Initial commit

Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai>
Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai>
Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.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: Valentin Berard <val@mistral.ai>
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin Torroba 2025-12-09 13:13:22 +01:00 committed by Quentin Torroba
commit fa15fc977b
200 changed files with 30484 additions and 0 deletions

0
vibe/cli/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,22 @@
from __future__ import annotations
from enum import StrEnum
from typing import Protocol
class CompletionResult(StrEnum):
IGNORED = "ignored"
HANDLED = "handled"
SUBMIT = "submit"
class CompletionView(Protocol):
def render_completion_suggestions(
self, suggestions: list[tuple[str, str]], selected_index: int
) -> None: ...
def clear_completion_suggestions(self) -> None: ...
def replace_completion_range(
self, start: int, end: int, replacement: str
) -> None: ...

View file

@ -0,0 +1,173 @@
from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from threading import Lock
from textual import events
from vibe.cli.autocompletion.base import CompletionResult, CompletionView
from vibe.core.autocompletion.completers import PathCompleter
MAX_SUGGESTIONS_COUNT = 10
class PathCompletionController:
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="path-completion")
def __init__(self, completer: PathCompleter, view: CompletionView) -> None:
self._completer = completer
self._view = view
self._suggestions: list[tuple[str, str]] = []
self._selected_index = 0
self._pending_future: Future | None = None
self._last_query: tuple[str, int] | None = None
self._query_lock = Lock()
def can_handle(self, text: str, cursor_index: int) -> bool:
if cursor_index < 0 or cursor_index > len(text):
return False
if cursor_index == 0:
return False
before_cursor = text[:cursor_index]
if "@" not in before_cursor:
return False
at_index = before_cursor.rfind("@")
if cursor_index <= at_index:
return False
fragment = before_cursor[at_index:cursor_index]
# fragment must not be empty (including @) and not contain any spaces
return bool(fragment) and " " not in fragment
def reset(self) -> None:
with self._query_lock:
if self._pending_future and not self._pending_future.done():
self._pending_future.cancel()
self._pending_future = None
self._last_query = None
if self._suggestions:
self._suggestions.clear()
self._selected_index = 0
self._view.clear_completion_suggestions()
def on_text_changed(self, text: str, cursor_index: int) -> None:
if not self.can_handle(text, cursor_index):
self.reset()
return
query = (text, cursor_index)
with self._query_lock:
if query == self._last_query:
return
if self._pending_future and not self._pending_future.done():
# NOTE (Vince): this is a "best effort" cancellation: it only works if the task
# hasn't started; once running in the thread pool, it cannot be cancelled
self._pending_future.cancel()
self._last_query = query
app = getattr(self._view, "app", None)
if app:
with self._query_lock:
self._pending_future = self._executor.submit(
self._compute_completions, text, cursor_index
)
self._pending_future.add_done_callback(
lambda f: self._handle_completion_result(f, query)
)
else:
suggestions = self._compute_completions(text, cursor_index)
self._update_suggestions(suggestions)
def _compute_completions(
self, text: str, cursor_index: int
) -> list[tuple[str, str]]:
return self._completer.get_completion_items(text, cursor_index)
def _handle_completion_result(self, future: Future, query: tuple[str, int]) -> None:
if future.cancelled():
return
try:
suggestions = future.result()
with self._query_lock:
if query == self._last_query:
self._update_suggestions(suggestions)
except Exception:
with self._query_lock:
self._pending_future = None
self._last_query = None
def _update_suggestions(self, suggestions: list[tuple[str, str]]) -> None:
if len(suggestions) > MAX_SUGGESTIONS_COUNT:
suggestions = suggestions[:MAX_SUGGESTIONS_COUNT]
app = getattr(self._view, "app", None)
if suggestions:
self._suggestions = suggestions
self._selected_index = 0
if app:
app.call_after_refresh(
self._view.render_completion_suggestions,
self._suggestions,
self._selected_index,
)
else:
self._view.render_completion_suggestions(
self._suggestions, self._selected_index
)
elif app:
app.call_after_refresh(self.reset)
else:
self.reset()
def on_key(
self, event: events.Key, text: str, cursor_index: int
) -> CompletionResult:
if not self._suggestions:
return CompletionResult.IGNORED
match event.key:
case "tab" | "enter":
if self._apply_selected_completion(text, cursor_index):
return CompletionResult.HANDLED
return CompletionResult.IGNORED
case "down":
self._move_selection(1)
return CompletionResult.HANDLED
case "up":
self._move_selection(-1)
return CompletionResult.HANDLED
case _:
return CompletionResult.IGNORED
def _move_selection(self, delta: int) -> None:
if not self._suggestions:
return
count = len(self._suggestions)
self._selected_index = (self._selected_index + delta) % count
self._view.render_completion_suggestions(
self._suggestions, self._selected_index
)
def _apply_selected_completion(self, text: str, cursor_index: int) -> bool:
if not self._suggestions:
return False
completion, _ = self._suggestions[self._selected_index]
replacement_range = self._completer.get_replacement_range(text, cursor_index)
if replacement_range is None:
self.reset()
return False
start, end = replacement_range
self._view.replace_completion_range(start, end, completion)
self.reset()
return True

View file

@ -0,0 +1,99 @@
from __future__ import annotations
from textual import events
from vibe.cli.autocompletion.base import CompletionResult, CompletionView
from vibe.core.autocompletion.completers import CommandCompleter
MAX_SUGGESTIONS_COUNT = 5
class SlashCommandController:
def __init__(self, completer: CommandCompleter, view: CompletionView) -> None:
self._completer = completer
self._view = view
self._suggestions: list[tuple[str, str]] = []
self._selected_index = 0
def can_handle(self, text: str, cursor_index: int) -> bool:
return text.startswith("/")
def reset(self) -> None:
if self._suggestions:
self._suggestions.clear()
self._selected_index = 0
self._view.clear_completion_suggestions()
def on_text_changed(self, text: str, cursor_index: int) -> None:
if cursor_index < 0 or cursor_index > len(text):
self.reset()
return
if not self.can_handle(text, cursor_index):
self.reset()
return
suggestions = self._completer.get_completion_items(text, cursor_index)
if len(suggestions) > MAX_SUGGESTIONS_COUNT:
suggestions = suggestions[:MAX_SUGGESTIONS_COUNT]
if suggestions:
self._suggestions = suggestions
self._selected_index = 0
self._view.render_completion_suggestions(
self._suggestions, self._selected_index
)
else:
self.reset()
def on_key(
self, event: events.Key, text: str, cursor_index: int
) -> CompletionResult:
if not self._suggestions:
return CompletionResult.IGNORED
match event.key:
case "tab":
if self._apply_selected_completion(text, cursor_index):
result = CompletionResult.HANDLED
else:
result = CompletionResult.IGNORED
case "enter":
if self._apply_selected_completion(text, cursor_index):
result = CompletionResult.SUBMIT
else:
result = CompletionResult.HANDLED
case "down":
self._move_selection(1)
result = CompletionResult.HANDLED
case "up":
self._move_selection(-1)
result = CompletionResult.HANDLED
case _:
result = CompletionResult.IGNORED
return result
def _move_selection(self, delta: int) -> None:
if not self._suggestions:
return
count = len(self._suggestions)
self._selected_index = (self._selected_index + delta) % count
self._view.render_completion_suggestions(
self._suggestions, self._selected_index
)
def _apply_selected_completion(self, text: str, cursor_index: int) -> bool:
if not self._suggestions:
return False
alias, _ = self._suggestions[self._selected_index]
replacement_range = self._completer.get_replacement_range(text, cursor_index)
if replacement_range is None:
self.reset()
return False
start, end = replacement_range
self._view.replace_completion_range(start, end, alias)
self.reset()
return True

34
vibe/cli/clipboard.py Normal file
View file

@ -0,0 +1,34 @@
from __future__ import annotations
import pyperclip
from textual.app import App
def copy_selection_to_clipboard(app: App) -> None:
selected_texts = []
for widget in app.query("*"):
if not hasattr(widget, "text_selection") or not widget.text_selection:
continue
selection = widget.text_selection
result = widget.get_selection(selection)
if not result:
continue
selected_text, _ = result
if selected_text.strip():
selected_texts.append(selected_text)
if not selected_texts:
return
combined_text = "\n".join(selected_texts)
try:
pyperclip.copy(combined_text)
app.notify("Selection added to clipboard", severity="information", timeout=2)
except Exception:
app.notify(
"Use Ctrl+c to copy selections in Vibe", severity="warning", timeout=3
)

98
vibe/cli/commands.py Normal file
View file

@ -0,0 +1,98 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class Command:
aliases: frozenset[str]
description: str
handler: str
exits: bool = False
class CommandRegistry:
def __init__(self, excluded_commands: list[str] | None = None) -> None:
if excluded_commands is None:
excluded_commands = []
self.commands = {
"help": Command(
aliases=frozenset(["/help", "/h"]),
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"]),
description="Edit config settings",
handler="_show_config",
),
"reload": Command(
aliases=frozenset(["/reload", "/r"]),
description="Reload configuration from disk",
handler="_reload_config",
),
"clear": Command(
aliases=frozenset(["/clear", "/reset"]),
description="Clear conversation history",
handler="_clear_history",
),
"log": Command(
aliases=frozenset(["/log", "/logpath"]),
description="Show path to current interaction log file",
handler="_show_log_path",
),
"compact": Command(
aliases=frozenset(["/compact", "/summarize"]),
description="Compact conversation history by summarizing",
handler="_compact_history",
),
"exit": Command(
aliases=frozenset(["/exit", "/quit", "/q"]),
description="Exit the application",
handler="_exit_app",
exits=True,
),
}
for command in excluded_commands:
self.commands.pop(command, None)
self._alias_map = {}
for cmd_name, cmd in self.commands.items():
for alias in cmd.aliases:
self._alias_map[alias] = cmd_name
def find_command(self, user_input: str) -> Command | None:
cmd_name = self._alias_map.get(user_input.lower().strip())
return self.commands.get(cmd_name) if cmd_name else None
def get_help_text(self) -> str:
lines: list[str] = [
"### Keyboard Shortcuts",
"",
"- `Enter` Submit message",
"- `Ctrl+J` / `Shift+Enter` Insert newline",
"- `Escape` Interrupt agent or close dialogs",
"- `Ctrl+C` Quit (or clear input if text present)",
"- `Ctrl+O` Toggle tool output view",
"- `Ctrl+T` Toggle todo view",
"- `Shift+Tab` Toggle auto-approve mode",
"",
"### Special Features",
"",
"- `!<command>` Execute bash command directly",
"- `@path/to/file/` Autocompletes file paths",
"",
"### Commands",
"",
]
for cmd in self.commands.values():
aliases = ", ".join(f"`{alias}`" for alias in sorted(cmd.aliases))
lines.append(f"- {aliases}: {cmd.description}")
return "\n".join(lines)

