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:
commit
fa15fc977b
200 changed files with 30484 additions and 0 deletions
0
vibe/cli/__init__.py
Normal file
0
vibe/cli/__init__.py
Normal file
0
vibe/cli/autocompletion/__init__.py
Normal file
0
vibe/cli/autocompletion/__init__.py
Normal file
22
vibe/cli/autocompletion/base.py
Normal file
22
vibe/cli/autocompletion/base.py
Normal 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: ...
|
||||
173
vibe/cli/autocompletion/path_completion.py
Normal file
173
vibe/cli/autocompletion/path_completion.py
Normal 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
|
||||
99
vibe/cli/autocompletion/slash_command.py
Normal file
99
vibe/cli/autocompletion/slash_command.py
Normal 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
34
vibe/cli/clipboard.py
Normal 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
98
vibe/cli/commands.py
Normal 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
261
vibe/cli/entrypoint.py
Normal 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()
|
||||
91
vibe/cli/history_manager.py
Normal file
91
vibe/cli/history_manager.py
Normal 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 = ""
|
||||
0
vibe/cli/textual_ui/__init__.py
Normal file
0
vibe/cli/textual_ui/__init__.py
Normal file
1099
vibe/cli/textual_ui/app.py
Normal file
1099
vibe/cli/textual_ui/app.py
Normal file
File diff suppressed because it is too large
Load diff
681
vibe/cli/textual_ui/app.tcss
Normal file
681
vibe/cli/textual_ui/app.tcss
Normal 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;
|
||||
}
|
||||
5
vibe/cli/textual_ui/handlers/__init__.py
Normal file
5
vibe/cli/textual_ui/handlers/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
|
||||
__all__ = ["EventHandler"]
|
||||
158
vibe/cli/textual_ui/handlers/event_handler.py
Normal file
158
vibe/cli/textual_ui/handlers/event_handler.py
Normal 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
|
||||
5
vibe/cli/textual_ui/renderers/__init__.py
Normal file
5
vibe/cli/textual_ui/renderers/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.renderers.tool_renderers import get_renderer
|
||||
|
||||
__all__ = ["get_renderer"]
|
||||
216
vibe/cli/textual_ui/renderers/tool_renderers.py
Normal file
216
vibe/cli/textual_ui/renderers/tool_renderers.py
Normal 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()
|
||||
0
vibe/cli/textual_ui/widgets/__init__.py
Normal file
0
vibe/cli/textual_ui/widgets/__init__.py
Normal file
196
vibe/cli/textual_ui/widgets/approval_app.py
Normal file
196
vibe/cli/textual_ui/widgets/approval_app.py
Normal 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)
|
||||
67
vibe/cli/textual_ui/widgets/blinking_message.py
Normal file
67
vibe/cli/textual_ui/widgets/blinking_message.py
Normal 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()
|
||||
7
vibe/cli/textual_ui/widgets/chat_input/__init__.py
Normal file
7
vibe/cli/textual_ui/widgets/chat_input/__init__.py
Normal 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"]
|
||||
194
vibe/cli/textual_ui/widgets/chat_input/body.py
Normal file
194
vibe/cli/textual_ui/widgets/chat_input/body.py
Normal 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))))
|
||||
58
vibe/cli/textual_ui/widgets/chat_input/completion_manager.py
Normal file
58
vibe/cli/textual_ui/widgets/chat_input/completion_manager.py
Normal 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
|
||||
43
vibe/cli/textual_ui/widgets/chat_input/completion_popup.py
Normal file
43
vibe/cli/textual_ui/widgets/chat_input/completion_popup.py
Normal 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"
|
||||
157
vibe/cli/textual_ui/widgets/chat_input/container.py
Normal file
157
vibe/cli/textual_ui/widgets/chat_input/container.py
Normal 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)
|
||||
246
vibe/cli/textual_ui/widgets/chat_input/text_area.py
Normal file
246
vibe/cli/textual_ui/widgets/chat_input/text_area.py
Normal 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()
|
||||
42
vibe/cli/textual_ui/widgets/compact.py
Normal file
42
vibe/cli/textual_ui/widgets/compact.py
Normal 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)
|
||||
156
vibe/cli/textual_ui/widgets/config_app.py
Normal file
156
vibe/cli/textual_ui/widgets/config_app.py
Normal 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)
|
||||
31
vibe/cli/textual_ui/widgets/context_progress.py
Normal file
31
vibe/cli/textual_ui/widgets/context_progress.py
Normal 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)
|
||||
157
vibe/cli/textual_ui/widgets/loading.py
Normal file
157
vibe/cli/textual_ui/widgets/loading.py
Normal 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)")
|
||||
148
vibe/cli/textual_ui/widgets/messages.py
Normal file
148
vibe/cli/textual_ui/widgets/messages.py
Normal 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))
|
||||
25
vibe/cli/textual_ui/widgets/mode_indicator.py
Normal file
25
vibe/cli/textual_ui/widgets/mode_indicator.py
Normal 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()
|
||||
28
vibe/cli/textual_ui/widgets/path_display.py
Normal file
28
vibe/cli/textual_ui/widgets/path_display.py
Normal 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()
|
||||
307
vibe/cli/textual_ui/widgets/tool_widgets.py
Normal file
307
vibe/cli/textual_ui/widgets/tool_widgets.py
Normal 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```")
|
||||
86
vibe/cli/textual_ui/widgets/tools.py
Normal file
86
vibe/cli/textual_ui/widgets/tools.py
Normal 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()
|
||||
283
vibe/cli/textual_ui/widgets/welcome.py
Normal file
283
vibe/cli/textual_ui/widgets/welcome.py
Normal 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]
|
||||
31
vibe/cli/update_notifier/__init__.py
Normal file
31
vibe/cli/update_notifier/__init__.py
Normal 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",
|
||||
]
|
||||
24
vibe/cli/update_notifier/fake_version_update_gateway.py
Normal file
24
vibe/cli/update_notifier/fake_version_update_gateway.py
Normal 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
|
||||
109
vibe/cli/update_notifier/github_version_update_gateway.py
Normal file
109
vibe/cli/update_notifier/github_version_update_gateway.py
Normal 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
|
||||
57
vibe/cli/update_notifier/version_update.py
Normal file
57
vibe/cli/update_notifier/version_update.py
Normal 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
|
||||
54
vibe/cli/update_notifier/version_update_gateway.py
Normal file
54
vibe/cli/update_notifier/version_update_gateway.py
Normal 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: ...
|
||||
Loading…
Add table
Add a link
Reference in a new issue