261
vibe/cli/entrypoint.py Normal file
View file

@ -0,0 +1,261 @@
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 (
CONFIG_FILE,
HISTORY_FILE,
INSTRUCTIONS_FILE,
MissingAPIKeyError,
MissingPromptFileError,
VibeConfig,
load_api_keys_from_env,
)
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(
"initial_prompt",
nargs="?",
metavar="PROMPT",
help="Initial prompt to start the interactive session with.",
)
parser.add_argument(
"-p",
"--prompt",
nargs="?",
const="",
metavar="TEXT",
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="Automatically approve all tool executions.",
)
parser.add_argument(
"--max-turns",
type=int,
metavar="N",
help="Maximum number of assistant turns "
"(only applies in programmatic mode with -p).",
)
parser.add_argument(
"--max-price",
type=float,
metavar="DOLLARS",
help="Maximum cost in dollars (only applies in programmatic mode with -p). "
"Session will be interrupted if cost exceeds this limit.",
)
parser.add_argument(
"--enabled-tools",
action="append",
metavar="TOOL",
help="Enable specific tools. In programmatic mode (-p), this disables "
"all other tools. "
"Can use exact names, glob patterns (e.g., 'bash*'), or "
"regex with 're:' prefix. Can be specified multiple times.",
)
parser.add_argument(
"--output",
type=str,
choices=["text", "json", "streaming"],
default="text",
help="Output format for programmatic mode (-p): 'text' "
"for human-readable (default), 'json' for all messages at end, "
"'streaming' for newline-delimited JSON per message.",
)
parser.add_argument(
"--agent",
metavar="NAME",
default=None,
help="Load agent configuration from ~/.vibe/agents/NAME.toml",
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
continuation_group = parser.add_mutually_exclusive_group()
continuation_group.add_argument(
"-c",
"--continue",
action="store_true",
dest="continue_session",
help="Continue from the most recent saved session",
)
continuation_group.add_argument(
"--resume",
metavar="SESSION_ID",
help="Resume a specific session by its ID (supports partial matching)",
)
return parser.parse_args()
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) -> 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()
args = parse_arguments()
if args.setup:
run_onboarding()
sys.exit(0)
try:
if not CONFIG_FILE.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.exists():
try:
INSTRUCTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
INSTRUCTIONS_FILE.touch()
except Exception as e:
rprint(f"[yellow]Could not create instructions file: {e}[/]")
if not HISTORY_FILE.exists():
try:
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.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)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,91 @@
from __future__ import annotations
import json
from pathlib import Path
class HistoryManager:
def __init__(self, history_file: Path, max_entries: int = 100) -> None:
self.history_file = history_file
self.max_entries = max_entries
self._entries: list[str] = []
self._current_index: int = -1
self._temp_input: str = ""
self._load_history()
def _load_history(self) -> None:
if not self.history_file.exists():
return
try:
with self.history_file.open("r", encoding="utf-8") as f:
entries = []
for raw_line in f:
raw_line = raw_line.rstrip("\n\r")
if not raw_line:
continue
try:
entry = json.loads(raw_line)
except json.JSONDecodeError:
entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :]
except (OSError, UnicodeDecodeError):
self._entries = []
def _save_history(self) -> None:
try:
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")
except OSError:
pass
def add(self, text: str) -> None:
text = text.strip()
if not text or text.startswith("/"):
return
if self._entries and self._entries[-1] == text:
return
self._entries.append(text)
if len(self._entries) > self.max_entries:
self._entries = self._entries[-self.max_entries :]
self._save_history()
self.reset_navigation()
def get_previous(self, current_input: str, prefix: str = "") -> str | None:
if not self._entries:
return None
if self._current_index == -1:
self._temp_input = current_input
self._current_index = len(self._entries)
for i in range(self._current_index - 1, -1, -1):
if self._entries[i].startswith(prefix):
self._current_index = i
return self._entries[i]
return None
def get_next(self, prefix: str = "") -> str | None:
if self._current_index == -1:
return None
for i in range(self._current_index + 1, len(self._entries)):
if self._entries[i].startswith(prefix):
self._current_index = i
return self._entries[i]
result = self._temp_input
self.reset_navigation()
return result
def reset_navigation(self) -> None:
self._current_index = -1
self._temp_input = ""

View file

1099
vibe/cli/textual_ui/app.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,681 @@
Screen {
background: $background;
}
#chat {
height: 1fr;
width: 100%;
background: $background;
padding: 0 2 0 2;
}
#loading-area {
height: auto;
width: 100%;
background: $background;
padding: 1 2 0 2;
layout: horizontal;
align: left middle;
}
#loading-area-content {
width: 1fr;
height: auto;
align: left middle;
}
#todo-area {
height: auto;
max-height: 8;
width: 100%;
background: $background;
margin: 0;
padding: 0;
overflow-y: auto;
text-align: left;
margin-top: 0;
}
#bottom-app-container {
height: auto;
width: 100%;
background: $background;
}
#bottom-bar {
height: auto;
width: 100%;
background: $background;
padding: 0 2 1 2;
align: left middle;
layout: horizontal;
}
#spacer {
width: 1fr;
height: auto;
background: transparent;
}
#messages {
width: 100%;
height: auto;
text-align: left;
margin-top: 1;
margin-bottom: 0;
}
#input-container {
height: auto;
width: 100%;
background: $background;
padding: 0;
margin: 0 2 1 2;
}
#completion-popup {
width: 100%;
padding: 1 1 1 1;
color: $text;
}
#input-box {
height: auto;
width: 100%;
background: $background;
border: round $foreground-muted;
padding: 0 1;
&.border-warning {
border: round $warning;
}
}
#input-body {
height: auto;
}
#prompt {
width: auto;
background: transparent;
color: $primary;
text-style: bold;
padding: 0 1 0 0;
}
#input {
width: 1fr;
height: auto;
max-height: 16;
background: transparent;
color: $text;
border: none;
padding: 0;
}
ToastRack {
align: left bottom;
padding: 0 2;
margin: 0 2 6 2;
}
Markdown MarkdownFence {
overflow-x: auto;
scrollbar-size-horizontal: 1;
max-width: 95%;
}
.user-message {
margin-top: 1;
width: 100%;
height: auto;
&:first-child {
margin-top: 0;
}
&.pending {
.user-message-prompt,
.user-message-content {
opacity: 0.7;
text-style: italic;
}
}
}
.user-message-container {
width: 100%;
height: auto;
}
.user-message-prompt {
width: auto;
height: auto;
color: $primary;
text-style: bold;
}
.user-message-content {
width: 1fr;
height: auto;
color: $foreground;
text-style: bold;
}
.assistant-message {
margin-top: 1;
width: 100%;
height: auto;
}
.assistant-message-container {
width: 100%;
height: auto;
align: left top;
}
.assistant-message-dot {
width: auto;
height: auto;
color: $foreground;
}
.assistant-message-content {
width: 1fr;
height: auto;
padding: 0;
Markdown {
width: 100%;
height: auto;
padding: 0;
margin: 0;
}
}
.interrupt-message {
margin-top: 0;
margin-bottom: 0;
margin-left: 2;
height: auto;
padding: 1 2;
background: $warning 10%;
color: $text-warning;
}
.error-message {
margin-top: 1;
height: auto;
padding: 1 2;
background: $error 10%;
color: $error;
text-style: bold;
}
.bash-output-message {
margin-top: 1;
width: 100%;
height: auto;
}
.bash-output-container {
width: 100%;
height: auto;
padding: 1 2;
background: $surface;
}
.bash-cwd-line,
.bash-command-line {
width: 100%;
height: auto;
margin-bottom: 1;
align: left middle;
}
.bash-cwd {
width: auto;
color: $text-muted;
}
.bash-cwd-spacer,
.bash-command-spacer {
width: 1fr;
}
.bash-chevron {
width: auto;
color: $primary;
text-style: bold;
}
.bash-command {
width: auto;
color: $foreground;
}
.bash-output {
width: 100%;
color: $foreground;
}
.bash-exit-success {
width: auto;
color: $text-success;
}
.bash-exit-failure {
width: auto;
color: $text-error;
}
.bash-exit-code {
width: auto;
color: $text-muted;
}
.unknown-event {
height: auto;
color: $text-muted;
}
BlinkingMessage {
width: 100%;
height: auto;
Horizontal {
width: 100%;
height: auto;
}
}
.blink-dot {
width: auto;
height: auto;
color: $foreground;
&.success {
color: $text-success;
}
&.error {
color: $text-error;
}
}
.blink-text {
width: 1fr;
height: auto;
color: $foreground;
}
.compact-message {
width: 100%;
height: auto;
margin-top: 1;
}
.tool-call {
width: 100%;
height: auto;
margin-top: 1;
}
.tool-result {
width: 100%;
height: auto;
margin-top: 0;
margin-left: 2;
padding: 1 2;
background: $surface;
color: $foreground;
&.error-text {
background: $error 10%;
color: $text-error;
}
&.warning-text {
background: $warning 10%;
color: $text-warning;
}
}
.tool-call-widget {
width: 100%;
height: auto;
color: $foreground;
}
.tool-call-detail {
height: auto;
color: $text-muted;
}
.tool-result-widget {
width: 100%;
height: auto;
color: $foreground;
Static {
height: auto;
}
}
.tool-result-detail {
height: auto;
margin-top: 0;
color: $text-muted;
}
.tool-result-error {
color: $text-error;
}
.tool-result-warning {
color: $text-warning;
}
.diff-header {
height: auto;
color: $text-muted;
text-style: bold;
}
.diff-removed {
height: auto;
color: $text-error;
}
.diff-added {
height: auto;
color: $text-success;
}
.diff-range {
height: auto;
color: $primary;
}
.diff-context {
height: auto;
color: $text-muted;
}
.todo-empty {
height: auto;
color: $text-muted;
}
.todo-pending {
height: auto;
color: $foreground;
}
.todo-in_progress {
height: auto;
color: $warning;
}
.todo-completed {
height: auto;
color: $success;
}
.todo-cancelled {
height: auto;
color: $text-muted;
}
#todo-area .tool-result {
margin-left: 0;
background: $surface;
}
.loading-widget {
width: 100%;
height: auto;
}
.loading-container {
width: auto;
height: auto;
}
.loading-star {
width: auto;
height: auto;
color: $warning;
}
.loading-status {
width: auto;
height: auto;
}
.loading-char {
width: auto;
height: auto;
}
.loading-ellipsis {
width: auto;
height: auto;
color: $text-muted;
}
.loading-hint {
width: auto;
height: auto;
color: $foreground;
}
WelcomeBanner {
width: 100%;
height: auto;
border: round $surface;
border-title-align: center;
text-align: center;
content-align: center middle;
padding: 2 4;
margin: 1 1 0 1;
color: $foreground;
.muted {
color: $text-muted;
}
}
#config-app {
width: 100%;
height: auto;
background: $background;
border: round $foreground-muted;
padding: 0 1;
margin: 0 2 1 2;
}
#config-content {
width: 100%;
height: auto;
}
.settings-title {
height: auto;
text-style: bold;
color: $primary;
}
.settings-option {
height: auto;
color: $foreground;
}
.settings-cursor-selected {
color: $primary;
text-style: bold;
}
.settings-label-selected {
color: $text;
text-style: bold;
}
.settings-value-toggle-on-selected {
color: $text-success;
text-style: bold;
}
.settings-value-toggle-on-unselected {
color: $success;
}
.settings-value-toggle-off {
color: $text-muted;
}
.settings-value-cycle-selected {
color: $primary;
text-style: bold;
}
.settings-value-cycle-unselected {
color: $primary;
}
.settings-help {
height: auto;
color: $text-muted;
}
#approval-app {
width: 100%;
height: auto;
max-height: 16;
background: $background;
border: round $foreground-muted;
padding: 0 1;
margin: 0 2 1 2;
}
#approval-content {
width: 100%;
height: auto;
}
.approval-tool-info-scroll {
width: 100%;
height: auto;
max-height: 8;
}
.approval-title {
height: auto;
text-style: bold;
color: $warning;
}
.approval-tool-info-container {
width: 100%;
height: auto;
}
.tool-approval-widget {
width: 100%;
height: auto;
Static {
width: 100%;
height: auto;
}
Vertical {
width: 100%;
height: auto;
}
}
.approval-option {
height: auto;
color: $foreground;
}
.approval-cursor-selected {
&.approval-option-yes {
color: $text-success;
text-style: bold;
}
&.approval-option-no {
color: $text-error;
text-style: bold;
}
}
.approval-option-selected {
&.approval-option-yes {
color: $success;
}
&.approval-option-no {
color: $error;
}
}
.approval-help {
height: auto;
color: $text-muted;
}
.approval-description {
height: auto;
color: $foreground;
}
.code-block {
height: auto;
color: $foreground;
background: $surface;
padding: 1;
}
Horizontal {
width: 100%;
height: auto;
}
ModeIndicator {
width: auto;
height: auto;
background: transparent;
padding: 0;
margin: 0 0 0 1;
color: $warning;
align: left middle;
&.mode-on {
color: $warning;
}
&.mode-off {
color: $text-muted;
}
}
PathDisplay {
width: auto;
height: auto;
background: transparent;
padding: 0;
margin: 0;
color: $primary;
}
ContextProgress {
width: auto;
height: auto;
background: transparent;
padding: 0;
margin: 0;
color: $foreground;
}

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
__all__ = ["EventHandler"]

View file

@ -0,0 +1,158 @@
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.messages import AssistantMessage
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.types import (
AssistantEvent,
BaseEvent,
CompactEndEvent,
CompactStartEvent,
ToolCallEvent,
ToolResultEvent,
)
from vibe.core.utils import TaggedText
if TYPE_CHECKING:
from vibe.cli.textual_ui.widgets.loading import LoadingWidget
class EventHandler:
def __init__(
self,
mount_callback: Callable,
scroll_callback: Callable,
todo_area_callback: Callable,
get_tools_collapsed: Callable[[], bool],
get_todos_collapsed: Callable[[], bool],
) -> None:
self.mount_callback = mount_callback
self.scroll_callback = scroll_callback
self.todo_area_callback = todo_area_callback
self.get_tools_collapsed = get_tools_collapsed
self.get_todos_collapsed = get_todos_collapsed
self.current_tool_call: ToolCallMessage | None = None
self.current_compact: CompactMessage | None = None
self.tool_results: list[ToolResultMessage] = []
async def handle_event(
self,
event: BaseEvent,
loading_active: bool = False,
loading_widget: LoadingWidget | None = None,
) -> ToolCallMessage | None:
match event:
case ToolCallEvent():
return await self._handle_tool_call(event, loading_widget)
case ToolResultEvent():
sanitized_event = self._sanitize_event(event)
await self._handle_tool_result(sanitized_event)
return None
case AssistantEvent():
await self._handle_assistant_message(event)
return None
case CompactStartEvent():
await self._handle_compact_start()
return None
case CompactEndEvent():
await self._handle_compact_end(event)
return None
case _:
await self._handle_unknown_event(event)
return None
def _sanitize_event(self, event: ToolResultEvent) -> ToolResultEvent:
if isinstance(event, ToolResultEvent):
return ToolResultEvent(
tool_name=event.tool_name,
tool_class=event.tool_class,
result=event.result,
error=TaggedText.from_string(event.error).message
if event.error
else None,
skipped=event.skipped,
skip_reason=TaggedText.from_string(event.skip_reason).message
if event.skip_reason
else None,
duration=event.duration,
tool_call_id=event.tool_call_id,
)
return event
async def _handle_tool_call(
self, event: ToolCallEvent, loading_widget: LoadingWidget | None = None
) -> ToolCallMessage | None:
tool_call = ToolCallMessage(event)
if loading_widget and event.tool_class:
from vibe.core.tools.ui import ToolUIDataAdapter
adapter = ToolUIDataAdapter(event.tool_class)
status_text = adapter.get_status_text()
loading_widget.set_status(status_text)
# Don't show todo in messages
if event.tool_name != "todo":
await self.mount_callback(tool_call)
self.current_tool_call = tool_call
return tool_call
async def _handle_tool_result(self, event: ToolResultEvent) -> None:
if event.tool_name == "todo":
todos_collapsed = self.get_todos_collapsed()
tool_result = ToolResultMessage(
event, self.current_tool_call, collapsed=todos_collapsed
)
# Show in todo area
todo_area = self.todo_area_callback()
await todo_area.remove_children()
await todo_area.mount(tool_result)
else:
tools_collapsed = self.get_tools_collapsed()
tool_result = ToolResultMessage(
event, self.current_tool_call, collapsed=tools_collapsed
)
await self.mount_callback(tool_result)
self.tool_results.append(tool_result)
self.current_tool_call = None
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
await self.mount_callback(AssistantMessage(event.content))
async def _handle_compact_start(self) -> None:
compact_msg = CompactMessage()
self.current_compact = compact_msg
await self.mount_callback(compact_msg)
async def _handle_compact_end(self, event: CompactEndEvent) -> None:
if self.current_compact:
self.current_compact.set_complete(
old_tokens=event.old_context_tokens, new_tokens=event.new_context_tokens
)
self.current_compact = None
async def _handle_unknown_event(self, event: BaseEvent) -> None:
await self.mount_callback(
Static(str(event), markup=False, classes="unknown-event")
)
def stop_current_tool_call(self) -> None:
if self.current_tool_call:
self.current_tool_call.stop_blinking()
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 = None
def get_last_tool_result(self) -> ToolResultMessage | None:
return self.tool_results[-1] if self.tool_results else None

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from vibe.cli.textual_ui.renderers.tool_renderers import get_renderer
__all__ = ["get_renderer"]

View file

@ -0,0 +1,216 @@
from __future__ import annotations
import difflib
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from vibe.core.tools.ui import ToolResultDisplay
from vibe.cli.textual_ui.widgets.tool_widgets import (
BashApprovalWidget,
BashResultWidget,
GrepApprovalWidget,
GrepResultWidget,
ReadFileApprovalWidget,
ReadFileResultWidget,
SearchReplaceApprovalWidget,
SearchReplaceResultWidget,
TodoApprovalWidget,
TodoResultWidget,
ToolApprovalWidget,
ToolResultWidget,
WriteFileApprovalWidget,
WriteFileResultWidget,
)
class ToolRenderer:
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
return ToolApprovalWidget, tool_args
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[ToolResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"details": self._clean_details(display.details),
"warnings": display.warnings,
}
return ToolResultWidget, data
def _clean_details(self, details: dict) -> dict:
clean = {}
for key, value in details.items():
if value is None or value in ("", []):
continue
value_str = str(value).strip().replace("\n", " ").replace("\r", "")
value_str = " ".join(value_str.split())
if value_str:
clean[key] = value_str
return clean
class BashRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[BashApprovalWidget], dict[str, Any]]:
data = {
"command": tool_args.get("command", ""),
"description": tool_args.get("description", ""),
}
return BashApprovalWidget, data
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[BashResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"details": self._clean_details(display.details),
"warnings": display.warnings,
}
return BashResultWidget, data
class WriteFileRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[WriteFileApprovalWidget], dict[str, Any]]:
data = {
"path": tool_args.get("path", ""),
"content": tool_args.get("content", ""),
"file_extension": tool_args.get("file_extension", "text"),
}
return WriteFileApprovalWidget, data
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[WriteFileResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"path": display.details.get("path", ""),
"bytes_written": display.details.get("bytes_written"),
"content": display.details.get("content", ""),
"file_extension": display.details.get("file_extension", "text"),
}
return WriteFileResultWidget, data
class SearchReplaceRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[SearchReplaceApprovalWidget], dict[str, Any]]:
file_path = tool_args.get("file_path", "")
content = str(tool_args.get("content", ""))
diff_lines = self._parse_search_replace_blocks(content)
data = {"file_path": file_path, "diff_lines": diff_lines}
return SearchReplaceApprovalWidget, data
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[SearchReplaceResultWidget], dict[str, Any]]:
diff_lines = self._parse_search_replace_blocks(
display.details.get("content", "")
)
data = {
"success": display.success,
"message": display.message,
"diff_lines": diff_lines if not collapsed else [],
}
return SearchReplaceResultWidget, data
def _parse_search_replace_blocks(self, content: str) -> list[str]:
if "<<<<<<< SEARCH" not in content:
return [content]
try:
sections = content.split("<<<<<<< SEARCH")
rest = sections[1].split("=======")
search_section = rest[0].strip()
replace_part = rest[1].split(">>>>>>> REPLACE")
replace_section = replace_part[0].strip()
search_lines = search_section.split("\n")
replace_lines = replace_section.split("\n")
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
return list(diff)[2:] # Skip file headers
except (IndexError, AttributeError):
return [content[:500]]
class TodoRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[TodoApprovalWidget], dict[str, Any]]:
data = {"description": tool_args.get("description", "")}
return TodoApprovalWidget, data
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[TodoResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"todos_by_status": display.details.get("todos_by_status", {}),
}
return TodoResultWidget, data
class ReadFileRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[ReadFileApprovalWidget], dict[str, Any]]:
return ReadFileApprovalWidget, tool_args
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[ReadFileResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"path": display.details.get("path", ""),
"warnings": display.warnings,
"content": display.details.get("content", "") if not collapsed else "",
"file_extension": display.details.get("file_extension", "text"),
}
return ReadFileResultWidget, data
class GrepRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[GrepApprovalWidget], dict[str, Any]]:
return GrepApprovalWidget, tool_args
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[GrepResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"warnings": display.warnings,
"matches": display.details.get("matches", "") if not collapsed else "",
}
return GrepResultWidget, data
_RENDERER_REGISTRY: dict[str, type[ToolRenderer]] = {
"write_file": WriteFileRenderer,
"search_replace": SearchReplaceRenderer,
"todo": TodoRenderer,
"read_file": ReadFileRenderer,
"bash": BashRenderer,
"grep": GrepRenderer,
}
def get_renderer(tool_name: str) -> ToolRenderer:
renderer_class = _RENDERER_REGISTRY.get(tool_name, ToolRenderer)
return renderer_class()

View file

View file

@ -0,0 +1,196 @@
from __future__ import annotations
from typing import ClassVar
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical, VerticalScroll
from textual.message import Message
from textual.widgets import Static
from vibe.cli.textual_ui.renderers import get_renderer
from vibe.core.config import VibeConfig
class ApprovalApp(Container):
can_focus = True
can_focus_children = False
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("1", "select_1", "Yes", show=False),
Binding("y", "select_1", "Yes", show=False),
Binding("2", "select_2", "Always Tool Session", show=False),
Binding("3", "select_3", "No", show=False),
Binding("n", "select_3", "No", show=False),
]
class ApprovalGranted(Message):
def __init__(self, tool_name: str, tool_args: dict) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
class ApprovalGrantedAlwaysTool(Message):
def __init__(
self, tool_name: str, tool_args: dict, save_permanently: bool
) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
self.save_permanently = save_permanently
class ApprovalRejected(Message):
def __init__(self, tool_name: str, tool_args: dict) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
def __init__(
self, tool_name: str, tool_args: dict, workdir: str, 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
self.title_widget: Static | None = None
self.tool_info_container: Vertical | None = None
self.option_widgets: list[Static] = []
self.help_widget: Static | None = None
def compose(self) -> ComposeResult:
with Vertical(id="approval-content"):
self.title_widget = Static(
f"{self.tool_name} command", classes="approval-title"
)
yield self.title_widget
with VerticalScroll(classes="approval-tool-info-scroll"):
self.tool_info_container = Vertical(
classes="approval-tool-info-container"
)
yield self.tool_info_container
yield Static("")
for _ in range(3):
widget = Static("", classes="approval-option")
self.option_widgets.append(widget)
yield widget
yield Static("")
self.help_widget = Static(
"↑↓ navigate Enter select ESC reject", classes="approval-help"
)
yield self.help_widget
async def on_mount(self) -> None:
await self._update_tool_info()
self._update_options()
self.focus()
async def _update_tool_info(self) -> None:
if not self.tool_info_container:
return
renderer = get_renderer(self.tool_name)
widget_class, data = renderer.get_approval_widget(self.tool_args)
await self.tool_info_container.remove_children()
approval_widget = widget_class(data)
await self.tool_info_container.mount(approval_widget)
def _update_options(self) -> None:
options = [
("Yes", "yes"),
(f"Yes and always allow {self.tool_name} this session", "yes"),
("No and tell the agent what to do instead", "no"),
]
for idx, ((text, color_type), widget) in enumerate(
zip(options, self.option_widgets, strict=True)
):
is_selected = idx == self.selected_option
cursor = " " if is_selected else " "
option_text = f"{cursor}{idx + 1}. {text}"
widget.update(option_text)
widget.remove_class("approval-cursor-selected")
widget.remove_class("approval-option-selected")
widget.remove_class("approval-option-yes")
widget.remove_class("approval-option-no")
if is_selected:
widget.add_class("approval-cursor-selected")
if color_type == "yes":
widget.add_class("approval-option-yes")
else:
widget.add_class("approval-option-no")
else:
widget.add_class("approval-option-selected")
if color_type == "yes":
widget.add_class("approval-option-yes")
else:
widget.add_class("approval-option-no")
def action_move_up(self) -> None:
self.selected_option = (self.selected_option - 1) % 3
self._update_options()
def action_move_down(self) -> None:
self.selected_option = (self.selected_option + 1) % 3
self._update_options()
def action_select(self) -> None:
self._handle_selection(self.selected_option)
def action_select_1(self) -> None:
self.selected_option = 0
self._handle_selection(0)
def action_select_2(self) -> None:
self.selected_option = 1
self._handle_selection(1)
def action_select_3(self) -> None:
self.selected_option = 2
self._handle_selection(2)
def action_reject(self) -> None:
self.selected_option = 2
self._handle_selection(2)
def _handle_selection(self, option: int) -> None:
match option:
case 0:
self.post_message(
self.ApprovalGranted(
tool_name=self.tool_name, tool_args=self.tool_args
)
)
case 1:
self.post_message(
self.ApprovalGrantedAlwaysTool(
tool_name=self.tool_name,
tool_args=self.tool_args,
save_permanently=False,
)
)
case 2:
self.post_message(
self.ApprovalRejected(
tool_name=self.tool_name, tool_args=self.tool_args
)
)
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View file

@ -0,0 +1,67 @@
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

@ -0,0 +1,7 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
__all__ = ["ChatInputBody", "ChatInputContainer", "ChatTextArea"]

View file

@ -0,0 +1,194 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.message import Message
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
class ChatInputBody(Widget):
class Submitted(Message):
def __init__(self, value: str) -> None:
self.value = value
super().__init__()
def __init__(self, history_file: Path | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.input_widget: ChatTextArea | None = None
self.prompt_widget: Static | None = None
if history_file:
self.history = HistoryManager(history_file)
else:
self.history = None
self._completion_reset: Callable[[], None] | None = None
def compose(self) -> ComposeResult:
with Horizontal():
self.prompt_widget = Static(">", id="prompt")
yield self.prompt_widget
self.input_widget = ChatTextArea(placeholder="Ask anything...", id="input")
yield self.input_widget
def on_mount(self) -> None:
if self.input_widget:
self.input_widget.focus()
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(">")
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)
first_line = text.split("\n")[0] if text else ""
col = cursor_col if cursor_col is not None else len(first_line)
cursor_pos = (0, col)
self.input_widget.move_cursor(cursor_pos)
self.input_widget._last_cursor_col = col
self.input_widget._cursor_pos_after_load = cursor_pos
self.input_widget._cursor_moved_since_load = False
self._update_prompt()
self._notify_completion_reset()
def on_chat_text_area_history_previous(
self, event: ChatTextArea.HistoryPrevious
) -> None:
if not self.history or not self.input_widget:
return
if self.history._current_index == -1:
self.input_widget._original_text = self.input_widget.text
if (
self.history._current_index != -1
and self.input_widget._last_used_prefix is not None
and self.input_widget._last_used_prefix != event.prefix
):
self.history.reset_navigation()
self.input_widget._last_used_prefix = event.prefix
previous = self.history.get_previous(
self.input_widget._original_text, prefix=event.prefix
)
if previous is not None:
self._load_history_entry(previous)
def on_chat_text_area_history_next(self, event: ChatTextArea.HistoryNext) -> None:
if not self.history or not self.input_widget:
return
if self.history._current_index == -1:
return
if (
self.input_widget._last_used_prefix is not None
and self.input_widget._last_used_prefix != event.prefix
):
self.history.reset_navigation()
self.input_widget._last_used_prefix = event.prefix
has_next = any(
self.history._entries[i].startswith(event.prefix)
for i in range(self.history._current_index + 1, len(self.history._entries))
)
original_matches = self.input_widget._original_text.startswith(event.prefix)
if has_next or original_matches:
next_entry = self.history.get_next(prefix=event.prefix)
if next_entry is not None:
cursor_col = (
len(event.prefix) if self.history._current_index == -1 else None
)
self._load_history_entry(next_entry, cursor_col=cursor_col)
def on_chat_text_area_history_reset(self, event: ChatTextArea.HistoryReset) -> None:
if self.history:
self.history.reset_navigation()
if self.input_widget:
self.input_widget._original_text = ""
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()
if not self.input_widget:
return
value = event.value.strip()
if value:
if self.history:
self.history.add(value)
self.history.reset_navigation()
self.input_widget.clear_text()
self._update_prompt()
self._notify_completion_reset()
self.post_message(self.Submitted(value))
@property
def value(self) -> str:
return self.input_widget.text if self.input_widget else ""
@value.setter
def value(self, text: str) -> None:
if self.input_widget:
self.input_widget.load_text(text)
self._update_prompt()
def focus_input(self) -> None:
if self.input_widget:
self.input_widget.focus()
def set_completion_reset_callback(
self, callback: Callable[[], None] | None
) -> None:
self._completion_reset = callback
def _notify_completion_reset(self) -> None:
if self._completion_reset:
self._completion_reset()
def replace_input(self, text: str, cursor_offset: int | None = None) -> None:
if not self.input_widget:
return
self.input_widget.load_text(text)
self.input_widget.reset_history_state()
self._update_prompt()
if cursor_offset is not None:
self.input_widget.set_cursor_offset(max(0, min(cursor_offset, len(text))))

View file

@ -0,0 +1,58 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Protocol
from textual import events
from vibe.cli.autocompletion.base import CompletionResult
class CompletionController(Protocol):
def can_handle(self, text: str, cursor_index: int) -> bool: ...
def on_text_changed(self, text: str, cursor_index: int) -> None: ...
def on_key(
self, event: events.Key, text: str, cursor_index: int
) -> CompletionResult: ...
def reset(self) -> None: ...
class MultiCompletionManager:
def __init__(self, controllers: Sequence[CompletionController]) -> None:
self._controllers = list(controllers)
self._active: CompletionController | None = None
def on_text_changed(self, text: str, cursor_index: int) -> None:
candidate = None
for controller in self._controllers:
if controller.can_handle(text, cursor_index):
candidate = controller
break
if candidate is None:
if self._active is not None:
self._active.reset()
self._active = None
return
if candidate is not self._active:
if self._active is not None:
self._active.reset()
self._active = candidate
candidate.on_text_changed(text, cursor_index)
def on_key(
self, event: events.Key, text: str, cursor_index: int
) -> CompletionResult:
if self._active is None:
return CompletionResult.IGNORED
return self._active.on_key(event, text, cursor_index)
def reset(self) -> None:
if self._active is not None:
self._active.reset()
self._active = None

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Any
from rich.text import Text
from textual.widgets import Static
class CompletionPopup(Static):
def __init__(self, **kwargs: Any) -> None:
super().__init__("", id="completion-popup", **kwargs)
self.styles.display = "none"
self.can_focus = False
def update_suggestions(
self, suggestions: list[tuple[str, str]], selected: int
) -> None:
if not suggestions:
self.hide()
return
text = Text()
for idx, (label, description) in enumerate(suggestions):
if idx:
text.append("\n")
label_style = "bold reverse" if idx == selected else "bold"
description_style = "italic" if idx == selected else "dim"
text.append(label, style=label_style)
if description:
text.append(" ")
text.append(description, style=description_style)
self.update(text)
self.show()
def hide(self) -> None:
self.update("")
self.styles.display = "none"
def show(self) -> None:
self.styles.display = "block"

View file

@ -0,0 +1,157 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.message import Message
from vibe.cli.autocompletion.path_completion import PathCompletionController
from vibe.cli.autocompletion.slash_command import SlashCommandController
from vibe.cli.commands import CommandRegistry
from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
class ChatInputContainer(Vertical):
ID_INPUT_BOX = "input-box"
BORDER_WARNING_CLASS = "border-warning"
class Submitted(Message):
def __init__(self, value: str) -> None:
self.value = value
super().__init__()
def __init__(
self,
history_file: Path | None = None,
command_registry: CommandRegistry | None = None,
show_warning: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._history_file = history_file
self._command_registry = command_registry or CommandRegistry()
self._show_warning = show_warning
command_entries = [
(alias, command.description)
for command in self._command_registry.commands.values()
for alias in sorted(command.aliases)
]
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(command_entries), self),
PathCompletionController(PathCompleter(), self),
])
self._completion_popup: CompletionPopup | None = None
self._body: ChatInputBody | None = None
def compose(self) -> ComposeResult:
self._completion_popup = CompletionPopup()
yield self._completion_popup
with Vertical(
id=self.ID_INPUT_BOX, classes="border-warning" if self._show_warning else ""
):
self._body = ChatInputBody(history_file=self._history_file, id="input-body")
yield self._body
def on_mount(self) -> None:
if not self._body:
return
self._body.set_completion_reset_callback(self._completion_manager.reset)
if self._body.input_widget:
self._body.input_widget.set_completion_manager(self._completion_manager)
self._body.focus_input()
@property
def input_widget(self) -> ChatTextArea | None:
return self._body.input_widget if self._body else None
@property
def value(self) -> str:
if not self._body:
return ""
return self._body.value
@value.setter
def value(self, text: str) -> None:
if not self._body:
return
self._body.value = text
widget = self._body.input_widget
if widget:
self._completion_manager.on_text_changed(
widget.text, widget.get_cursor_offset()
)
def focus_input(self) -> None:
if self._body:
self._body.focus_input()
def render_completion_suggestions(
self, suggestions: list[tuple[str, str]], selected_index: int
) -> None:
if self._completion_popup:
self._completion_popup.update_suggestions(suggestions, selected_index)
def clear_completion_suggestions(self) -> None:
if self._completion_popup:
self._completion_popup.hide()
def _format_insertion(self, replacement: str, suffix: str) -> str:
"""Format the insertion text with appropriate spacing.
Args:
replacement: The text to insert
suffix: The text that follows the insertion point
Returns:
The formatted insertion text with spacing if needed
"""
if replacement.startswith("@"):
if replacement.endswith("/"):
return replacement
# For @-prefixed completions, add space unless suffix starts with whitespace
return replacement + (" " if not suffix or not suffix[0].isspace() else "")
# For other completions, add space only if suffix exists and doesn't start with whitespace
return replacement + (" " if suffix and not suffix[0].isspace() else "")
def replace_completion_range(self, start: int, end: int, replacement: str) -> None:
widget = self.input_widget
if not widget or not self._body:
return
text = widget.text
start = max(0, min(start, len(text)))
end = max(start, min(end, len(text)))
prefix = text[:start]
suffix = text[end:]
insertion = self._format_insertion(replacement, suffix)
new_text = f"{prefix}{insertion}{suffix}"
self._body.replace_input(new_text, cursor_offset=start + len(insertion))
def on_chat_input_body_submitted(self, event: ChatInputBody.Submitted) -> None:
event.stop()
self.post_message(self.Submitted(event.value))
def set_show_warning(self, show_warning: bool) -> None:
self._show_warning = show_warning
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)

View file

@ -0,0 +1,246 @@
from __future__ import annotations
from typing import Any, ClassVar
from textual import events
from textual.binding import Binding
from textual.message import Message
from textual.widgets import TextArea
from vibe.cli.autocompletion.base import CompletionResult
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
class ChatTextArea(TextArea):
BINDINGS: ClassVar[list[Binding]] = [
Binding(
"shift+enter,ctrl+j",
"insert_newline",
"New Line",
show=False,
priority=True,
)
]
class Submitted(Message):
def __init__(self, value: str) -> None:
self.value = value
super().__init__()
class HistoryPrevious(Message):
def __init__(self, prefix: str) -> None:
self.prefix = prefix
super().__init__()
class HistoryNext(Message):
def __init__(self, prefix: str) -> None:
self.prefix = prefix
super().__init__()
class HistoryReset(Message):
"""Message sent when history navigation should be reset."""
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._history_prefix: str | None = None
self._last_text = ""
self._navigating_history = False
self._last_cursor_col: int = 0
self._last_used_prefix: str | None = None
self._original_text: str = ""
self._cursor_pos_after_load: tuple[int, int] | None = None
self._cursor_moved_since_load: bool = False
self._completion_manager: MultiCompletionManager | None = None
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)
def on_click(self, event: events.Click) -> None:
self._mark_cursor_moved_if_needed()
def action_insert_newline(self) -> None:
self.insert("\n")
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not self._navigating_history and self.text != self._last_text:
self._reset_prefix()
self._original_text = ""
self._cursor_pos_after_load = None
self._cursor_moved_since_load = False
self.post_message(self.HistoryReset())
self._last_text = self.text
was_navigating_history = self._navigating_history
self._navigating_history = False
if self._completion_manager and not was_navigating_history:
self._completion_manager.on_text_changed(
self.text, self.get_cursor_offset()
)
def _reset_prefix(self) -> None:
self._history_prefix = None
self._last_used_prefix = None
def _mark_cursor_moved_if_needed(self) -> None:
if (
self._cursor_pos_after_load is not None
and not self._cursor_moved_since_load
and self.cursor_location != self._cursor_pos_after_load
):
self._cursor_moved_since_load = True
self._reset_prefix()
def _get_prefix_up_to_cursor(self) -> str:
cursor_row, cursor_col = self.cursor_location
lines = self.text.split("\n")
if cursor_row < len(lines):
return lines[cursor_row][:cursor_col]
return ""
def _handle_history_up(self) -> bool:
cursor_row, cursor_col = self.cursor_location
if cursor_row == 0:
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
self._reset_prefix()
self._last_cursor_col = 0
if self._history_prefix is None:
self._history_prefix = self._get_prefix_up_to_cursor()
self._navigating_history = True
self.post_message(self.HistoryPrevious(self._history_prefix))
return True
return False
def _handle_history_down(self) -> bool:
cursor_row, cursor_col = self.cursor_location
total_lines = self.text.count("\n") + 1
on_first_line_unmoved = cursor_row == 0 and not self._cursor_moved_since_load
on_last_line = cursor_row == total_lines - 1
should_intercept = (
on_first_line_unmoved and self._history_prefix is not None
) or on_last_line
if not should_intercept:
return False
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
self._reset_prefix()
self._last_cursor_col = 0
if self._history_prefix is None:
self._history_prefix = self._get_prefix_up_to_cursor()
self._navigating_history = True
self.post_message(self.HistoryNext(self._history_prefix))
return True
async def _on_key(self, event: events.Key) -> None:
self._mark_cursor_moved_if_needed()
manager = self._completion_manager
if manager:
match manager.on_key(event, self.text, self.get_cursor_offset()):
case CompletionResult.HANDLED:
event.prevent_default()
event.stop()
return
case CompletionResult.SUBMIT:
event.prevent_default()
event.stop()
value = self.text.strip()
if value:
self._reset_prefix()
self.post_message(self.Submitted(value))
return
if event.key == "enter":
event.prevent_default()
event.stop()
value = self.text.strip()
if value:
self._reset_prefix()
self.post_message(self.Submitted(value))
return
if event.key == "shift+enter":
event.prevent_default()
event.stop()
return
if event.key == "up" and self._handle_history_up():
event.prevent_default()
event.stop()
return
if event.key == "down" and self._handle_history_down():
event.prevent_default()
event.stop()
return
await super()._on_key(event)
self._mark_cursor_moved_if_needed()
def set_completion_manager(self, manager: MultiCompletionManager | None) -> None:
self._completion_manager = manager
if self._completion_manager:
self._completion_manager.on_text_changed(
self.text, self.get_cursor_offset()
)
def get_cursor_offset(self) -> int:
text = self.text
row, col = self.cursor_location
if not text:
return 0
lines = text.split("\n")
row = max(0, min(row, len(lines) - 1))
col = max(0, col)
offset = sum(len(lines[i]) + 1 for i in range(row))
return offset + min(col, len(lines[row]))
def set_cursor_offset(self, offset: int) -> None:
text = self.text
if offset <= 0:
self.move_cursor((0, 0))
return
if offset >= len(text):
lines = text.split("\n")
if not lines:
self.move_cursor((0, 0))
return
last_row = len(lines) - 1
self.move_cursor((last_row, len(lines[last_row])))
return
remaining = offset
lines = text.split("\n")
for row, line in enumerate(lines):
line_length = len(line)
if remaining <= line_length:
self.move_cursor((row, remaining))
return
remaining -= line_length + 1
last_row = len(lines) - 1
self.move_cursor((last_row, len(lines[last_row])))
def reset_history_state(self) -> None:
self._reset_prefix()
self._original_text = ""
self._cursor_pos_after_load = None
self._cursor_moved_since_load = False
self._last_text = self.text
def clear_text(self) -> None:
self.clear()
self.reset_history_state()

View file

@ -0,0 +1,42 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.blinking_message import BlinkingMessage
class CompactMessage(BlinkingMessage):
def __init__(self) -> None:
super().__init__()
self.add_class("compact-message")
self.old_tokens: int | None = None
self.new_tokens: int | None = None
self.error_message: str | None = None
def get_content(self) -> str:
if self._is_blinking:
return "Compacting conversation history..."
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"
def set_complete(
self, old_tokens: int | None = None, new_tokens: int | None = None
) -> None:
self.old_tokens = old_tokens
self.new_tokens = new_tokens
self.stop_blinking(success=True)
def set_error(self, error_message: str) -> None:
self.error_message = error_message
self.stop_blinking(success=False)

View file

@ -0,0 +1,156 @@
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, TypedDict
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.theme import BUILTIN_THEMES
from textual.widgets import Static
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
THEMES = sorted(k for k in BUILTIN_THEMES if k != "textual-ansi")
class SettingDefinition(TypedDict):
key: str
label: str
type: str
options: list[str]
value: str
class ConfigApp(Container):
can_focus = True
can_focus_children = False
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("space", "toggle_setting", "Toggle", show=False),
Binding("enter", "cycle", "Next", show=False),
]
class SettingChanged(Message):
def __init__(self, key: str, value: str) -> None:
super().__init__()
self.key = key
self.value = value
class ConfigClosed(Message):
def __init__(self, changes: dict[str, str]) -> None:
super().__init__()
self.changes = changes
def __init__(self, config: VibeConfig) -> None:
super().__init__(id="config-app")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
self.settings: list[SettingDefinition] = [
{
"key": "active_model",
"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,
},
]
self.title_widget: Static | None = None
self.setting_widgets: list[Static] = []
self.help_widget: Static | None = None
def compose(self) -> ComposeResult:
with Vertical(id="config-content"):
self.title_widget = Static("Settings", classes="settings-title")
yield self.title_widget
yield Static("")
for _ in self.settings:
widget = Static("", classes="settings-option")
self.setting_widgets.append(widget)
yield widget
yield Static("")
self.help_widget = Static(
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
)
yield self.help_widget
def on_mount(self) -> None:
self._update_display()
self.focus()
def _update_display(self) -> None:
for i, (setting, widget) in enumerate(
zip(self.settings, self.setting_widgets, strict=True)
):
is_selected = i == self.selected_index
cursor = " " if is_selected else " "
label: str = setting["label"]
value: str = self.changes.get(setting["key"], setting["value"])
text = f"{cursor}{label}: {value}"
widget.update(text)
widget.remove_class("settings-cursor-selected")
widget.remove_class("settings-value-cycle-selected")
widget.remove_class("settings-value-cycle-unselected")
if is_selected:
widget.add_class("settings-value-cycle-selected")
else:
widget.add_class("settings-value-cycle-unselected")
def action_move_up(self) -> None:
self.selected_index = (self.selected_index - 1) % len(self.settings)
self._update_display()
def action_move_down(self) -> None:
self.selected_index = (self.selected_index + 1) % len(self.settings)
self._update_display()
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self.changes.get(key, setting["value"])
options: list[str] = setting["options"]
try:
current_idx = options.index(current)
next_idx = (current_idx + 1) % len(options)
new_value: str = options[next_idx]
except (ValueError, IndexError):
new_value: str = options[0] if options else current
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._update_display()
def action_cycle(self) -> None:
self.action_toggle_setting()
def action_close(self) -> None:
self.post_message(self.ConfigClosed(changes=self.changes.copy()))
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from textual.reactive import reactive
from textual.widgets import Static
@dataclass
class TokenState:
max_tokens: int = 0
current_tokens: int = 0
class ContextProgress(Static):
tokens = reactive(TokenState())
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
def watch_tokens(self, new_state: TokenState) -> None:
if new_state.max_tokens == 0:
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"
self.update(text)

View file

@ -0,0 +1,157 @@
from __future__ import annotations
from datetime import datetime
import random
from time import time
from typing import ClassVar
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
class LoadingWidget(Static):
BRAILLE_SPINNER = ("", "", "", "", "", "", "", "", "", "")
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
EASTER_EGGS: ClassVar[list[str]] = [
"Eating a chocolatine",
"Eating a pain au chocolat",
"Réflexion",
"Analyse",
"Contemplation",
"Synthèse",
"Reading Proust",
"Oui oui baguette",
"Counting Rs in strawberry",
"Seeding Mistral weights",
"Vibing",
"Sending good vibes",
"Petting le chat",
]
EASTER_EGGS_HALLOWEEN: ClassVar[list[str]] = [
"Trick or treating",
"Carving pumpkins",
"Summoning spirits",
"Brewing potions",
"Haunting the terminal",
"Petting le chat noir",
]
EASTER_EGGS_DECEMBER: ClassVar[list[str]] = [
"Wrapping presents",
"Decorating the tree",
"Drinking hot chocolate",
"Building snowmen",
"Writing holiday cards",
]
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.char_widgets: list[Static] = []
self.spinner_widget: Static | None = None
self.ellipsis_widget: Static | None = None
self.hint_widget: Static | None = None
self.start_time: float | None = None
def _get_easter_egg(self) -> str | None:
EASTER_EGG_PROBABILITY = 0.10
if random.random() < EASTER_EGG_PROBABILITY:
available_eggs = list(self.EASTER_EGGS)
OCTOBER = 10
HALLOWEEN_DAY = 31
DECEMBER = 12
now = datetime.now()
if now.month == OCTOBER and now.day == HALLOWEEN_DAY:
available_eggs.extend(self.EASTER_EGGS_HALLOWEEN)
if now.month == DECEMBER:
available_eggs.extend(self.EASTER_EGGS_DECEMBER)
return random.choice(available_eggs)
return None
def _get_default_status(self) -> str:
return self._get_easter_egg() or "Thinking"
def _apply_easter_egg(self, status: str) -> str:
return self._get_easter_egg() or status
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"
)
yield self.spinner_widget
with Horizontal(classes="loading-status"):
for char in self.status:
widget = Static(char, classes="loading-char")
self.char_widgets.append(widget)
yield widget
self.ellipsis_widget = Static("", classes="loading-ellipsis")
yield self.ellipsis_widget
self.hint_widget = Static("(0s esc to interrupt)", classes="loading-hint")
yield self.hint_widget
def _rebuild_chars(self) -> None:
if not self.is_mounted:
return
status_container = self.query_one(".loading-status", Horizontal)
status_container.remove_children()
self.char_widgets.clear()
for char in self.status:
widget = Static(char, classes="loading-char")
self.char_widgets.append(widget)
status_container.mount(widget)
self.update_animation()
def on_mount(self) -> None:
self.start_time = time()
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 update_animation(self) -> None:
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)
for i, widget in enumerate(self.char_widgets):
position = 2 + i
color = self._get_gradient_color(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)
self.ellipsis_widget.update(f"[{color_ellipsis}]…[/][{color_space}] [/]")
self.gradient_offset = (self.gradient_offset + 1) % len(self.TARGET_COLORS)
if self.hint_widget and self.start_time is not None:
elapsed = int(time() - self.start_time)
self.hint_widget.update(f"({elapsed}s esc to interrupt)")

View file

@ -0,0 +1,148 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Markdown, Static
from textual.widgets._markdown import MarkdownStream
class UserMessage(Static):
def __init__(self, content: str, pending: bool = False) -> None:
super().__init__()
self.add_class("user-message")
self._content = content
self._pending = pending
def compose(self) -> ComposeResult:
with Horizontal(classes="user-message-container"):
yield Static("> ", classes="user-message-prompt")
yield Static(self._content, markup=False, classes="user-message-content")
if self._pending:
self.add_class("pending")
async def set_pending(self, pending: bool) -> None:
if pending == self._pending:
return
self._pending = pending
if pending:
self.add_class("pending")
return
self.remove_class("pending")
class AssistantMessage(Static):
def __init__(self, content: str) -> None:
super().__init__()
self.add_class("assistant-message")
self._content = content
self._markdown: Markdown | None = None
self._stream: MarkdownStream | None = None
def compose(self) -> ComposeResult:
with Horizontal(classes="assistant-message-container"):
yield Static("", classes="assistant-message-dot")
with Vertical(classes="assistant-message-content"):
markdown = Markdown("")
self._markdown = markdown
yield markdown
def _get_markdown(self) -> Markdown:
if self._markdown is None:
self._markdown = self.query_one(Markdown)
return self._markdown
def _ensure_stream(self) -> MarkdownStream:
if self._stream is None:
self._stream = Markdown.get_stream(self._get_markdown())
return self._stream
async def append_content(self, content: str) -> None:
if not content:
return
self._content += content
stream = self._ensure_stream()
await stream.write(content)
async def write_initial_content(self) -> None:
if self._content:
stream = self._ensure_stream()
await stream.write(self._content)
async def stop_stream(self) -> None:
if self._stream is None:
return
await self._stream.stop()
self._stream = None
class UserCommandMessage(Static):
def __init__(self, content: str) -> None:
super().__init__()
self.add_class("user-command-message")
self._content = content
def compose(self) -> ComposeResult:
yield Markdown(self._content)
class InterruptMessage(Static):
def __init__(self) -> None:
super().__init__(
"Interrupted · What should Vibe do instead?", classes="interrupt-message"
)
class BashOutputMessage(Static):
def __init__(self, command: str, cwd: str, output: str, exit_code: int) -> None:
super().__init__()
self.add_class("bash-output-message")
self._command = command
self._cwd = cwd
self._output = output
self._exit_code = exit_code
def compose(self) -> ComposeResult:
with Vertical(classes="bash-output-container"):
with Horizontal(classes="bash-cwd-line"):
yield Static(self._cwd, markup=False, classes="bash-cwd")
yield Static("", classes="bash-cwd-spacer")
if self._exit_code == 0:
yield Static("", classes="bash-exit-success")
else:
yield Static("", classes="bash-exit-failure")
yield Static(f" ({self._exit_code})", classes="bash-exit-code")
with Horizontal(classes="bash-command-line"):
yield Static("> ", classes="bash-chevron")
yield Static(self._command, markup=False, classes="bash-command")
yield Static("", classes="bash-command-spacer")
yield Static(self._output, markup=False, classes="bash-output")
class ErrorMessage(Static):
def __init__(self, error: str, collapsed: bool = True) -> None:
super().__init__(classes="error-message")
self._error = error
self.collapsed = collapsed
def compose(self) -> ComposeResult:
if self.collapsed:
yield Static("Error. (ctrl+o to expand)", markup=False)
else:
yield Static(f"Error: {self._error}", markup=False)
def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed == collapsed:
return
self.collapsed = collapsed
self.remove_children()
if self.collapsed:
self.mount(Static("Error. (ctrl+o to expand)", markup=False))
else:
self.mount(Static(f"Error: {self._error}", markup=False))

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from textual.widgets import Static
class ModeIndicator(Static):
def __init__(self, auto_approve: bool = False) -> None:
super().__init__()
self.can_focus = False
self._auto_approve = auto_approve
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")
def set_auto_approve(self, enabled: bool) -> None:
self._auto_approve = enabled
self._update_display()

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from pathlib import Path
from textual.widgets import Static
class PathDisplay(Static):
def __init__(self, path: Path | str) -> None:
super().__init__()
self.can_focus = False
self._path = Path(path)
self._update_display()
def _update_display(self) -> None:
path_str = str(self._path)
try:
home = Path.home()
if self._path.is_relative_to(home):
path_str = f"~/{self._path.relative_to(home)}"
except (ValueError, OSError):
pass
self.update(path_str)
def set_path(self, path: Path | str) -> None:
self._path = Path(path)
self._update_display()

View file

@ -0,0 +1,307 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import Markdown, Static
class ToolApprovalWidget(Vertical):
def __init__(self, data: dict) -> None:
super().__init__()
self.data = data
self.add_class("tool-approval-widget")
def compose(self) -> ComposeResult:
MAX_APPROVAL_MSG_SIZE = 150
for key, value in self.data.items():
value_str = str(value)
if len(value_str) > MAX_APPROVAL_MSG_SIZE:
hidden = len(value_str) - MAX_APPROVAL_MSG_SIZE
value_str = (
value_str[:MAX_APPROVAL_MSG_SIZE] + f"… ({hidden} more characters)"
)
yield Static(
f"{key}: {value_str}", markup=False, classes="approval-description"
)
class ToolResultWidget(Static):
def __init__(self, data: dict, collapsed: bool = True) -> None:
super().__init__()
self.data = data
self.collapsed = collapsed
self.add_class("tool-result-widget")
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
else:
yield Static(message, markup=False)
if not self.collapsed and (details := self.data.get("details")):
for key, value in details.items():
if value:
yield Static(
f"{key}: {value}", markup=False, classes="tool-result-detail"
)
class BashApprovalWidget(ToolApprovalWidget):
def compose(self) -> ComposeResult:
command = self.data.get("command", "")
description = self.data.get("description", "")
if description:
yield Static(description, markup=False, classes="approval-description")
yield Static("")
yield Markdown(f"```bash\n{command}\n```")
class BashResultWidget(ToolResultWidget):
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
else:
yield Static(message, markup=False)
if not self.collapsed and (details := self.data.get("details")):
for key, value in details.items():
if value:
yield Static(
f"{key}: {value}", markup=False, classes="tool-result-detail"
)
class WriteFileApprovalWidget(ToolApprovalWidget):
def compose(self) -> ComposeResult:
path = self.data.get("path", "")
content = self.data.get("content", "")
file_extension = self.data.get("file_extension", "text")
yield Static(f"File: {path}", markup=False, classes="approval-description")
yield Static("")
yield Markdown(f"```{file_extension}\n{content}\n```")
class WriteFileResultWidget(ToolResultWidget):
def compose(self) -> ComposeResult:
MAX_LINES = 10
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
else:
yield Static(message, markup=False)
if not self.collapsed:
if path := self.data.get("path"):
yield Static(
f"Path: {path}", markup=False, classes="tool-result-detail"
)
if bytes_written := self.data.get("bytes_written"):
yield Static(
f"Bytes: {bytes_written}",
markup=False,
classes="tool-result-detail",
)
if content := self.data.get("content"):
yield Static("")
file_extension = self.data.get("file_extension", "text")
lines = content.split("\n")
total_lines = len(lines)
if total_lines > MAX_LINES:
shown_lines = lines[:MAX_LINES]
remaining = total_lines - MAX_LINES
truncated_content = "\n".join(
shown_lines + [f"… ({remaining} more lines)"]
)
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
else:
yield Markdown(f"```{file_extension}\n{content}\n```")
class SearchReplaceApprovalWidget(ToolApprovalWidget):
def compose(self) -> ComposeResult:
file_path = self.data.get("file_path", "")
diff_lines = self.data.get("diff_lines", [])
yield Static(f"File: {file_path}", markup=False, classes="approval-description")
yield Static("")
if diff_lines:
for line in diff_lines:
if line.startswith("---") or line.startswith("+++"):
yield Static(line, markup=False, classes="diff-header")
elif line.startswith("-"):
yield Static(line, markup=False, classes="diff-removed")
elif line.startswith("+"):
yield Static(line, markup=False, classes="diff-added")
elif line.startswith("@@"):
yield Static(line, markup=False, classes="diff-range")
else:
yield Static(line, markup=False, classes="diff-context")
class SearchReplaceResultWidget(ToolResultWidget):
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
else:
yield Static(message, markup=False)
if not self.collapsed and (diff_lines := self.data.get("diff_lines")):
yield Static("")
for line in diff_lines:
if line.startswith("---") or line.startswith("+++"):
yield Static(line, markup=False, classes="diff-header")
elif line.startswith("-"):
yield Static(line, markup=False, classes="diff-removed")
elif line.startswith("+"):
yield Static(line, markup=False, classes="diff-added")
elif line.startswith("@@"):
yield Static(line, markup=False, classes="diff-range")
else:
yield Static(line, markup=False, classes="diff-context")
class TodoApprovalWidget(ToolApprovalWidget):
def compose(self) -> ComposeResult:
description = self.data.get("description", "")
if description:
yield Static(description, markup=False, classes="approval-description")
class TodoResultWidget(ToolResultWidget):
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(message, markup=False)
else:
yield Static(message, markup=False)
yield Static("")
by_status = self.data.get("todos_by_status", {})
if not any(by_status.values()):
yield Static("No todos", markup=False, classes="todo-empty")
return
for status in ["in_progress", "pending", "completed", "cancelled"]:
todos = by_status.get(status, [])
for todo in todos:
content = todo.get("content", "")
icon = self._get_status_icon(status)
yield Static(
f"{icon} {content}", markup=False, classes=f"todo-{status}"
)
def _get_status_icon(self, status: str) -> str:
icons = {"pending": "", "in_progress": "", "completed": "", "cancelled": ""}
return icons.get(status, "")
class ReadFileApprovalWidget(ToolApprovalWidget):
def compose(self) -> ComposeResult:
for key, value in self.data.items():
if value:
yield Static(
f"{key}: {value}", markup=False, classes="approval-description"
)
class ReadFileResultWidget(ToolResultWidget):
def compose(self) -> ComposeResult:
MAX_LINES = 10
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
else:
yield Static(message, markup=False)
if self.collapsed:
return
if path := self.data.get("path"):
yield Static(f"Path: {path}", markup=False, classes="tool-result-detail")
if warnings := self.data.get("warnings"):
for warning in warnings:
yield Static(
f"{warning}", markup=False, classes="tool-result-warning"
)
if content := self.data.get("content"):
yield Static("")
file_extension = self.data.get("file_extension", "text")
lines = content.split("\n")
total_lines = len(lines)
if total_lines > MAX_LINES:
shown_lines = lines[:MAX_LINES]
remaining = total_lines - MAX_LINES
truncated_content = "\n".join(
shown_lines + [f"… ({remaining} more lines)"]
)
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
else:
yield Markdown(f"```{file_extension}\n{content}\n```")
class GrepApprovalWidget(ToolApprovalWidget):
def compose(self) -> ComposeResult:
for key, value in self.data.items():
if value:
yield Static(
f"{key}: {value!s}", classes="approval-description", markup=False
)
class GrepResultWidget(ToolResultWidget):
def compose(self) -> ComposeResult:
MAX_LINES = 30
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
else:
yield Static(message, markup=False)
if self.collapsed:
return
if warnings := self.data.get("warnings"):
for warning in warnings:
yield Static(
f"{warning}", classes="tool-result-warning", markup=False
)
if matches := self.data.get("matches"):
yield Static("")
lines = matches.split("\n")
total_lines = len(lines)
if total_lines > MAX_LINES:
shown_lines = lines[:MAX_LINES]
remaining = total_lines - MAX_LINES
truncated_content = "\n".join(
shown_lines + [f"… ({remaining} more lines)"]
)
yield Markdown(f"```\n{truncated_content}\n```")
else:
yield Markdown(f"```\n{matches}\n```")

View file

@ -0,0 +1,86 @@
from __future__ import annotations
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.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
class ToolCallMessage(BlinkingMessage):
def __init__(self, event: ToolCallEvent) -> None:
self.event = event
super().__init__()
self.add_class("tool-call")
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}"
class ToolResultMessage(Static):
def __init__(
self,
event: ToolResultEvent,
call_widget: ToolCallMessage | None = None,
collapsed: bool = True,
) -> None:
self.event = event
self.call_widget = call_widget
self.collapsed = collapsed
super().__init__()
self.add_class("tool-result")
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()
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))
return
if self.event.skipped:
self.add_class("warning-text")
reason = self.event.skip_reason or "User skipped"
if self.collapsed:
self.update("Skipped. (ctrl+o to expand)")
else:
await self.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)
renderer = get_renderer(self.event.tool_name)
widget_class, data = renderer.get_result_widget(display, self.collapsed)
result_widget = widget_class(data, collapsed=self.collapsed)
await self.mount(result_widget)
async def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed != collapsed:
self.collapsed = collapsed
await self.render_result()
async def toggle_collapsed(self) -> None:
self.collapsed = not self.collapsed
await self.render_result()

View file

@ -0,0 +1,283 @@
from __future__ import annotations
from dataclasses import dataclass
from time import monotonic
from rich.align import Align
from rich.console import Group
from rich.text import Text
from textual.color import Color
from textual.widgets import Static
from vibe.core import __version__
from vibe.core.config import VibeConfig
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
normalized = hex_color.lstrip("#")
r, g, b = (int(normalized[i : i + 2], 16) for i in (0, 2, 4))
return (r, g, b)
def rgb_to_hex(r: int, g: int, b: int) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
def interpolate_color(
start_rgb: tuple[int, int, int], end_rgb: tuple[int, int, int], progress: float
) -> str:
progress = max(0.0, min(1.0, progress))
r = int(start_rgb[0] + (end_rgb[0] - start_rgb[0]) * progress)
g = int(start_rgb[1] + (end_rgb[1] - start_rgb[1]) * progress)
b = int(start_rgb[2] + (end_rgb[2] - start_rgb[2]) * progress)
return rgb_to_hex(r, g, b)
@dataclass
class LineAnimationState:
progress: float = 0.0
cached_color: str | None = None
cached_progress: float = -1.0
rendered_color: str | None = None
class WelcomeBanner(Static):
FLASH_COLOR = "#FFFFFF"
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
BORDER_TARGET_COLOR = "#b05800"
LINE_ANIMATION_DURATION_MS = 200
LINE_STAGGER_MS = 280
FLASH_RESET_DURATION_MS = 400
ANIMATION_TICK_INTERVAL = 0.1
COLOR_FLASH_MIDPOINT = 0.5
COLOR_PHASE_SCALE = 2.0
COLOR_CACHE_THRESHOLD = 0.001
BORDER_PROGRESS_THRESHOLD = 0.01
BLOCK = "▇▇"
SPACE = " "
LOGO_TEXT_GAP = " "
def __init__(self, config: VibeConfig) -> None:
super().__init__(" ")
self.config = config
self.animation_timer = None
self._animation_start_time: float | None = None
self._cached_skeleton_color: str | None = None
self._cached_skeleton_rgb: tuple[int, int, int] | None = None
self._flash_rgb = hex_to_rgb(self.FLASH_COLOR)
self._target_rgbs = [hex_to_rgb(c) for c in self.TARGET_COLORS]
self._border_target_rgb = hex_to_rgb(self.BORDER_TARGET_COLOR)
self._line_states = [LineAnimationState() for _ in self.TARGET_COLORS]
self.border_progress = 0.0
self._cached_border_color: str | None = None
self._cached_border_progress = -1.0
self._line_duration = self.LINE_ANIMATION_DURATION_MS / 1000
self._line_stagger = self.LINE_STAGGER_MS / 1000
self._border_duration = self.FLASH_RESET_DURATION_MS / 1000
self._line_start_times = [
idx * self._line_stagger for idx in range(len(self.TARGET_COLORS))
]
self._all_lines_finish_time = (
(len(self.TARGET_COLORS) - 1) * self.LINE_STAGGER_MS
+ self.LINE_ANIMATION_DURATION_MS
) / 1000
self._cached_text_lines: list[Text | None] = [None] * 7
self._initialize_static_line_suffixes()
def _initialize_static_line_suffixes(self) -> None:
self._static_line1_suffix = (
f"{self.LOGO_TEXT_GAP}[b]Mistral Vibe v{__version__}[/]"
)
self._static_line2_suffix = (
f"{self.LOGO_TEXT_GAP}[dim]{self.config.active_model}[/]"
)
mcp_count = len(self.config.mcp_servers)
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}[/]"
)
block = (self.SPACE * 4) + self.LOGO_TEXT_GAP
self._static_line7 = f"{block}[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information[/]"
@property
def skeleton_color(self) -> str:
return self._cached_skeleton_color or "#1e1e1e"
@property
def skeleton_rgb(self) -> tuple[int, int, int]:
return self._cached_skeleton_rgb or hex_to_rgb("#1e1e1e")
def on_mount(self) -> None:
if not self.config.disable_welcome_banner_animation:
self.call_after_refresh(self._init_after_styles)
def _init_after_styles(self) -> None:
self._cache_skeleton_color()
self._cached_text_lines[5] = Text("")
self._cached_text_lines[6] = Text.from_markup(self._static_line7)
self._update_display()
self._start_animation()
def _cache_skeleton_color(self) -> None:
try:
border = self.styles.border
if (
hasattr(border, "top")
and isinstance(edge := border.top, tuple)
and len(edge) >= 2 # noqa: PLR2004
and isinstance(color := edge[1], Color)
):
self._cached_skeleton_color = color.hex
self._cached_skeleton_rgb = hex_to_rgb(color.hex)
return
except (AttributeError, TypeError):
pass
self._cached_skeleton_color = "#1e1e1e"
self._cached_skeleton_rgb = hex_to_rgb("#1e1e1e")
def _stop_timer(self) -> None:
if self.animation_timer:
try:
self.animation_timer.stop()
except Exception:
pass
self.animation_timer = None
def on_unmount(self) -> None:
self._stop_timer()
def _start_animation(self) -> None:
self._animation_start_time = monotonic()
def tick() -> None:
if self._is_animation_complete():
self._stop_timer()
return
if self._animation_start_time is None:
return
elapsed = monotonic() - self._animation_start_time
updated_lines = self._advance_line_progress(elapsed)
border_updated = self._advance_border_progress(elapsed)
if border_updated:
self._update_border_color()
if updated_lines or border_updated:
self._update_display()
self.animation_timer = self.set_interval(self.ANIMATION_TICK_INTERVAL, tick)
def _advance_line_progress(self, elapsed: float) -> bool:
any_updates = False
for line_idx, state in enumerate(self._line_states):
if state.progress >= 1.0:
continue
start_time = self._line_start_times[line_idx]
if elapsed < start_time:
continue
progress = min(1.0, (elapsed - start_time) / self._line_duration)
if progress > state.progress:
state.progress = progress
any_updates = True
return any_updates
def _advance_border_progress(self, elapsed: float) -> bool:
if elapsed < self._all_lines_finish_time:
return False
new_progress = min(
1.0, (elapsed - self._all_lines_finish_time) / self._border_duration
)
if abs(new_progress - self.border_progress) > self.BORDER_PROGRESS_THRESHOLD:
self.border_progress = new_progress
return True
return False
def _is_animation_complete(self) -> bool:
return (
all(state.progress >= 1.0 for state in self._line_states)
and self.border_progress >= 1.0
)
def _update_border_color(self) -> None:
progress = self.border_progress
if abs(progress - self._cached_border_progress) < self.COLOR_CACHE_THRESHOLD:
return
border_color = self._compute_color_for_progress(
progress, self._border_target_rgb
)
self._cached_border_color = border_color
self._cached_border_progress = progress
self.styles.border = ("round", border_color)
def _compute_color_for_progress(
self, progress: float, target_rgb: tuple[int, int, int]
) -> str:
if progress <= 0:
return self.skeleton_color
if progress <= self.COLOR_FLASH_MIDPOINT:
phase = progress * self.COLOR_PHASE_SCALE
return interpolate_color(self.skeleton_rgb, self._flash_rgb, phase)
phase = (progress - self.COLOR_FLASH_MIDPOINT) * self.COLOR_PHASE_SCALE
return interpolate_color(self._flash_rgb, target_rgb, phase)
def _update_display(self) -> None:
for idx in range(5):
self._update_colored_line(idx, idx)
lines = [line if line else Text("") for line in self._cached_text_lines]
self.update(Align.center(Group(*lines)))
def _get_color(self, line_idx: int) -> str:
state = self._line_states[line_idx]
if (
abs(state.progress - state.cached_progress) < self.COLOR_CACHE_THRESHOLD
and state.cached_color
):
return state.cached_color
color = self._compute_color_for_progress(
state.progress, self._target_rgbs[line_idx]
)
state.cached_color = color
state.cached_progress = state.progress
return color
def _update_colored_line(self, slot_idx: int, line_idx: int) -> None:
color = self._get_color(line_idx)
state = self._line_states[line_idx]
if color == state.rendered_color and self._cached_text_lines[slot_idx]:
return
state.rendered_color = color
self._cached_text_lines[slot_idx] = Text.from_markup(
self._build_line(slot_idx, color)
)
def _build_line(self, line_idx: int, color: str) -> str:
B = self.BLOCK
S = self.SPACE
patterns = [
f"{S}[{color}]{B}[/]{S}{S}{S}[{color}]{B}[/]{S}{self._static_line1_suffix}",
f"{S}[{color}]{B}{B}[/]{S}[{color}]{B}{B}[/]{S}{self._static_line2_suffix}",
f"{S}[{color}]{B}{B}{B}{B}{B}[/]{S}{self._static_line3_suffix}",
f"{S}[{color}]{B}[/]{S}[{color}]{B}[/]{S}[{color}]{B}[/]{S}",
f"[{color}]{B}{B}{B}[/]{S}[{color}]{B}{B}{B}[/]{self._static_line5_suffix}",
]
return patterns[line_idx]

View file

@ -0,0 +1,31 @@
from __future__ import annotations
from vibe.cli.update_notifier.fake_version_update_gateway import (
FakeVersionUpdateGateway,
)
from vibe.cli.update_notifier.github_version_update_gateway import (
GitHubVersionUpdateGateway,
)
from vibe.cli.update_notifier.version_update import (
VersionUpdateError,
is_version_update_available,
)
from vibe.cli.update_notifier.version_update_gateway import (
DEFAULT_GATEWAY_MESSAGES,
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)
__all__ = [
"DEFAULT_GATEWAY_MESSAGES",
"FakeVersionUpdateGateway",
"GitHubVersionUpdateGateway",
"VersionUpdate",
"VersionUpdateError",
"VersionUpdateGateway",
"VersionUpdateGatewayCause",
"VersionUpdateGatewayError",
"is_version_update_available",
]

View file

@ -0,0 +1,24 @@
from __future__ import annotations
from vibe.cli.update_notifier.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayError,
)
class FakeVersionUpdateGateway(VersionUpdateGateway):
def __init__(
self,
update: VersionUpdate | None = None,
error: VersionUpdateGatewayError | None = None,
) -> None:
self._update: VersionUpdate | None = update
self._error = error
self.fetch_update_calls = 0
async def fetch_update(self) -> VersionUpdate | None:
self.fetch_update_calls += 1
if self._error is not None:
raise self._error
return self._update

View file

@ -0,0 +1,109 @@
from __future__ import annotations
import httpx
from vibe.cli.update_notifier.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)
class GitHubVersionUpdateGateway(VersionUpdateGateway):
def __init__(
self,
owner: str,
repository: str,
*,
token: str | None = None,
client: httpx.AsyncClient | None = None,
timeout: float = 5.0,
base_url: str = "https://api.github.com",
) -> None:
self._owner = owner
self._repository = repository
self._token = token
self._client = client
self._timeout = timeout
self._base_url = base_url.rstrip("/")
async def fetch_update(self) -> VersionUpdate | None:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "mistral-vibe-update-notifier",
}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
request_path = f"/repos/{self._owner}/{self._repository}/releases"
try:
if self._client is not None:
response = await self._client.get(
f"{self._base_url}{request_path}",
headers=headers,
timeout=self._timeout,
)
else:
async with httpx.AsyncClient(
base_url=self._base_url, timeout=self._timeout
) as client:
response = await client.get(request_path, headers=headers)
except httpx.RequestError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.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
)
if response.status_code == httpx.codes.FORBIDDEN:
raise VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.FORBIDDEN)
if response.status_code == httpx.codes.NOT_FOUND:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.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
)
try:
data = response.json()
except ValueError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.INVALID_RESPONSE
) from exc
if not data:
return None
# pick the most recently published non-prerelease and non-draft release
# github "list releases" API most likely returns ordered results, but this is not guaranteed
for release in sorted(
data, key=lambda x: x.get("published_at") or "", reverse=True
):
if release.get("prerelease") or release.get("draft"):
continue
if version := _extract_version(release.get("tag_name")):
return VersionUpdate(latest_version=version)
return None
def _extract_version(tag_name: str | None) -> str | None:
if not tag_name:
return None
tag = tag_name.strip()
if not tag:
return None
return tag[1:] if tag.startswith(("v", "V")) else tag

View file

@ -0,0 +1,57 @@
from __future__ import annotations
from packaging.version import InvalidVersion, Version
from vibe.cli.update_notifier.version_update_gateway import (
DEFAULT_GATEWAY_MESSAGES,
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
)
class VersionUpdateError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
def _parse_version(raw: str) -> Version | None:
try:
return Version(raw.replace("-", "+"))
except InvalidVersion:
return None
def _describe_gateway_error(error: VersionUpdateGatewayError) -> str:
if message := getattr(error, "user_message", None):
return message
cause = getattr(error, "cause", VersionUpdateGatewayCause.UNKNOWN)
if isinstance(cause, VersionUpdateGatewayCause):
return DEFAULT_GATEWAY_MESSAGES.get(
cause, DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
)
return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
async def is_version_update_available(
version_update_notifier: VersionUpdateGateway, current_version: str
) -> VersionUpdate | None:
try:
update = await version_update_notifier.fetch_update()
except VersionUpdateGatewayError as error:
raise VersionUpdateError(_describe_gateway_error(error)) from error
if not update:
return None
latest_version = _parse_version(update.latest_version)
current = _parse_version(current_version)
if latest_version is None or current is None:
return None
return update if latest_version > current else None

View file

@ -0,0 +1,54 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol, runtime_checkable
@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)
@runtime_checkable
class VersionUpdateGateway(Protocol):
async def fetch_update(self) -> VersionUpdate | None: ...