v1.2.0
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai> Co-Authored-By: Kracekumar <kracethekingmaker@gmail.com>
This commit is contained in:
parent
661588de0c
commit
d8dbeeb31e
91 changed files with 4521 additions and 873 deletions
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from threading import Lock
|
||||
|
||||
|
|
@ -171,3 +172,6 @@ class PathCompletionController:
|
|||
self._view.replace_completion_range(start, end, completion)
|
||||
self.reset()
|
||||
return True
|
||||
|
||||
|
||||
atexit.register(PathCompletionController._executor.shutdown)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from textual import events
|
|||
from vibe.cli.autocompletion.base import CompletionResult, CompletionView
|
||||
from vibe.core.autocompletion.completers import CommandCompleter
|
||||
|
||||
MAX_SUGGESTIONS_COUNT = 5
|
||||
MAX_SUGGESTIONS_COUNT = 10
|
||||
|
||||
|
||||
class SlashCommandController:
|
||||
|
|
|
|||
186
vibe/cli/cli.py
Normal file
186
vibe/cli/cli.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.core.config import (
|
||||
MissingAPIKeyError,
|
||||
MissingPromptFileError,
|
||||
VibeConfig,
|
||||
load_api_keys_from_env,
|
||||
)
|
||||
from vibe.core.interaction_logger import InteractionLogger
|
||||
from vibe.core.modes import AgentMode
|
||||
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.types import LLMMessage, OutputFormat
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
||||
|
||||
def get_initial_mode(args: argparse.Namespace) -> AgentMode:
|
||||
if args.plan:
|
||||
return AgentMode.PLAN
|
||||
if args.auto_approve:
|
||||
return AgentMode.AUTO_APPROVE
|
||||
return AgentMode.DEFAULT
|
||||
|
||||
|
||||
def get_prompt_from_stdin() -> str | None:
|
||||
if sys.stdin.isatty():
|
||||
return None
|
||||
try:
|
||||
if content := sys.stdin.read().strip():
|
||||
sys.stdin = sys.__stdin__ = open("/dev/tty")
|
||||
return content
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_config_or_exit(
|
||||
agent: str | None = None, mode: AgentMode = AgentMode.DEFAULT
|
||||
) -> VibeConfig:
|
||||
try:
|
||||
return VibeConfig.load(agent, **mode.config_overrides)
|
||||
except MissingAPIKeyError:
|
||||
run_onboarding()
|
||||
return VibeConfig.load(agent, **mode.config_overrides)
|
||||
except MissingPromptFileError as e:
|
||||
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
rprint(f"[yellow]{e}[/]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def bootstrap_config_files() -> None:
|
||||
if not CONFIG_FILE.path.exists():
|
||||
try:
|
||||
VibeConfig.save_updates(VibeConfig.create_default())
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create default config file: {e}[/]")
|
||||
|
||||
if not INSTRUCTIONS_FILE.path.exists():
|
||||
try:
|
||||
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
INSTRUCTIONS_FILE.path.touch()
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create instructions file: {e}[/]")
|
||||
|
||||
if not HISTORY_FILE.path.exists():
|
||||
try:
|
||||
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create history file: {e}[/]")
|
||||
|
||||
|
||||
def load_session(
|
||||
args: argparse.Namespace, config: VibeConfig
|
||||
) -> list[LLMMessage] | None:
|
||||
if not args.continue_session and not args.resume:
|
||||
return None
|
||||
|
||||
if not config.session_logging.enabled:
|
||||
rprint(
|
||||
"[red]Session logging is disabled. "
|
||||
"Enable it in config to use --continue or --resume[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
session_to_load = None
|
||||
if args.continue_session:
|
||||
session_to_load = InteractionLogger.find_latest_session(config.session_logging)
|
||||
if not session_to_load:
|
||||
rprint(
|
||||
f"[red]No previous sessions found in "
|
||||
f"{config.session_logging.save_dir}[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
session_to_load = InteractionLogger.find_session_by_id(
|
||||
args.resume, config.session_logging
|
||||
)
|
||||
if not session_to_load:
|
||||
rprint(
|
||||
f"[red]Session '{args.resume}' not found in "
|
||||
f"{config.session_logging.save_dir}[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
loaded_messages, _ = InteractionLogger.load_session(session_to_load)
|
||||
return loaded_messages
|
||||
except Exception as e:
|
||||
rprint(f"[red]Failed to load session: {e}[/]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_cli(args: argparse.Namespace) -> None:
|
||||
load_api_keys_from_env()
|
||||
|
||||
if args.setup:
|
||||
run_onboarding()
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
bootstrap_config_files()
|
||||
|
||||
initial_mode = get_initial_mode(args)
|
||||
config = load_config_or_exit(args.agent, initial_mode)
|
||||
|
||||
if args.enabled_tools:
|
||||
config.enabled_tools = args.enabled_tools
|
||||
|
||||
loaded_messages = load_session(args, config)
|
||||
|
||||
stdin_prompt = get_prompt_from_stdin()
|
||||
if args.prompt is not None:
|
||||
programmatic_prompt = args.prompt or stdin_prompt
|
||||
if not programmatic_prompt:
|
||||
print(
|
||||
"Error: No prompt provided for programmatic mode", file=sys.stderr
|
||||
)
|
||||
sys.exit(1)
|
||||
output_format = OutputFormat(
|
||||
args.output if hasattr(args, "output") else "text"
|
||||
)
|
||||
|
||||
try:
|
||||
final_response = run_programmatic(
|
||||
config=config,
|
||||
prompt=programmatic_prompt,
|
||||
max_turns=args.max_turns,
|
||||
max_price=args.max_price,
|
||||
output_format=output_format,
|
||||
previous_messages=loaded_messages,
|
||||
mode=initial_mode,
|
||||
)
|
||||
if final_response:
|
||||
print(final_response)
|
||||
sys.exit(0)
|
||||
except ConversationLimitException as e:
|
||||
print(e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
run_textual_ui(
|
||||
config,
|
||||
initial_mode=initial_mode,
|
||||
enable_streaming=True,
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
loaded_messages=loaded_messages,
|
||||
)
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
rprint("\n[dim]Bye![/]")
|
||||
sys.exit(0)
|
||||
|
|
@ -17,46 +17,51 @@ class CommandRegistry:
|
|||
excluded_commands = []
|
||||
self.commands = {
|
||||
"help": Command(
|
||||
aliases=frozenset(["/help", "/h"]),
|
||||
aliases=frozenset(["/help"]),
|
||||
description="Show help message",
|
||||
handler="_show_help",
|
||||
),
|
||||
"status": Command(
|
||||
aliases=frozenset(["/status", "/stats"]),
|
||||
description="Display agent statistics",
|
||||
handler="_show_status",
|
||||
),
|
||||
"config": Command(
|
||||
aliases=frozenset(["/config", "/cfg", "/theme", "/model"]),
|
||||
aliases=frozenset(["/config", "/theme", "/model"]),
|
||||
description="Edit config settings",
|
||||
handler="_show_config",
|
||||
),
|
||||
"reload": Command(
|
||||
aliases=frozenset(["/reload", "/r"]),
|
||||
aliases=frozenset(["/reload"]),
|
||||
description="Reload configuration from disk",
|
||||
handler="_reload_config",
|
||||
),
|
||||
"clear": Command(
|
||||
aliases=frozenset(["/clear", "/reset"]),
|
||||
aliases=frozenset(["/clear"]),
|
||||
description="Clear conversation history",
|
||||
handler="_clear_history",
|
||||
),
|
||||
"log": Command(
|
||||
aliases=frozenset(["/log", "/logpath"]),
|
||||
aliases=frozenset(["/log"]),
|
||||
description="Show path to current interaction log file",
|
||||
handler="_show_log_path",
|
||||
),
|
||||
"compact": Command(
|
||||
aliases=frozenset(["/compact", "/summarize"]),
|
||||
aliases=frozenset(["/compact"]),
|
||||
description="Compact conversation history by summarizing",
|
||||
handler="_compact_history",
|
||||
),
|
||||
"exit": Command(
|
||||
aliases=frozenset(["/exit", "/quit", "/q"]),
|
||||
aliases=frozenset(["/exit"]),
|
||||
description="Exit the application",
|
||||
handler="_exit_app",
|
||||
exits=True,
|
||||
),
|
||||
"terminal-setup": Command(
|
||||
aliases=frozenset(["/terminal-setup"]),
|
||||
description="Configure Shift+Enter for newlines",
|
||||
handler="_setup_terminal",
|
||||
),
|
||||
"status": Command(
|
||||
aliases=frozenset(["/status"]),
|
||||
description="Display agent statistics",
|
||||
handler="_show_status",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
|
|||
|
|
@ -1,27 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.core.config import (
|
||||
MissingAPIKeyError,
|
||||
MissingPromptFileError,
|
||||
VibeConfig,
|
||||
load_api_keys_from_env,
|
||||
from vibe import __version__
|
||||
from vibe.core.paths.config_paths import unlock_config_paths
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
||||
TrustDialogQuitException,
|
||||
ask_trust_folder,
|
||||
)
|
||||
from vibe.core.config_path import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
|
||||
from vibe.core.interaction_logger import InteractionLogger
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.types import OutputFormat, ResumeSessionInfo
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI")
|
||||
parser.add_argument(
|
||||
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
|
||||
)
|
||||
parser.add_argument(
|
||||
"initial_prompt",
|
||||
nargs="?",
|
||||
|
|
@ -41,7 +39,13 @@ def parse_arguments() -> argparse.Namespace:
|
|||
"--auto-approve",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Automatically approve all tool executions.",
|
||||
help="Start in auto-approve mode: never ask for approval before running tools.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--plan",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Start in plan mode: read-only tools for exploration and planning.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-turns",
|
||||
|
|
@ -99,160 +103,41 @@ def parse_arguments() -> argparse.Namespace:
|
|||
return parser.parse_args()
|
||||
|
||||
|
||||
def get_prompt_from_stdin() -> str | None:
|
||||
if sys.stdin.isatty():
|
||||
return None
|
||||
def check_and_resolve_trusted_folder() -> None:
|
||||
cwd = Path.cwd()
|
||||
if not (cwd / ".vibe").exists():
|
||||
return
|
||||
|
||||
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
|
||||
|
||||
if is_folder_trusted is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
if content := sys.stdin.read().strip():
|
||||
sys.stdin = sys.__stdin__ = open("/dev/tty")
|
||||
return content
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except OSError:
|
||||
return None
|
||||
is_folder_trusted = ask_trust_folder(cwd)
|
||||
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
|
||||
return
|
||||
|
||||
return None
|
||||
if is_folder_trusted is True:
|
||||
trusted_folders_manager.add_trusted(cwd)
|
||||
elif is_folder_trusted is False:
|
||||
trusted_folders_manager.add_untrusted(cwd)
|
||||
|
||||
|
||||
def load_config_or_exit(agent: str | None = None) -> VibeConfig:
|
||||
try:
|
||||
return VibeConfig.load(agent)
|
||||
except MissingAPIKeyError:
|
||||
run_onboarding()
|
||||
return VibeConfig.load(agent)
|
||||
except MissingPromptFileError as e:
|
||||
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
rprint(f"[yellow]{e}[/]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None: # noqa: PLR0912, PLR0915
|
||||
load_api_keys_from_env()
|
||||
def main() -> None:
|
||||
args = parse_arguments()
|
||||
|
||||
if args.setup:
|
||||
run_onboarding()
|
||||
sys.exit(0)
|
||||
try:
|
||||
if not CONFIG_FILE.path.exists():
|
||||
try:
|
||||
VibeConfig.save_updates(VibeConfig.create_default())
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create default config file: {e}[/]")
|
||||
is_interactive = args.prompt is None
|
||||
if is_interactive:
|
||||
check_and_resolve_trusted_folder()
|
||||
unlock_config_paths()
|
||||
|
||||
if not INSTRUCTIONS_FILE.path.exists():
|
||||
try:
|
||||
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
INSTRUCTIONS_FILE.path.touch()
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create instructions file: {e}[/]")
|
||||
from vibe.cli.cli import run_cli
|
||||
|
||||
if not HISTORY_FILE.path.exists():
|
||||
try:
|
||||
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
|
||||
except Exception as e:
|
||||
rprint(f"[yellow]Could not create history file: {e}[/]")
|
||||
|
||||
config = load_config_or_exit(args.agent)
|
||||
|
||||
if args.enabled_tools:
|
||||
config.enabled_tools = args.enabled_tools
|
||||
|
||||
loaded_messages = None
|
||||
session_info = None
|
||||
|
||||
if args.continue_session or args.resume:
|
||||
if not config.session_logging.enabled:
|
||||
rprint(
|
||||
"[red]Session logging is disabled. "
|
||||
"Enable it in config to use --continue or --resume[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
session_to_load = None
|
||||
if args.continue_session:
|
||||
session_to_load = InteractionLogger.find_latest_session(
|
||||
config.session_logging
|
||||
)
|
||||
if not session_to_load:
|
||||
rprint(
|
||||
f"[red]No previous sessions found in "
|
||||
f"{config.session_logging.save_dir}[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
session_to_load = InteractionLogger.find_session_by_id(
|
||||
args.resume, config.session_logging
|
||||
)
|
||||
if not session_to_load:
|
||||
rprint(
|
||||
f"[red]Session '{args.resume}' not found in "
|
||||
f"{config.session_logging.save_dir}[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
loaded_messages, metadata = InteractionLogger.load_session(
|
||||
session_to_load
|
||||
)
|
||||
session_id = metadata.get("session_id", "unknown")[:8]
|
||||
session_time = metadata.get("start_time", "unknown time")
|
||||
|
||||
session_info = ResumeSessionInfo(
|
||||
type="continue" if args.continue_session else "resume",
|
||||
session_id=session_id,
|
||||
session_time=session_time,
|
||||
)
|
||||
except Exception as e:
|
||||
rprint(f"[red]Failed to load session: {e}[/]")
|
||||
sys.exit(1)
|
||||
|
||||
stdin_prompt = get_prompt_from_stdin()
|
||||
if args.prompt is not None:
|
||||
programmatic_prompt = args.prompt or stdin_prompt
|
||||
if not programmatic_prompt:
|
||||
print(
|
||||
"Error: No prompt provided for programmatic mode", file=sys.stderr
|
||||
)
|
||||
sys.exit(1)
|
||||
output_format = OutputFormat(
|
||||
args.output if hasattr(args, "output") else "text"
|
||||
)
|
||||
|
||||
try:
|
||||
final_response = run_programmatic(
|
||||
config=config,
|
||||
prompt=programmatic_prompt,
|
||||
max_turns=args.max_turns,
|
||||
max_price=args.max_price,
|
||||
output_format=output_format,
|
||||
previous_messages=loaded_messages,
|
||||
)
|
||||
if final_response:
|
||||
print(final_response)
|
||||
sys.exit(0)
|
||||
except ConversationLimitException as e:
|
||||
print(e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
run_textual_ui(
|
||||
config,
|
||||
auto_approve=args.auto_approve,
|
||||
enable_streaming=True,
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
loaded_messages=loaded_messages,
|
||||
session_info=session_info,
|
||||
)
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
rprint("\n[dim]Bye![/]")
|
||||
sys.exit(0)
|
||||
run_cli(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
400
vibe/cli/terminal_setup.py
Normal file
400
vibe/cli/terminal_setup.py
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Terminal(Enum):
|
||||
VSCODE = "vscode"
|
||||
CURSOR = "cursor"
|
||||
ITERM2 = "iterm2"
|
||||
WEZTERM = "wezterm"
|
||||
GHOSTTY = "ghostty"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SetupResult:
|
||||
success: bool
|
||||
terminal: Terminal
|
||||
message: str
|
||||
requires_restart: bool = False
|
||||
|
||||
|
||||
def _is_cursor() -> bool:
|
||||
path_indicators = [
|
||||
"VSCODE_GIT_ASKPASS_NODE",
|
||||
"VSCODE_GIT_ASKPASS_MAIN",
|
||||
"VSCODE_IPC_HOOK_CLI",
|
||||
"VSCODE_NLS_CONFIG",
|
||||
]
|
||||
for var in path_indicators:
|
||||
val = os.environ.get(var, "").lower()
|
||||
if "cursor" in val:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect_terminal() -> Terminal:
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
if term_program == "vscode":
|
||||
if _is_cursor():
|
||||
return Terminal.CURSOR
|
||||
return Terminal.VSCODE
|
||||
|
||||
term_map = {
|
||||
"iterm.app": Terminal.ITERM2,
|
||||
"wezterm": Terminal.WEZTERM,
|
||||
"ghostty": Terminal.GHOSTTY,
|
||||
}
|
||||
if term_program in term_map:
|
||||
return term_map[term_program]
|
||||
|
||||
if os.environ.get("WEZTERM_PANE"):
|
||||
return Terminal.WEZTERM
|
||||
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
|
||||
return Terminal.GHOSTTY
|
||||
|
||||
return Terminal.UNKNOWN
|
||||
|
||||
|
||||
def _get_vscode_keybindings_path() -> Path | None:
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
base = Path.home() / "Library" / "Application Support" / "Code" / "User"
|
||||
elif system == "Linux":
|
||||
base = Path.home() / ".config" / "Code" / "User"
|
||||
elif system == "Windows":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
base = Path(appdata) / "Code" / "User"
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
return base / "keybindings.json"
|
||||
|
||||
|
||||
def _get_cursor_keybindings_path() -> Path | None:
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
base = Path.home() / "Library" / "Application Support" / "Cursor" / "User"
|
||||
elif system == "Linux":
|
||||
base = Path.home() / ".config" / "Cursor" / "User"
|
||||
elif system == "Windows":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
base = Path(appdata) / "Cursor" / "User"
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
return base / "keybindings.json"
|
||||
|
||||
|
||||
def _parse_keybindings(content: str) -> list[dict[str, Any]]:
|
||||
content = content.strip()
|
||||
if not content or content.startswith("//"):
|
||||
return []
|
||||
|
||||
lines = [line for line in content.split("\n") if not line.strip().startswith("//")]
|
||||
clean_content = "\n".join(lines)
|
||||
|
||||
try:
|
||||
return json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
|
||||
def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
|
||||
"""Setup keybindings for VSCode or Cursor."""
|
||||
if terminal == Terminal.CURSOR:
|
||||
keybindings_path = _get_cursor_keybindings_path()
|
||||
editor_name = "Cursor"
|
||||
else:
|
||||
keybindings_path = _get_vscode_keybindings_path()
|
||||
editor_name = "VSCode"
|
||||
|
||||
if keybindings_path is None:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=terminal,
|
||||
message=f"Could not determine keybindings path for {editor_name}",
|
||||
)
|
||||
|
||||
new_binding = {
|
||||
"key": "shift+enter",
|
||||
"command": "workbench.action.terminal.sendSequence",
|
||||
"args": {"text": "\u001b[13;2u"},
|
||||
"when": "terminalFocus",
|
||||
}
|
||||
|
||||
try:
|
||||
keybindings = _read_existing_keybindings(keybindings_path)
|
||||
|
||||
if _has_shift_enter_binding(keybindings):
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=terminal,
|
||||
message=f"Shift+Enter already configured in {editor_name}",
|
||||
)
|
||||
|
||||
keybindings.append(new_binding)
|
||||
keybindings_path.write_text(json.dumps(keybindings, indent=2) + "\n")
|
||||
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=terminal,
|
||||
message=f"Added Shift+Enter binding to {keybindings_path}",
|
||||
requires_restart=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=terminal,
|
||||
message=f"Failed to configure {editor_name}: {e}",
|
||||
)
|
||||
|
||||
|
||||
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
|
||||
if keybindings_path.exists():
|
||||
content = keybindings_path.read_text()
|
||||
return _parse_keybindings(content)
|
||||
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return []
|
||||
|
||||
|
||||
def _has_shift_enter_binding(keybindings: list[dict[str, Any]]) -> bool:
|
||||
for binding in keybindings:
|
||||
if (
|
||||
binding.get("key") == "shift+enter"
|
||||
and binding.get("command") == "workbench.action.terminal.sendSequence"
|
||||
and binding.get("when") == "terminalFocus"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _setup_iterm2() -> SetupResult:
|
||||
if platform.system() != "Darwin":
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.ITERM2,
|
||||
message="iTerm2 is only available on macOS",
|
||||
)
|
||||
|
||||
plist_key = "0xd-0x20000-0x24"
|
||||
plist_value = """<dict>
|
||||
<key>Text</key>
|
||||
<string>\\n</string>
|
||||
<key>Action</key>
|
||||
<integer>12</integer>
|
||||
<key>Version</key>
|
||||
<integer>1</integer>
|
||||
<key>Keycode</key>
|
||||
<integer>13</integer>
|
||||
<key>Modifiers</key>
|
||||
<integer>131072</integer>
|
||||
</dict>"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["defaults", "read", "com.googlecode.iterm2", "GlobalKeyMap"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if plist_key in result.stdout:
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.ITERM2,
|
||||
message="Shift+Enter already configured in iTerm2",
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"defaults",
|
||||
"write",
|
||||
"com.googlecode.iterm2",
|
||||
"GlobalKeyMap",
|
||||
"-dict-add",
|
||||
plist_key,
|
||||
plist_value,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.ITERM2,
|
||||
message="Added Shift+Enter binding to iTerm2 preferences",
|
||||
requires_restart=True,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.ITERM2,
|
||||
message=f"Failed to configure iTerm2: {e.stderr}",
|
||||
)
|
||||
except Exception as e:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.ITERM2,
|
||||
message=f"Failed to configure iTerm2: {e}",
|
||||
)
|
||||
|
||||
|
||||
def _setup_wezterm() -> SetupResult:
|
||||
wezterm_config = Path.home() / ".wezterm.lua"
|
||||
|
||||
key_binding = """{
|
||||
key = "Enter",
|
||||
mods = "SHIFT",
|
||||
action = wezterm.action.SendString("\\x1b[13;2u"),
|
||||
}"""
|
||||
|
||||
try:
|
||||
if wezterm_config.exists():
|
||||
content = wezterm_config.read_text()
|
||||
|
||||
if 'mods = "SHIFT"' in content and 'key = "Enter"' in content:
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message="Shift+Enter already configured in WezTerm",
|
||||
)
|
||||
|
||||
if "keys = {" in content:
|
||||
content = content.replace("keys = {", f"keys = {{\n {key_binding},")
|
||||
else:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message="Please manually add the following to your .wezterm.lua:\n\n"
|
||||
f" keys = {{\n {key_binding}\n }}",
|
||||
)
|
||||
else:
|
||||
content = f"""local wezterm = require 'wezterm'
|
||||
|
||||
return {{
|
||||
keys = {{
|
||||
{key_binding}
|
||||
}},
|
||||
}}
|
||||
"""
|
||||
|
||||
wezterm_config.write_text(content)
|
||||
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message=f"Added Shift+Enter binding to {wezterm_config}",
|
||||
requires_restart=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.WEZTERM,
|
||||
message=f"Failed to configure WezTerm: {e}",
|
||||
)
|
||||
|
||||
|
||||
def _setup_ghostty() -> SetupResult:
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
config_path = (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "com.mitchellh.ghostty"
|
||||
/ "config"
|
||||
)
|
||||
elif system == "Linux":
|
||||
xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
||||
config_path = Path(xdg_config) / "ghostty" / "config"
|
||||
else:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message="Ghostty configuration path unknown for this OS",
|
||||
)
|
||||
|
||||
keybind_line = "keybind = shift+enter=text:\\x1b[13;2u"
|
||||
|
||||
try:
|
||||
if config_path.exists():
|
||||
content = config_path.read_text()
|
||||
|
||||
if "shift+enter" in content.lower():
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message="Shift+Enter already configured in Ghostty",
|
||||
)
|
||||
|
||||
if not content.endswith("\n"):
|
||||
content += "\n"
|
||||
content += keybind_line + "\n"
|
||||
else:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = keybind_line + "\n"
|
||||
|
||||
config_path.write_text(content)
|
||||
|
||||
return SetupResult(
|
||||
success=True,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message=f"Added Shift+Enter binding to {config_path}",
|
||||
requires_restart=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.GHOSTTY,
|
||||
message=f"Failed to configure Ghostty: {e}",
|
||||
)
|
||||
|
||||
|
||||
def setup_terminal() -> SetupResult:
|
||||
terminal = detect_terminal()
|
||||
|
||||
match terminal:
|
||||
case Terminal.VSCODE:
|
||||
return _setup_vscode_like_terminal(Terminal.VSCODE)
|
||||
case Terminal.CURSOR:
|
||||
return _setup_vscode_like_terminal(Terminal.CURSOR)
|
||||
case Terminal.ITERM2:
|
||||
return _setup_iterm2()
|
||||
case Terminal.WEZTERM:
|
||||
return _setup_wezterm()
|
||||
case Terminal.GHOSTTY:
|
||||
return _setup_ghostty()
|
||||
case Terminal.UNKNOWN:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.UNKNOWN,
|
||||
message="Could not detect terminal. Supported terminals:\n"
|
||||
"- VSCode\n"
|
||||
"- Cursor\n"
|
||||
"- iTerm2\n"
|
||||
"- WezTerm\n"
|
||||
"- Ghostty\n\n"
|
||||
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
|
||||
)
|
||||
|
|
@ -3,17 +3,20 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
from enum import StrEnum, auto
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, ClassVar, assert_never
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Horizontal, VerticalScroll
|
||||
from textual.events import MouseUp
|
||||
from textual.events import AppBlur, AppFocus, MouseUp
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe import __version__ as CORE_VERSION
|
||||
from vibe.cli.clipboard import copy_selection_to_clipboard
|
||||
from vibe.cli.commands import CommandRegistry
|
||||
from vibe.cli.terminal_setup import setup_terminal
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
|
|
@ -28,6 +31,7 @@ from vibe.cli.textual_ui.widgets.messages import (
|
|||
InterruptMessage,
|
||||
UserCommandMessage,
|
||||
UserMessage,
|
||||
WarningMessage,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.mode_indicator import ModeIndicator
|
||||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
|
|
@ -42,13 +46,13 @@ from vibe.cli.update_notifier import (
|
|||
VersionUpdateGateway,
|
||||
get_update_if_available,
|
||||
)
|
||||
from vibe.core import __version__ as CORE_VERSION
|
||||
from vibe.core.agent import Agent
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.config_path import HISTORY_FILE
|
||||
from vibe.core.modes import AgentMode, next_mode
|
||||
from vibe.core.paths.config_paths import HISTORY_FILE
|
||||
from vibe.core.tools.base import BaseToolConfig, ToolPermission
|
||||
from vibe.core.types import ApprovalResponse, LLMMessage, ResumeSessionInfo, Role
|
||||
from vibe.core.types import ApprovalResponse, LLMMessage, Role
|
||||
from vibe.core.utils import (
|
||||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
|
|
@ -68,7 +72,8 @@ class VibeApp(App):
|
|||
CSS_PATH = "app.tcss"
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+c", "force_quit", "Quit", show=False),
|
||||
Binding("ctrl+c", "clear_quit", "Quit", show=False),
|
||||
Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
|
||||
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
|
||||
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
|
||||
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
|
||||
|
|
@ -82,11 +87,10 @@ class VibeApp(App):
|
|||
def __init__(
|
||||
self,
|
||||
config: VibeConfig,
|
||||
auto_approve: bool = False,
|
||||
initial_mode: AgentMode = AgentMode.DEFAULT,
|
||||
enable_streaming: bool = False,
|
||||
initial_prompt: str | None = None,
|
||||
loaded_messages: list[LLMMessage] | None = None,
|
||||
session_info: ResumeSessionInfo | None = None,
|
||||
version_update_notifier: VersionUpdateGateway | None = None,
|
||||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
|
|
@ -94,7 +98,7 @@ class VibeApp(App):
|
|||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.config = config
|
||||
self.auto_approve = auto_approve
|
||||
self._current_agent_mode = initial_mode
|
||||
self.enable_streaming = enable_streaming
|
||||
self.agent: Agent | None = None
|
||||
self._agent_running = False
|
||||
|
|
@ -128,12 +132,12 @@ class VibeApp(App):
|
|||
|
||||
self._initial_prompt = initial_prompt
|
||||
self._loaded_messages = loaded_messages
|
||||
self._session_info = session_info
|
||||
self._agent_init_task: asyncio.Task | None = None
|
||||
# prevent a race condition where the agent initialization
|
||||
# completes exactly at the moment the user interrupts
|
||||
self._agent_init_interrupted = False
|
||||
self._auto_scroll = True
|
||||
self._last_escape_time: float | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll(id="chat"):
|
||||
|
|
@ -142,7 +146,7 @@ class VibeApp(App):
|
|||
|
||||
with Horizontal(id="loading-area"):
|
||||
yield Static(id="loading-area-content")
|
||||
yield ModeIndicator(auto_approve=self.auto_approve)
|
||||
yield ModeIndicator(mode=self._current_agent_mode)
|
||||
|
||||
yield Static(id="todo-area")
|
||||
|
||||
|
|
@ -151,7 +155,7 @@ class VibeApp(App):
|
|||
history_file=self.history_file,
|
||||
command_registry=self.commands,
|
||||
id="input-container",
|
||||
show_warning=self.auto_approve,
|
||||
safety=self._current_agent_mode.safety,
|
||||
)
|
||||
|
||||
with Horizontal(id="bottom-bar"):
|
||||
|
|
@ -184,8 +188,8 @@ class VibeApp(App):
|
|||
await self._show_dangerous_directory_warning()
|
||||
self._schedule_update_notification()
|
||||
|
||||
if self._session_info:
|
||||
await self._mount_and_scroll(AssistantMessage(self._session_info.message()))
|
||||
if self._loaded_messages:
|
||||
await self._rebuild_history_from_messages()
|
||||
|
||||
if self._initial_prompt:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
|
@ -258,6 +262,21 @@ class VibeApp(App):
|
|||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
|
||||
def _show_todo_area(self) -> None:
|
||||
try:
|
||||
todo_area = self.query_one("#todo-area")
|
||||
todo_area.add_class("loading-active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _hide_todo_area(self) -> None:
|
||||
try:
|
||||
todo_area = self.query_one("#todo-area")
|
||||
todo_area.remove_class("loading-active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
|
||||
if message.key == "textual_theme":
|
||||
|
|
@ -307,6 +326,7 @@ class VibeApp(App):
|
|||
|
||||
async def _handle_command(self, user_input: str) -> bool:
|
||||
if command := self.commands.find_command(user_input):
|
||||
await self._mount_and_scroll(UserMessage(user_input))
|
||||
handler = getattr(self, command.handler)
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler()
|
||||
|
|
@ -419,11 +439,11 @@ class VibeApp(App):
|
|||
try:
|
||||
agent = Agent(
|
||||
self.config,
|
||||
auto_approve=self.auto_approve,
|
||||
mode=self._current_agent_mode,
|
||||
enable_streaming=self.enable_streaming,
|
||||
)
|
||||
|
||||
if not self.auto_approve:
|
||||
if not self._current_agent_mode.auto_approve:
|
||||
agent.approval_callback = self._approval_callback
|
||||
|
||||
if self._loaded_messages:
|
||||
|
|
@ -450,6 +470,58 @@ class VibeApp(App):
|
|||
self._agent_initializing = False
|
||||
self._agent_init_task = None
|
||||
|
||||
async def _rebuild_history_from_messages(self) -> None:
|
||||
if not self._loaded_messages:
|
||||
return
|
||||
|
||||
messages_area = self.query_one("#messages")
|
||||
tool_call_map: dict[str, str] = {}
|
||||
|
||||
for msg in self._loaded_messages:
|
||||
if msg.role == Role.system:
|
||||
continue
|
||||
|
||||
match msg.role:
|
||||
case Role.user:
|
||||
if msg.content:
|
||||
await messages_area.mount(UserMessage(msg.content))
|
||||
|
||||
case Role.assistant:
|
||||
await self._mount_history_assistant_message(
|
||||
msg, messages_area, tool_call_map
|
||||
)
|
||||
|
||||
case Role.tool:
|
||||
tool_name = msg.name or tool_call_map.get(
|
||||
msg.tool_call_id or "", "tool"
|
||||
)
|
||||
await messages_area.mount(
|
||||
ToolResultMessage(
|
||||
tool_name=tool_name,
|
||||
content=msg.content,
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
|
||||
async def _mount_history_assistant_message(
|
||||
self, msg: LLMMessage, messages_area: Widget, tool_call_map: dict[str, str]
|
||||
) -> None:
|
||||
if msg.content:
|
||||
widget = AssistantMessage(msg.content)
|
||||
await messages_area.mount(widget)
|
||||
await widget.write_initial_content()
|
||||
await widget.stop_stream()
|
||||
|
||||
if not msg.tool_calls:
|
||||
return
|
||||
|
||||
for tool_call in msg.tool_calls:
|
||||
tool_name = tool_call.function.name or "unknown"
|
||||
if tool_call.id:
|
||||
tool_call_map[tool_call.id] = tool_name
|
||||
|
||||
await messages_area.mount(ToolCallMessage(tool_name=tool_name))
|
||||
|
||||
def _ensure_agent_init_task(self) -> asyncio.Task | None:
|
||||
if self.agent:
|
||||
self._agent_init_task = None
|
||||
|
|
@ -486,6 +558,7 @@ class VibeApp(App):
|
|||
loading = LoadingWidget()
|
||||
self._loading_widget = loading
|
||||
await loading_area.mount(loading)
|
||||
self._show_todo_area()
|
||||
|
||||
try:
|
||||
rendered_prompt = render_path_prompt(
|
||||
|
|
@ -527,6 +600,7 @@ class VibeApp(App):
|
|||
if self._loading_widget:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
await self._finalize_current_streaming_message()
|
||||
|
||||
async def _interrupt_agent(self) -> None:
|
||||
|
|
@ -563,6 +637,8 @@ class VibeApp(App):
|
|||
self._agent_running = False
|
||||
loading_area = self.query_one("#loading-area-content")
|
||||
await loading_area.remove_children()
|
||||
self._loading_widget = None
|
||||
self._hide_todo_area()
|
||||
|
||||
await self._finalize_current_streaming_message()
|
||||
await self._mount_and_scroll(InterruptMessage())
|
||||
|
|
@ -603,7 +679,7 @@ class VibeApp(App):
|
|||
|
||||
async def _reload_config(self) -> None:
|
||||
try:
|
||||
new_config = VibeConfig.load()
|
||||
new_config = VibeConfig.load(**self._current_agent_mode.config_overrides)
|
||||
|
||||
if self.agent:
|
||||
await self.agent.reload_with_initial_messages(config=new_config)
|
||||
|
|
@ -656,6 +732,7 @@ class VibeApp(App):
|
|||
max_tokens=current_state.max_tokens,
|
||||
current_tokens=self.agent.stats.context_tokens,
|
||||
)
|
||||
await messages_area.mount(UserMessage("/clear"))
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Conversation history cleared!")
|
||||
)
|
||||
|
|
@ -738,23 +815,68 @@ class VibeApp(App):
|
|||
self.event_handler.current_compact = compact_msg
|
||||
await self._mount_and_scroll(compact_msg)
|
||||
|
||||
self._agent_task = asyncio.create_task(
|
||||
self._run_compact(compact_msg, old_tokens)
|
||||
)
|
||||
|
||||
async def _run_compact(self, compact_msg: CompactMessage, old_tokens: int) -> None:
|
||||
self._agent_running = True
|
||||
try:
|
||||
if not self.agent:
|
||||
return
|
||||
|
||||
await self.agent.compact()
|
||||
new_tokens = self.agent.stats.context_tokens
|
||||
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
|
||||
self.event_handler.current_compact = None
|
||||
|
||||
if self._context_progress:
|
||||
current_state = self._context_progress.tokens
|
||||
self._context_progress.tokens = TokenState(
|
||||
max_tokens=current_state.max_tokens, current_tokens=new_tokens
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
compact_msg.set_error("Compaction interrupted")
|
||||
raise
|
||||
except Exception as e:
|
||||
compact_msg.set_error(str(e))
|
||||
self.event_handler.current_compact = None
|
||||
finally:
|
||||
self._agent_running = False
|
||||
self._agent_task = None
|
||||
if self.event_handler:
|
||||
self.event_handler.current_compact = None
|
||||
|
||||
def _get_session_resume_info(self) -> str | None:
|
||||
if not self.agent:
|
||||
return None
|
||||
if not self.agent.interaction_logger.enabled:
|
||||
return None
|
||||
if not self.agent.interaction_logger.session_id:
|
||||
return None
|
||||
return self.agent.interaction_logger.session_id[:8]
|
||||
|
||||
async def _exit_app(self) -> None:
|
||||
self.exit()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
async def _setup_terminal(self) -> None:
|
||||
result = setup_terminal()
|
||||
|
||||
if result.success:
|
||||
if result.requires_restart:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
f"{result.terminal.value}: Set up Shift+Enter keybind (You may need to restart your terminal.)"
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
WarningMessage(
|
||||
f"{result.terminal.value}: Shift+Enter keybind already set up"
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(result.message, collapsed=self._tools_collapsed)
|
||||
)
|
||||
|
||||
async def _switch_to_config_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
|
|
@ -833,7 +955,7 @@ class VibeApp(App):
|
|||
history_file=self.history_file,
|
||||
command_registry=self.commands,
|
||||
id="input-container",
|
||||
show_warning=self.auto_approve,
|
||||
safety=self._current_agent_mode.safety,
|
||||
)
|
||||
await bottom_container.mount(chat_input_container)
|
||||
self._chat_input_container = chat_input_container
|
||||
|
|
@ -857,12 +979,15 @@ class VibeApp(App):
|
|||
pass
|
||||
|
||||
def action_interrupt(self) -> None:
|
||||
current_time = time.monotonic()
|
||||
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
try:
|
||||
config_app = self.query_one(ConfigApp)
|
||||
config_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Approval:
|
||||
|
|
@ -871,8 +996,23 @@ class VibeApp(App):
|
|||
approval_app.action_reject()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
return
|
||||
|
||||
if (
|
||||
self._current_bottom_app == BottomApp.Input
|
||||
and self._last_escape_time is not None
|
||||
and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
|
||||
):
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
if input_widget.value:
|
||||
input_widget.value = ""
|
||||
self._last_escape_time = None
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
has_pending_user_message = any(
|
||||
msg.has_class("pending") for msg in self.query(UserMessage)
|
||||
)
|
||||
|
|
@ -886,71 +1026,72 @@ class VibeApp(App):
|
|||
if interrupt_needed:
|
||||
self.run_worker(self._interrupt_agent(), exclusive=False)
|
||||
|
||||
self._last_escape_time = current_time
|
||||
self._scroll_to_bottom()
|
||||
self._focus_current_bottom_app()
|
||||
|
||||
async def action_toggle_tool(self) -> None:
|
||||
if not self.event_handler:
|
||||
return
|
||||
|
||||
self._tools_collapsed = not self._tools_collapsed
|
||||
|
||||
non_todo_results = [
|
||||
result
|
||||
for result in self.event_handler.tool_results
|
||||
if result.event.tool_name != "todo"
|
||||
]
|
||||
|
||||
for result in non_todo_results:
|
||||
result.collapsed = self._tools_collapsed
|
||||
await result.render_result()
|
||||
for result in self.query(ToolResultMessage):
|
||||
if result.tool_name != "todo":
|
||||
await result.set_collapsed(self._tools_collapsed)
|
||||
|
||||
try:
|
||||
error_messages = self.query(ErrorMessage)
|
||||
for error_msg in error_messages:
|
||||
for error_msg in self.query(ErrorMessage):
|
||||
error_msg.set_collapsed(self._tools_collapsed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def action_toggle_todo(self) -> None:
|
||||
if not self.event_handler:
|
||||
return
|
||||
|
||||
self._todos_collapsed = not self._todos_collapsed
|
||||
|
||||
todo_results = [
|
||||
result
|
||||
for result in self.event_handler.tool_results
|
||||
if result.event.tool_name == "todo"
|
||||
]
|
||||
|
||||
for result in todo_results:
|
||||
result.collapsed = self._todos_collapsed
|
||||
await result.render_result()
|
||||
for result in self.query(ToolResultMessage):
|
||||
if result.tool_name == "todo":
|
||||
await result.set_collapsed(self._todos_collapsed)
|
||||
|
||||
def action_cycle_mode(self) -> None:
|
||||
if self._current_bottom_app != BottomApp.Input:
|
||||
return
|
||||
|
||||
self.auto_approve = not self.auto_approve
|
||||
new_mode = next_mode(self._current_agent_mode)
|
||||
self._switch_mode(new_mode)
|
||||
|
||||
def _switch_mode(self, mode: AgentMode) -> None:
|
||||
if mode == self._current_agent_mode:
|
||||
return
|
||||
|
||||
self._current_agent_mode = mode
|
||||
|
||||
if self._mode_indicator:
|
||||
self._mode_indicator.set_auto_approve(self.auto_approve)
|
||||
|
||||
self._mode_indicator.set_mode(mode)
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.set_show_warning(self.auto_approve)
|
||||
self._chat_input_container.set_safety(mode.safety)
|
||||
|
||||
if self.agent:
|
||||
self.agent.auto_approve = self.auto_approve
|
||||
|
||||
if self.auto_approve:
|
||||
if mode.auto_approve:
|
||||
self.agent.approval_callback = None
|
||||
else:
|
||||
self.agent.approval_callback = self._approval_callback
|
||||
|
||||
self.run_worker(
|
||||
self._do_agent_switch(mode), group="mode_switch", exclusive=True
|
||||
)
|
||||
|
||||
self._focus_current_bottom_app()
|
||||
|
||||
def action_force_quit(self) -> None:
|
||||
async def _do_agent_switch(self, mode: AgentMode) -> None:
|
||||
if self.agent:
|
||||
await self.agent.switch_mode(mode)
|
||||
|
||||
if self._context_progress:
|
||||
current_state = self._context_progress.tokens
|
||||
self._context_progress.tokens = TokenState(
|
||||
max_tokens=current_state.max_tokens,
|
||||
current_tokens=self.agent.stats.context_tokens,
|
||||
)
|
||||
|
||||
def action_clear_quit(self) -> None:
|
||||
input_widgets = self.query(ChatInputContainer)
|
||||
if input_widgets:
|
||||
input_widget = input_widgets.first()
|
||||
|
|
@ -958,10 +1099,13 @@ class VibeApp(App):
|
|||
input_widget.value = ""
|
||||
return
|
||||
|
||||
self.action_force_quit()
|
||||
|
||||
def action_force_quit(self) -> None:
|
||||
if self._agent_task and not self._agent_task.done():
|
||||
self._agent_task.cancel()
|
||||
|
||||
self.exit()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
def action_scroll_chat_up(self) -> None:
|
||||
try:
|
||||
|
|
@ -984,7 +1128,7 @@ class VibeApp(App):
|
|||
is_dangerous, reason = is_dangerous_directory()
|
||||
if is_dangerous:
|
||||
warning = (
|
||||
f"⚠️ WARNING: {reason}\n\nRunning in this location is not recommended."
|
||||
f"⚠ WARNING: {reason}\n\nRunning in this location is not recommended."
|
||||
)
|
||||
await self._mount_and_scroll(UserCommandMessage(warning))
|
||||
|
||||
|
|
@ -1110,25 +1254,42 @@ class VibeApp(App):
|
|||
def on_mouse_up(self, event: MouseUp) -> None:
|
||||
copy_selection_to_clipboard(self)
|
||||
|
||||
def on_app_blur(self, event: AppBlur) -> None:
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
self._chat_input_container.input_widget.set_app_focus(False)
|
||||
|
||||
def on_app_focus(self, event: AppFocus) -> None:
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
self._chat_input_container.input_widget.set_app_focus(True)
|
||||
|
||||
|
||||
def _print_session_resume_message(session_id: str | None) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
|
||||
print()
|
||||
print("To continue this session, run: vibe --continue")
|
||||
print(f"Or: vibe --resume {session_id}")
|
||||
|
||||
|
||||
def run_textual_ui(
|
||||
config: VibeConfig,
|
||||
auto_approve: bool = False,
|
||||
initial_mode: AgentMode = AgentMode.DEFAULT,
|
||||
enable_streaming: bool = False,
|
||||
initial_prompt: str | None = None,
|
||||
loaded_messages: list[LLMMessage] | None = None,
|
||||
session_info: ResumeSessionInfo | None = None,
|
||||
) -> None:
|
||||
"""Run the Textual UI."""
|
||||
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
app = VibeApp(
|
||||
config=config,
|
||||
auto_approve=auto_approve,
|
||||
initial_mode=initial_mode,
|
||||
enable_streaming=enable_streaming,
|
||||
initial_prompt=initial_prompt,
|
||||
loaded_messages=loaded_messages,
|
||||
session_info=session_info,
|
||||
version_update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
)
|
||||
app.run()
|
||||
session_id = app.run()
|
||||
_print_session_resume_message(session_id)
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ Screen {
|
|||
height: 1fr;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
padding: 0 2 0 2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#loading-area {
|
||||
height: auto;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
padding: 1 2 0 2;
|
||||
padding: 1 0 0 0;
|
||||
layout: horizontal;
|
||||
align: left middle;
|
||||
}
|
||||
|
|
@ -26,14 +26,28 @@ Screen {
|
|||
|
||||
#todo-area {
|
||||
height: auto;
|
||||
max-height: 8;
|
||||
max-height: 10;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
margin-top: 0;
|
||||
|
||||
.tool-result-border {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.loading-active .tool-result-border {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tool-result-content {
|
||||
max-height: 9;
|
||||
overflow-y: auto;
|
||||
scrollbar-visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
#bottom-app-container {
|
||||
|
|
@ -46,7 +60,7 @@ Screen {
|
|||
height: auto;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
padding: 0 2 1 2;
|
||||
padding: 0 0 1 0;
|
||||
align: left middle;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
|
@ -71,7 +85,7 @@ Screen {
|
|||
width: 100%;
|
||||
background: $background;
|
||||
padding: 0;
|
||||
margin: 0 2 1 2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#completion-popup {
|
||||
|
|
@ -84,11 +98,23 @@ Screen {
|
|||
height: auto;
|
||||
width: 100%;
|
||||
background: $background;
|
||||
border: round $foreground-muted;
|
||||
border-top: solid $foreground-muted;
|
||||
border-bottom: solid $foreground-muted;
|
||||
padding: 0 1;
|
||||
|
||||
&.border-warning {
|
||||
border: round $warning;
|
||||
border-top: solid $warning;
|
||||
border-bottom: solid $warning;
|
||||
}
|
||||
|
||||
&.border-safe {
|
||||
border-top: solid $success;
|
||||
border-bottom: solid $success;
|
||||
}
|
||||
|
||||
&.border-error {
|
||||
border-top: solid $error;
|
||||
border-bottom: solid $error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,12 +138,13 @@ Screen {
|
|||
color: $text;
|
||||
border: none;
|
||||
padding: 0;
|
||||
scrollbar-visibility: hidden;
|
||||
}
|
||||
|
||||
ToastRack {
|
||||
align: left bottom;
|
||||
padding: 0 2;
|
||||
margin: 0 2 6 2;
|
||||
padding: 0;
|
||||
margin: 0 0 6 0;
|
||||
}
|
||||
|
||||
Markdown MarkdownFence {
|
||||
|
|
@ -128,8 +155,10 @@ Markdown MarkdownFence {
|
|||
|
||||
.user-message {
|
||||
margin-top: 1;
|
||||
padding: 1 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: $surface;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
|
|
@ -194,25 +223,135 @@ Markdown MarkdownFence {
|
|||
}
|
||||
}
|
||||
|
||||
.assistant-message-content Markdown > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.interrupt-message {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
margin-left: 2;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
background: $warning 10%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.interrupt-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.interrupt-border {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.interrupt-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: $text-warning;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
background: $error 10%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.error-border {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: $error;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.warning-message {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.warning-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.warning-border {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
color: $text-warning;
|
||||
}
|
||||
|
||||
.user-command-message {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-command-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user-command-border {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.user-command-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
|
||||
Markdown {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
Markdown > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
Markdown > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bash-output-message {
|
||||
margin-top: 1;
|
||||
width: 100%;
|
||||
|
|
@ -280,7 +419,7 @@ Markdown MarkdownFence {
|
|||
color: $text-muted;
|
||||
}
|
||||
|
||||
BlinkingMessage {
|
||||
StatusMessage {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
|
|
@ -290,10 +429,11 @@ BlinkingMessage {
|
|||
}
|
||||
}
|
||||
|
||||
.blink-dot {
|
||||
.status-indicator-icon {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
margin-right: 1;
|
||||
|
||||
&.success {
|
||||
color: $text-success;
|
||||
|
|
@ -304,7 +444,7 @@ BlinkingMessage {
|
|||
}
|
||||
}
|
||||
|
||||
.blink-text {
|
||||
.status-indicator-text {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
|
|
@ -326,22 +466,40 @@ BlinkingMessage {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 0;
|
||||
margin-left: 2;
|
||||
padding: 1 2;
|
||||
background: $surface;
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: $foreground;
|
||||
|
||||
&.error-text {
|
||||
background: $error 10%;
|
||||
color: $text-error;
|
||||
}
|
||||
|
||||
&.warning-text {
|
||||
background: $warning 10%;
|
||||
color: $text-warning;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-result-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-result-border {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding: 0 1 0 2;
|
||||
color: $foreground-muted;
|
||||
}
|
||||
|
||||
.tool-result-content {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-call-widget {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
@ -430,7 +588,6 @@ BlinkingMessage {
|
|||
|
||||
#todo-area .tool-result {
|
||||
margin-left: 0;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
.loading-widget {
|
||||
|
|
@ -443,10 +600,11 @@ BlinkingMessage {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.loading-star {
|
||||
.loading-indicator {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: $warning;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
.loading-status {
|
||||
|
|
@ -478,8 +636,8 @@ WelcomeBanner {
|
|||
border-title-align: center;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
padding: 2 4;
|
||||
margin: 1 1 0 1;
|
||||
padding: 2 0;
|
||||
margin: 1 0 0 0;
|
||||
color: $foreground;
|
||||
|
||||
.muted {
|
||||
|
|
@ -493,7 +651,7 @@ WelcomeBanner {
|
|||
background: $background;
|
||||
border: round $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0 2 1 2;
|
||||
margin: 0 0 1 0;
|
||||
}
|
||||
|
||||
#config-content {
|
||||
|
|
@ -556,7 +714,7 @@ WelcomeBanner {
|
|||
background: $background;
|
||||
border: round $foreground-muted;
|
||||
padding: 0 1;
|
||||
margin: 0 2 1 2;
|
||||
margin: 0 0 1 0;
|
||||
}
|
||||
|
||||
#approval-content {
|
||||
|
|
@ -645,21 +803,35 @@ Horizontal {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
ExpandingBorder {
|
||||
width: auto;
|
||||
height: 100%;
|
||||
content-align: left bottom;
|
||||
}
|
||||
|
||||
ModeIndicator {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0 0 0 1;
|
||||
color: $warning;
|
||||
color: $text-muted;
|
||||
align: left middle;
|
||||
|
||||
&.mode-on {
|
||||
&.mode-safe {
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.mode-neutral {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
&.mode-destructive {
|
||||
color: $warning;
|
||||
}
|
||||
|
||||
&.mode-off {
|
||||
color: $text-muted;
|
||||
&.mode-yolo {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -146,12 +146,12 @@ class EventHandler:
|
|||
|
||||
def stop_current_tool_call(self) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.stop_blinking()
|
||||
self.current_tool_call.stop_spinning()
|
||||
self.current_tool_call = None
|
||||
|
||||
def stop_current_compact(self) -> None:
|
||||
if self.current_compact:
|
||||
self.current_compact.stop_blinking(success=False)
|
||||
self.current_compact.stop_spinning(success=False)
|
||||
self.current_compact = None
|
||||
|
||||
def get_last_tool_result(self) -> ToolResultMessage | None:
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ class ApprovalApp(Container):
|
|||
def _update_options(self) -> None:
|
||||
options = [
|
||||
("Yes", "yes"),
|
||||
(f"Yes and always allow {self.tool_name} this session", "yes"),
|
||||
(f"Yes and always allow {self.tool_name} for this session", "yes"),
|
||||
("No and tell the agent what to do instead", "no"),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class BlinkingMessage(Static):
|
||||
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
|
||||
self.blink_state = False
|
||||
self._blink_timer = None
|
||||
self._is_blinking = True
|
||||
self.success = True
|
||||
self._initial_text = initial_text
|
||||
self._dot_widget: Static | None = None
|
||||
self._text_widget: Static | None = None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
self._dot_widget = Static("● ", classes="blink-dot")
|
||||
yield self._dot_widget
|
||||
self._text_widget = Static("", markup=False, classes="blink-text")
|
||||
yield self._text_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.update_display()
|
||||
self._blink_timer = self.set_interval(0.5, self.toggle_blink)
|
||||
|
||||
def toggle_blink(self) -> None:
|
||||
if not self._is_blinking:
|
||||
return
|
||||
self.blink_state = not self.blink_state
|
||||
self.update_display()
|
||||
|
||||
def update_display(self) -> None:
|
||||
if not self._dot_widget or not self._text_widget:
|
||||
return
|
||||
|
||||
content = self.get_content()
|
||||
|
||||
if self._is_blinking:
|
||||
dot = "● " if self.blink_state else "○ "
|
||||
self._dot_widget.update(dot)
|
||||
self._dot_widget.remove_class("success")
|
||||
self._dot_widget.remove_class("error")
|
||||
else:
|
||||
self._dot_widget.update("● ")
|
||||
if self.success:
|
||||
self._dot_widget.add_class("success")
|
||||
self._dot_widget.remove_class("error")
|
||||
else:
|
||||
self._dot_widget.add_class("error")
|
||||
self._dot_widget.remove_class("success")
|
||||
|
||||
self._text_widget.update(content)
|
||||
|
||||
def get_content(self) -> str:
|
||||
return self._initial_text
|
||||
|
||||
def stop_blinking(self, success: bool = True) -> None:
|
||||
self._is_blinking = False
|
||||
self.blink_state = True
|
||||
self.success = success
|
||||
self.update_display()
|
||||
|
|
@ -11,7 +11,7 @@ from textual.widget import Widget
|
|||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.history_manager import HistoryManager
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
|
||||
|
||||
|
||||
class ChatInputBody(Widget):
|
||||
|
|
@ -44,26 +44,35 @@ class ChatInputBody(Widget):
|
|||
if self.input_widget:
|
||||
self.input_widget.focus()
|
||||
|
||||
def _parse_mode_and_text(self, text: str) -> tuple[InputMode, str]:
|
||||
if text.startswith("!"):
|
||||
return "!", text[1:]
|
||||
elif text.startswith("/"):
|
||||
return "/", text[1:]
|
||||
else:
|
||||
return ">", text
|
||||
|
||||
def _update_prompt(self) -> None:
|
||||
if not self.input_widget or not self.prompt_widget:
|
||||
return
|
||||
|
||||
text = self.input_widget.text
|
||||
if text.startswith("!"):
|
||||
self.prompt_widget.update("!")
|
||||
elif text.startswith("/"):
|
||||
self.prompt_widget.update("/")
|
||||
else:
|
||||
self.prompt_widget.update(">")
|
||||
self.prompt_widget.update(self.input_widget.input_mode)
|
||||
|
||||
def on_chat_text_area_mode_changed(self, event: ChatTextArea.ModeChanged) -> None:
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.update(event.mode)
|
||||
|
||||
def _load_history_entry(self, text: str, cursor_col: int | None = None) -> None:
|
||||
if not self.input_widget:
|
||||
return
|
||||
|
||||
self.input_widget._navigating_history = True
|
||||
self.input_widget.load_text(text)
|
||||
mode, display_text = self._parse_mode_and_text(text)
|
||||
|
||||
first_line = text.split("\n")[0] if text else ""
|
||||
self.input_widget._navigating_history = True
|
||||
self.input_widget.set_mode(mode)
|
||||
self.input_widget.load_text(display_text)
|
||||
|
||||
first_line = display_text.split("\n")[0]
|
||||
col = cursor_col if cursor_col is not None else len(first_line)
|
||||
cursor_pos = (0, col)
|
||||
|
||||
|
|
@ -137,9 +146,6 @@ class ChatInputBody(Widget):
|
|||
self.input_widget._cursor_pos_after_load = None
|
||||
self.input_widget._cursor_moved_since_load = False
|
||||
|
||||
def on_text_area_changed(self, event: ChatTextArea.Changed) -> None:
|
||||
self._update_prompt()
|
||||
|
||||
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
|
||||
event.stop()
|
||||
|
||||
|
|
@ -161,12 +167,16 @@ class ChatInputBody(Widget):
|
|||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
return self.input_widget.text if self.input_widget else ""
|
||||
if not self.input_widget:
|
||||
return ""
|
||||
return self.input_widget.get_full_text()
|
||||
|
||||
@value.setter
|
||||
def value(self, text: str) -> None:
|
||||
if self.input_widget:
|
||||
self.input_widget.load_text(text)
|
||||
mode, display_text = self._parse_mode_and_text(text)
|
||||
self.input_widget.set_mode(mode)
|
||||
self.input_widget.load_text(display_text)
|
||||
self._update_prompt()
|
||||
|
||||
def focus_input(self) -> None:
|
||||
|
|
|
|||
|
|
@ -17,11 +17,17 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
|
||||
from vibe.core.modes import ModeSafety
|
||||
|
||||
SAFETY_BORDER_CLASSES: dict[ModeSafety, str] = {
|
||||
ModeSafety.SAFE: "border-safe",
|
||||
ModeSafety.DESTRUCTIVE: "border-warning",
|
||||
ModeSafety.YOLO: "border-error",
|
||||
}
|
||||
|
||||
|
||||
class ChatInputContainer(Vertical):
|
||||
ID_INPUT_BOX = "input-box"
|
||||
BORDER_WARNING_CLASS = "border-warning"
|
||||
|
||||
class Submitted(Message):
|
||||
def __init__(self, value: str) -> None:
|
||||
|
|
@ -32,13 +38,13 @@ class ChatInputContainer(Vertical):
|
|||
self,
|
||||
history_file: Path | None = None,
|
||||
command_registry: CommandRegistry | None = None,
|
||||
show_warning: bool = False,
|
||||
safety: ModeSafety = ModeSafety.NEUTRAL,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._history_file = history_file
|
||||
self._command_registry = command_registry or CommandRegistry()
|
||||
self._show_warning = show_warning
|
||||
self._safety = safety
|
||||
|
||||
command_entries = [
|
||||
(alias, command.description)
|
||||
|
|
@ -57,9 +63,8 @@ class ChatInputContainer(Vertical):
|
|||
self._completion_popup = CompletionPopup()
|
||||
yield self._completion_popup
|
||||
|
||||
with Vertical(
|
||||
id=self.ID_INPUT_BOX, classes="border-warning" if self._show_warning else ""
|
||||
):
|
||||
border_class = SAFETY_BORDER_CLASSES.get(self._safety, "")
|
||||
with Vertical(id=self.ID_INPUT_BOX, classes=border_class):
|
||||
self._body = ChatInputBody(history_file=self._history_file, id="input-body")
|
||||
|
||||
yield self._body
|
||||
|
|
@ -91,7 +96,7 @@ class ChatInputContainer(Vertical):
|
|||
widget = self._body.input_widget
|
||||
if widget:
|
||||
self._completion_manager.on_text_changed(
|
||||
widget.text, widget.get_cursor_offset()
|
||||
widget.get_full_text(), widget._get_full_cursor_offset()
|
||||
)
|
||||
|
||||
def focus_input(self) -> None:
|
||||
|
|
@ -131,6 +136,9 @@ class ChatInputContainer(Vertical):
|
|||
widget = self.input_widget
|
||||
if not widget or not self._body:
|
||||
return
|
||||
start, end, replacement = widget.adjust_from_full_text_coords(
|
||||
start, end, replacement
|
||||
)
|
||||
|
||||
text = widget.text
|
||||
start = max(0, min(start, len(text)))
|
||||
|
|
@ -147,11 +155,12 @@ class ChatInputContainer(Vertical):
|
|||
event.stop()
|
||||
self.post_message(self.Submitted(event.value))
|
||||
|
||||
def set_show_warning(self, show_warning: bool) -> None:
|
||||
self._show_warning = show_warning
|
||||
def set_safety(self, safety: ModeSafety) -> None:
|
||||
self._safety = safety
|
||||
|
||||
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
|
||||
if show_warning:
|
||||
input_box.add_class(self.BORDER_WARNING_CLASS)
|
||||
else:
|
||||
input_box.remove_class(self.BORDER_WARNING_CLASS)
|
||||
for border_class in SAFETY_BORDER_CLASSES.values():
|
||||
input_box.remove_class(border_class)
|
||||
|
||||
if safety in SAFETY_BORDER_CLASSES:
|
||||
input_box.add_class(SAFETY_BORDER_CLASSES[safety])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
from textual import events
|
||||
from textual.binding import Binding
|
||||
|
|
@ -12,6 +12,8 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|||
MultiCompletionManager,
|
||||
)
|
||||
|
||||
InputMode = Literal["!", "/", ">"]
|
||||
|
||||
|
||||
class ChatTextArea(TextArea):
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
|
|
@ -24,6 +26,9 @@ class ChatTextArea(TextArea):
|
|||
)
|
||||
]
|
||||
|
||||
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
|
||||
DEFAULT_MODE: ClassVar[Literal[">"]] = ">"
|
||||
|
||||
class Submitted(Message):
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
|
|
@ -42,8 +47,16 @@ class ChatTextArea(TextArea):
|
|||
class HistoryReset(Message):
|
||||
"""Message sent when history navigation should be reset."""
|
||||
|
||||
class ModeChanged(Message):
|
||||
"""Message sent when the input mode changes (>, !, /)."""
|
||||
|
||||
def __init__(self, mode: InputMode) -> None:
|
||||
self.mode = mode
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._input_mode: InputMode = self.DEFAULT_MODE
|
||||
self._history_prefix: str | None = None
|
||||
self._last_text = ""
|
||||
self._navigating_history = False
|
||||
|
|
@ -53,9 +66,17 @@ class ChatTextArea(TextArea):
|
|||
self._cursor_pos_after_load: tuple[int, int] | None = None
|
||||
self._cursor_moved_since_load: bool = False
|
||||
self._completion_manager: MultiCompletionManager | None = None
|
||||
self._app_has_focus: bool = True
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
if self._app_has_focus:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
||||
def set_app_focus(self, has_focus: bool) -> None:
|
||||
self._app_has_focus = has_focus
|
||||
self.cursor_blink = has_focus
|
||||
if has_focus and not self.has_focus:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
|
@ -76,7 +97,7 @@ class ChatTextArea(TextArea):
|
|||
|
||||
if self._completion_manager and not was_navigating_history:
|
||||
self._completion_manager.on_text_changed(
|
||||
self.text, self.get_cursor_offset()
|
||||
self.get_full_text(), self._get_full_cursor_offset()
|
||||
)
|
||||
|
||||
def _reset_prefix(self) -> None:
|
||||
|
|
@ -96,7 +117,10 @@ class ChatTextArea(TextArea):
|
|||
cursor_row, cursor_col = self.cursor_location
|
||||
lines = self.text.split("\n")
|
||||
if cursor_row < len(lines):
|
||||
return lines[cursor_row][:cursor_col]
|
||||
visible_prefix = lines[cursor_row][:cursor_col]
|
||||
if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
|
||||
return self._input_mode + visible_prefix
|
||||
return visible_prefix
|
||||
return ""
|
||||
|
||||
def _handle_history_up(self) -> bool:
|
||||
|
|
@ -139,12 +163,14 @@ class ChatTextArea(TextArea):
|
|||
self.post_message(self.HistoryNext(self._history_prefix))
|
||||
return True
|
||||
|
||||
async def _on_key(self, event: events.Key) -> None:
|
||||
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
manager = self._completion_manager
|
||||
if manager:
|
||||
match manager.on_key(event, self.text, self.get_cursor_offset()):
|
||||
match manager.on_key(
|
||||
event, self.get_full_text(), self._get_full_cursor_offset()
|
||||
):
|
||||
case CompletionResult.HANDLED:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
|
@ -152,7 +178,7 @@ class ChatTextArea(TextArea):
|
|||
case CompletionResult.SUBMIT:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
value = self.text.strip()
|
||||
value = self.get_full_text().strip()
|
||||
if value:
|
||||
self._reset_prefix()
|
||||
self.post_message(self.Submitted(value))
|
||||
|
|
@ -161,7 +187,7 @@ class ChatTextArea(TextArea):
|
|||
if event.key == "enter":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
value = self.text.strip()
|
||||
value = self.get_full_text().strip()
|
||||
if value:
|
||||
self._reset_prefix()
|
||||
self.post_message(self.Submitted(value))
|
||||
|
|
@ -172,6 +198,23 @@ class ChatTextArea(TextArea):
|
|||
event.stop()
|
||||
return
|
||||
|
||||
if (
|
||||
event.character
|
||||
and event.character in self.MODE_CHARACTERS
|
||||
and not self.text
|
||||
and self._input_mode == self.DEFAULT_MODE
|
||||
):
|
||||
self._set_mode(event.character)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
return
|
||||
|
||||
if event.key == "backspace" and self._should_reset_mode_on_backspace():
|
||||
self._set_mode(self.DEFAULT_MODE)
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
return
|
||||
|
||||
if event.key == "up" and self._handle_history_up():
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
|
@ -189,7 +232,7 @@ class ChatTextArea(TextArea):
|
|||
self._completion_manager = manager
|
||||
if self._completion_manager:
|
||||
self._completion_manager.on_text_changed(
|
||||
self.text, self.get_cursor_offset()
|
||||
self.get_full_text(), self._get_full_cursor_offset()
|
||||
)
|
||||
|
||||
def get_cursor_offset(self) -> int:
|
||||
|
|
@ -244,3 +287,59 @@ class ChatTextArea(TextArea):
|
|||
def clear_text(self) -> None:
|
||||
self.clear()
|
||||
self.reset_history_state()
|
||||
self._set_mode(self.DEFAULT_MODE)
|
||||
|
||||
def _set_mode(self, mode: InputMode) -> None:
|
||||
if self._input_mode == mode:
|
||||
return
|
||||
self._input_mode = mode
|
||||
self.post_message(self.ModeChanged(mode))
|
||||
if self._completion_manager:
|
||||
self._completion_manager.on_text_changed(
|
||||
self.get_full_text(), self._get_full_cursor_offset()
|
||||
)
|
||||
|
||||
def _should_reset_mode_on_backspace(self) -> bool:
|
||||
return (
|
||||
self._input_mode != self.DEFAULT_MODE
|
||||
and not self.text
|
||||
and self.get_cursor_offset() == 0
|
||||
)
|
||||
|
||||
def get_full_text(self) -> str:
|
||||
if self._input_mode != self.DEFAULT_MODE:
|
||||
return self._input_mode + self.text
|
||||
return self.text
|
||||
|
||||
def _get_full_cursor_offset(self) -> int:
|
||||
return self.get_cursor_offset() + self._get_mode_prefix_length()
|
||||
|
||||
def _get_mode_prefix_length(self) -> int:
|
||||
return {">": 0, "/": 1, "!": 1}[self._input_mode]
|
||||
|
||||
@property
|
||||
def input_mode(self) -> InputMode:
|
||||
return self._input_mode
|
||||
|
||||
def set_mode(self, mode: InputMode) -> None:
|
||||
if self._input_mode != mode:
|
||||
self._input_mode = mode
|
||||
self.post_message(self.ModeChanged(mode))
|
||||
|
||||
def adjust_from_full_text_coords(
|
||||
self, start: int, end: int, replacement: str
|
||||
) -> tuple[int, int, str]:
|
||||
"""Translate from full-text coordinates to widget coordinates.
|
||||
|
||||
The completion manager works with 'full text' that includes the mode prefix.
|
||||
This adjusts coordinates and replacement text for the actual widget text.
|
||||
"""
|
||||
mode_len = self._get_mode_prefix_length()
|
||||
|
||||
adj_start = max(0, start - mode_len)
|
||||
adj_end = max(adj_start, end - mode_len)
|
||||
|
||||
if mode_len > 0 and replacement.startswith(self._input_mode):
|
||||
replacement = replacement[mode_len:]
|
||||
|
||||
return adj_start, adj_end, replacement
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.blinking_message import BlinkingMessage
|
||||
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
||||
|
||||
|
||||
class CompactMessage(BlinkingMessage):
|
||||
class CompactMessage(StatusMessage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.add_class("compact-message")
|
||||
|
|
@ -12,7 +12,7 @@ class CompactMessage(BlinkingMessage):
|
|||
self.error_message: str | None = None
|
||||
|
||||
def get_content(self) -> str:
|
||||
if self._is_blinking:
|
||||
if self._is_spinning:
|
||||
return "Compacting conversation history..."
|
||||
|
||||
if self.error_message:
|
||||
|
|
@ -35,8 +35,8 @@ class CompactMessage(BlinkingMessage):
|
|||
) -> None:
|
||||
self.old_tokens = old_tokens
|
||||
self.new_tokens = new_tokens
|
||||
self.stop_blinking(success=True)
|
||||
self.stop_spinning(success=True)
|
||||
|
||||
def set_error(self, error_message: str) -> None:
|
||||
self.error_message = error_message
|
||||
self.stop_blinking(success=False)
|
||||
self.stop_spinning(success=False)
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ from textual.app import ComposeResult
|
|||
from textual.containers import Horizontal
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.spinner import BrailleSpinner
|
||||
|
||||
|
||||
class LoadingWidget(Static):
|
||||
BRAILLE_SPINNER = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
||||
|
||||
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
|
||||
|
||||
EASTER_EGGS: ClassVar[list[str]] = [
|
||||
|
|
@ -51,8 +51,9 @@ class LoadingWidget(Static):
|
|||
def __init__(self, status: str | None = None) -> None:
|
||||
super().__init__(classes="loading-widget")
|
||||
self.status = status or self._get_default_status()
|
||||
self.gradient_offset = 0
|
||||
self.spinner_pos = 0
|
||||
self.current_color_index = 0
|
||||
self.transition_progress = 0
|
||||
self._spinner = BrailleSpinner()
|
||||
self.char_widgets: list[Static] = []
|
||||
self.spinner_widget: Static | None = None
|
||||
self.ellipsis_widget: Static | None = None
|
||||
|
|
@ -84,13 +85,12 @@ class LoadingWidget(Static):
|
|||
|
||||
def set_status(self, status: str) -> None:
|
||||
self.status = self._apply_easter_egg(status)
|
||||
self.gradient_offset = 0
|
||||
self._rebuild_chars()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="loading-container"):
|
||||
self.spinner_widget = Static(
|
||||
self.BRAILLE_SPINNER[0] + " ", classes="loading-star"
|
||||
self._spinner.current_frame(), classes="loading-indicator"
|
||||
)
|
||||
yield self.spinner_widget
|
||||
|
||||
|
|
@ -127,30 +127,40 @@ class LoadingWidget(Static):
|
|||
self.update_animation()
|
||||
self.set_interval(0.1, self.update_animation)
|
||||
|
||||
def _get_gradient_color(self, position: int) -> str:
|
||||
color_index = (position - self.gradient_offset) % len(self.TARGET_COLORS)
|
||||
return self.TARGET_COLORS[color_index]
|
||||
def _get_color_for_position(self, position: int) -> str:
|
||||
current_color = self.TARGET_COLORS[self.current_color_index]
|
||||
next_color = self.TARGET_COLORS[
|
||||
(self.current_color_index + 1) % len(self.TARGET_COLORS)
|
||||
]
|
||||
if position < self.transition_progress:
|
||||
return next_color
|
||||
return current_color
|
||||
|
||||
def update_animation(self) -> None:
|
||||
total_elements = 1 + len(self.char_widgets) + 2
|
||||
|
||||
if self.spinner_widget:
|
||||
spinner_char = self.BRAILLE_SPINNER[self.spinner_pos]
|
||||
color_0 = self._get_gradient_color(0)
|
||||
color_1 = self._get_gradient_color(1)
|
||||
self.spinner_widget.update(f"[{color_0}]{spinner_char}[/][{color_1}] [/]")
|
||||
self.spinner_pos = (self.spinner_pos + 1) % len(self.BRAILLE_SPINNER)
|
||||
spinner_char = self._spinner.next_frame()
|
||||
color = self._get_color_for_position(0)
|
||||
self.spinner_widget.update(f"[{color}]{spinner_char}[/]")
|
||||
|
||||
for i, widget in enumerate(self.char_widgets):
|
||||
position = 2 + i
|
||||
color = self._get_gradient_color(position)
|
||||
position = 1 + i
|
||||
color = self._get_color_for_position(position)
|
||||
widget.update(f"[{color}]{self.status[i]}[/]")
|
||||
|
||||
if self.ellipsis_widget:
|
||||
ellipsis_start = 2 + len(self.status)
|
||||
color_ellipsis = self._get_gradient_color(ellipsis_start)
|
||||
color_space = self._get_gradient_color(ellipsis_start + 1)
|
||||
ellipsis_start = 1 + len(self.status)
|
||||
color_ellipsis = self._get_color_for_position(ellipsis_start)
|
||||
color_space = self._get_color_for_position(ellipsis_start + 1)
|
||||
self.ellipsis_widget.update(f"[{color_ellipsis}]…[/][{color_space}] [/]")
|
||||
|
||||
self.gradient_offset = (self.gradient_offset + 1) % len(self.TARGET_COLORS)
|
||||
self.transition_progress += 1
|
||||
if self.transition_progress > total_elements:
|
||||
self.current_color_index = (self.current_color_index + 1) % len(
|
||||
self.TARGET_COLORS
|
||||
)
|
||||
self.transition_progress = 0
|
||||
|
||||
if self.hint_widget and self.start_time is not None:
|
||||
elapsed = int(time() - self.start_time)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import Markdown, Static
|
||||
from textual.widgets._markdown import MarkdownStream
|
||||
|
||||
|
||||
class NonSelectableStatic(Static):
|
||||
@property
|
||||
def text_selection(self) -> None:
|
||||
return None
|
||||
|
||||
@text_selection.setter
|
||||
def text_selection(self, value: Any) -> None:
|
||||
pass
|
||||
|
||||
def get_selection(self, selection: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class ExpandingBorder(NonSelectableStatic):
|
||||
def render(self) -> str:
|
||||
height = self.size.height
|
||||
return "\n".join(["⎢"] * (height - 1) + ["⎣"])
|
||||
|
||||
def on_resize(self) -> None:
|
||||
self.refresh()
|
||||
|
||||
|
||||
class UserMessage(Static):
|
||||
def __init__(self, content: str, pending: bool = False) -> None:
|
||||
super().__init__()
|
||||
|
|
@ -15,7 +39,7 @@ class UserMessage(Static):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="user-message-container"):
|
||||
yield Static("> ", classes="user-message-prompt")
|
||||
yield NonSelectableStatic("> ", classes="user-message-prompt")
|
||||
yield Static(self._content, markup=False, classes="user-message-content")
|
||||
if self._pending:
|
||||
self.add_class("pending")
|
||||
|
|
@ -43,7 +67,7 @@ class AssistantMessage(Static):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="assistant-message-container"):
|
||||
yield Static("● ", classes="assistant-message-dot")
|
||||
yield NonSelectableStatic("● ", classes="assistant-message-dot")
|
||||
with Vertical(classes="assistant-message-content"):
|
||||
markdown = Markdown("")
|
||||
self._markdown = markdown
|
||||
|
|
@ -87,14 +111,25 @@ class UserCommandMessage(Static):
|
|||
self._content = content
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Markdown(self._content)
|
||||
with Horizontal(classes="user-command-container"):
|
||||
yield ExpandingBorder(classes="user-command-border")
|
||||
with Vertical(classes="user-command-content"):
|
||||
yield Markdown(self._content)
|
||||
|
||||
|
||||
class InterruptMessage(Static):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"Interrupted · What should Vibe do instead?", classes="interrupt-message"
|
||||
)
|
||||
super().__init__()
|
||||
self.add_class("interrupt-message")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="interrupt-container"):
|
||||
yield ExpandingBorder(classes="interrupt-border")
|
||||
yield Static(
|
||||
"Interrupted · What should Vibe do instead?",
|
||||
markup=False,
|
||||
classes="interrupt-content",
|
||||
)
|
||||
|
||||
|
||||
class BashOutputMessage(Static):
|
||||
|
|
@ -125,24 +160,41 @@ class BashOutputMessage(Static):
|
|||
|
||||
class ErrorMessage(Static):
|
||||
def __init__(self, error: str, collapsed: bool = True) -> None:
|
||||
super().__init__(classes="error-message")
|
||||
super().__init__()
|
||||
self.add_class("error-message")
|
||||
self._error = error
|
||||
self.collapsed = collapsed
|
||||
self._content_widget: Static | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="error-container"):
|
||||
yield ExpandingBorder(classes="error-border")
|
||||
self._content_widget = Static(
|
||||
self._get_text(), markup=False, classes="error-content"
|
||||
)
|
||||
yield self._content_widget
|
||||
|
||||
def _get_text(self) -> str:
|
||||
if self.collapsed:
|
||||
yield Static("Error. (ctrl+o to expand)", markup=False)
|
||||
else:
|
||||
yield Static(f"Error: {self._error}", markup=False)
|
||||
return "Error. (ctrl+o to expand)"
|
||||
return f"Error: {self._error}"
|
||||
|
||||
def set_collapsed(self, collapsed: bool) -> None:
|
||||
if self.collapsed == collapsed:
|
||||
return
|
||||
|
||||
self.collapsed = collapsed
|
||||
self.remove_children()
|
||||
if self._content_widget:
|
||||
self._content_widget.update(self._get_text())
|
||||
|
||||
if self.collapsed:
|
||||
self.mount(Static("Error. (ctrl+o to expand)", markup=False))
|
||||
else:
|
||||
self.mount(Static(f"Error: {self._error}", markup=False))
|
||||
|
||||
class WarningMessage(Static):
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__()
|
||||
self.add_class("warning-message")
|
||||
self._message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="warning-container"):
|
||||
yield ExpandingBorder(classes="warning-border")
|
||||
yield Static(self._message, markup=False, classes="warning-content")
|
||||
|
|
|
|||
|
|
@ -2,24 +2,46 @@ from __future__ import annotations
|
|||
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.core.modes import AgentMode, ModeSafety
|
||||
|
||||
MODE_ICONS: dict[AgentMode, str] = {
|
||||
AgentMode.DEFAULT: "⏵",
|
||||
AgentMode.PLAN: "⏸︎",
|
||||
AgentMode.ACCEPT_EDITS: "⏵⏵",
|
||||
AgentMode.AUTO_APPROVE: "⏵⏵⏵",
|
||||
}
|
||||
|
||||
SAFETY_CLASSES: dict[ModeSafety, str] = {
|
||||
ModeSafety.SAFE: "mode-safe",
|
||||
ModeSafety.NEUTRAL: "mode-neutral",
|
||||
ModeSafety.DESTRUCTIVE: "mode-destructive",
|
||||
ModeSafety.YOLO: "mode-yolo",
|
||||
}
|
||||
|
||||
|
||||
class ModeIndicator(Static):
|
||||
def __init__(self, auto_approve: bool = False) -> None:
|
||||
"""Displays the current agent mode with safety-colored indicator."""
|
||||
|
||||
def __init__(self, mode: AgentMode = AgentMode.DEFAULT) -> None:
|
||||
super().__init__()
|
||||
self.can_focus = False
|
||||
self._auto_approve = auto_approve
|
||||
self._mode = mode
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
if self._auto_approve:
|
||||
self.update("⏵⏵ auto-approve on (shift+tab to toggle)")
|
||||
self.add_class("mode-on")
|
||||
self.remove_class("mode-off")
|
||||
else:
|
||||
self.update("⏵ auto-approve off (shift+tab to toggle)")
|
||||
self.add_class("mode-off")
|
||||
self.remove_class("mode-on")
|
||||
icon = MODE_ICONS.get(self._mode, "??")
|
||||
name = self._mode.display_name.lower()
|
||||
self.update(f"{icon} {name} mode (shift+tab to cycle)")
|
||||
|
||||
def set_auto_approve(self, enabled: bool) -> None:
|
||||
self._auto_approve = enabled
|
||||
for safety_class in SAFETY_CLASSES.values():
|
||||
self.remove_class(safety_class)
|
||||
|
||||
self.add_class(SAFETY_CLASSES[self._mode.safety])
|
||||
|
||||
@property
|
||||
def mode(self) -> AgentMode:
|
||||
return self._mode
|
||||
|
||||
def set_mode(self, mode: AgentMode) -> None:
|
||||
self._mode = mode
|
||||
self._update_display()
|
||||
|
|
|
|||
87
vibe/cli/textual_ui/widgets/spinner.py
Normal file
87
vibe/cli/textual_ui/widgets/spinner.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from enum import Enum
|
||||
from typing import ClassVar
|
||||
|
||||
|
||||
class Spinner(ABC):
|
||||
FRAMES: ClassVar[tuple[str, ...]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._position = 0
|
||||
|
||||
def next_frame(self) -> str:
|
||||
frame = self.FRAMES[self._position]
|
||||
self._position = (self._position + 1) % len(self.FRAMES)
|
||||
return frame
|
||||
|
||||
def current_frame(self) -> str:
|
||||
return self.FRAMES[self._position]
|
||||
|
||||
def reset(self) -> None:
|
||||
self._position = 0
|
||||
|
||||
|
||||
class BrailleSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = (
|
||||
"⠋",
|
||||
"⠙",
|
||||
"⠹",
|
||||
"⠸",
|
||||
"⠼",
|
||||
"⠴",
|
||||
"⠦",
|
||||
"⠧",
|
||||
"⠇",
|
||||
"⠏",
|
||||
)
|
||||
|
||||
|
||||
class LineSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = ("|", "/", "-", "\\")
|
||||
|
||||
|
||||
class CircleSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = ("◴", "◷", "◶", "◵")
|
||||
|
||||
|
||||
class BowtieSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = (
|
||||
"⠋",
|
||||
"⠙",
|
||||
"⠚",
|
||||
"⠞",
|
||||
"⠖",
|
||||
"⠦",
|
||||
"⠴",
|
||||
"⠲",
|
||||
"⠳",
|
||||
"⠓",
|
||||
)
|
||||
|
||||
|
||||
class DotWaveSpinner(Spinner):
|
||||
FRAMES: ClassVar[tuple[str, ...]] = ("⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷")
|
||||
|
||||
|
||||
class SpinnerType(Enum):
|
||||
BRAILLE = "braille"
|
||||
LINE = "line"
|
||||
CIRCLE = "circle"
|
||||
BOWTIE = "bowtie"
|
||||
DOT_WAVE = "dot_wave"
|
||||
|
||||
|
||||
_SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
|
||||
SpinnerType.BRAILLE: BrailleSpinner,
|
||||
SpinnerType.LINE: LineSpinner,
|
||||
SpinnerType.CIRCLE: CircleSpinner,
|
||||
SpinnerType.BOWTIE: BowtieSpinner,
|
||||
SpinnerType.DOT_WAVE: DotWaveSpinner,
|
||||
}
|
||||
|
||||
|
||||
def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
|
||||
spinner_class = _SPINNER_CLASSES.get(spinner_type, BrailleSpinner)
|
||||
return spinner_class()
|
||||
75
vibe/cli/textual_ui/widgets/status_message.py
Normal file
75
vibe/cli/textual_ui/widgets/status_message.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import Spinner, SpinnerType, create_spinner
|
||||
|
||||
|
||||
class StatusMessage(Static):
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
|
||||
|
||||
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
|
||||
self._spinner: Spinner = create_spinner(self.SPINNER_TYPE)
|
||||
self._spinner_timer = None
|
||||
self._is_spinning = True
|
||||
self.success = True
|
||||
self._initial_text = initial_text
|
||||
self._indicator_widget: Static | None = None
|
||||
self._text_widget: Static | None = None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
self._indicator_widget = NonSelectableStatic(
|
||||
self._spinner.current_frame(),
|
||||
markup=False,
|
||||
classes="status-indicator-icon",
|
||||
)
|
||||
yield self._indicator_widget
|
||||
self._text_widget = Static(
|
||||
"", markup=False, classes="status-indicator-text"
|
||||
)
|
||||
yield self._text_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.update_display()
|
||||
self._spinner_timer = self.set_interval(0.1, self._update_spinner)
|
||||
|
||||
def _update_spinner(self) -> None:
|
||||
if not self._is_spinning:
|
||||
return
|
||||
self.update_display()
|
||||
|
||||
def update_display(self) -> None:
|
||||
if not self._indicator_widget or not self._text_widget:
|
||||
return
|
||||
|
||||
content = self.get_content()
|
||||
|
||||
if self._is_spinning:
|
||||
self._indicator_widget.update(self._spinner.next_frame())
|
||||
self._indicator_widget.remove_class("success")
|
||||
self._indicator_widget.remove_class("error")
|
||||
elif self.success:
|
||||
self._indicator_widget.update("✓")
|
||||
self._indicator_widget.add_class("success")
|
||||
self._indicator_widget.remove_class("error")
|
||||
else:
|
||||
self._indicator_widget.update("✕")
|
||||
self._indicator_widget.add_class("error")
|
||||
self._indicator_widget.remove_class("success")
|
||||
|
||||
self._text_widget.update(content)
|
||||
|
||||
def get_content(self) -> str:
|
||||
return self._initial_text
|
||||
|
||||
def stop_spinning(self, success: bool = True) -> None:
|
||||
self._is_spinning = False
|
||||
self.success = success
|
||||
self.update_display()
|
||||
|
|
@ -4,6 +4,8 @@ from textual.app import ComposeResult
|
|||
from textual.containers import Vertical
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
||||
|
||||
|
||||
class ToolApprovalWidget(Vertical):
|
||||
def __init__(self, data: dict) -> None:
|
||||
|
|
@ -27,17 +29,23 @@ class ToolApprovalWidget(Vertical):
|
|||
|
||||
|
||||
class ToolResultWidget(Static):
|
||||
SHORTCUT = DEFAULT_TOOL_SHORTCUT
|
||||
|
||||
def __init__(self, data: dict, collapsed: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.data = data
|
||||
self.collapsed = collapsed
|
||||
self.add_class("tool-result-widget")
|
||||
|
||||
def _hint(self) -> str:
|
||||
action = "expand" if self.collapsed else "collapse"
|
||||
return f"({self.SHORTCUT} to {action})"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
|
|
@ -66,7 +74,7 @@ class BashResultWidget(ToolResultWidget):
|
|||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
|
|
@ -96,7 +104,7 @@ class WriteFileResultWidget(ToolResultWidget):
|
|||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
|
|
@ -158,7 +166,7 @@ class SearchReplaceResultWidget(ToolResultWidget):
|
|||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
|
|
@ -185,13 +193,15 @@ class TodoApprovalWidget(ToolApprovalWidget):
|
|||
|
||||
|
||||
class TodoResultWidget(ToolResultWidget):
|
||||
SHORTCUT = TOOL_SHORTCUTS["todo"]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(message, markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
yield Static("")
|
||||
|
||||
by_status = self.data.get("todos_by_status", {})
|
||||
|
|
@ -228,7 +238,7 @@ class ReadFileResultWidget(ToolResultWidget):
|
|||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
|
|
@ -277,7 +287,7 @@ class GrepResultWidget(ToolResultWidget):
|
|||
message = self.data.get("message", "")
|
||||
|
||||
if self.collapsed:
|
||||
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
|
||||
yield Static(f"{message} {self._hint()}", markup=False)
|
||||
else:
|
||||
yield Static(message, markup=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,86 +1,163 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.renderers import get_renderer
|
||||
from vibe.cli.textual_ui.widgets.blinking_message import BlinkingMessage
|
||||
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
|
||||
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
||||
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
|
||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||
from vibe.core.types import ToolCallEvent, ToolResultEvent
|
||||
|
||||
|
||||
class ToolCallMessage(BlinkingMessage):
|
||||
def __init__(self, event: ToolCallEvent) -> None:
|
||||
self.event = event
|
||||
class ToolCallMessage(StatusMessage):
|
||||
def __init__(
|
||||
self, event: ToolCallEvent | None = None, *, tool_name: str | None = None
|
||||
) -> None:
|
||||
if event is None and tool_name is None:
|
||||
raise ValueError("Either event or tool_name must be provided")
|
||||
|
||||
self._event = event
|
||||
self._tool_name = tool_name or (event.tool_name if event else "unknown")
|
||||
self._is_history = event is None
|
||||
|
||||
super().__init__()
|
||||
self.add_class("tool-call")
|
||||
|
||||
if self._is_history:
|
||||
self._is_spinning = False
|
||||
|
||||
def get_content(self) -> str:
|
||||
if not self.event.tool_class:
|
||||
return f"{self.event.tool_name}"
|
||||
|
||||
adapter = ToolUIDataAdapter(self.event.tool_class)
|
||||
display = adapter.get_call_display(self.event)
|
||||
|
||||
return f"{display.summary}"
|
||||
if self._event and self._event.tool_class:
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
display = adapter.get_call_display(self._event)
|
||||
return display.summary
|
||||
return self._tool_name
|
||||
|
||||
|
||||
class ToolResultMessage(Static):
|
||||
def __init__(
|
||||
self,
|
||||
event: ToolResultEvent,
|
||||
event: ToolResultEvent | None = None,
|
||||
call_widget: ToolCallMessage | None = None,
|
||||
collapsed: bool = True,
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
content: str | None = None,
|
||||
) -> None:
|
||||
self.event = event
|
||||
self.call_widget = call_widget
|
||||
if event is None and tool_name is None:
|
||||
raise ValueError("Either event or tool_name must be provided")
|
||||
|
||||
self._event = event
|
||||
self._call_widget = call_widget
|
||||
self._tool_name = tool_name or (event.tool_name if event else "unknown")
|
||||
self._content = content
|
||||
self.collapsed = collapsed
|
||||
self._content_container: Vertical | None = None
|
||||
|
||||
super().__init__()
|
||||
self.add_class("tool-result")
|
||||
|
||||
@property
|
||||
def tool_name(self) -> str:
|
||||
return self._tool_name
|
||||
|
||||
def _shortcut(self) -> str:
|
||||
return TOOL_SHORTCUTS.get(self._tool_name, DEFAULT_TOOL_SHORTCUT)
|
||||
|
||||
def _hint(self) -> str:
|
||||
action = "expand" if self.collapsed else "collapse"
|
||||
return f"({self._shortcut()} to {action})"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="tool-result-container"):
|
||||
yield ExpandingBorder(classes="tool-result-border")
|
||||
self._content_container = Vertical(classes="tool-result-content")
|
||||
yield self._content_container
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
if self.call_widget:
|
||||
success = not self.event.error and not self.event.skipped
|
||||
self.call_widget.stop_blinking(success=success)
|
||||
await self.render_result()
|
||||
if self._call_widget:
|
||||
success = self._event is None or (
|
||||
not self._event.error and not self._event.skipped
|
||||
)
|
||||
self._call_widget.stop_spinning(success=success)
|
||||
await self._render_result()
|
||||
|
||||
async def render_result(self) -> None:
|
||||
await self.remove_children()
|
||||
|
||||
if self.event.error:
|
||||
self.add_class("error-text")
|
||||
if self.collapsed:
|
||||
self.update("Error. (ctrl+o to expand)")
|
||||
else:
|
||||
await self.mount(Static(f"Error: {self.event.error}", markup=False))
|
||||
async def _render_result(self) -> None:
|
||||
if self._content_container is None:
|
||||
return
|
||||
|
||||
if self.event.skipped:
|
||||
self.add_class("warning-text")
|
||||
reason = self.event.skip_reason or "User skipped"
|
||||
await self._content_container.remove_children()
|
||||
|
||||
if self._event is None:
|
||||
await self._render_simple()
|
||||
return
|
||||
|
||||
if self._event.error:
|
||||
self.add_class("error-text")
|
||||
if self.collapsed:
|
||||
self.update("Skipped. (ctrl+o to expand)")
|
||||
await self._content_container.mount(
|
||||
Static(f"Error. {self._hint()}", markup=False)
|
||||
)
|
||||
else:
|
||||
await self.mount(Static(f"Skipped: {reason}", markup=False))
|
||||
await self._content_container.mount(
|
||||
Static(f"Error: {self._event.error}", markup=False)
|
||||
)
|
||||
return
|
||||
|
||||
if self._event.skipped:
|
||||
self.add_class("warning-text")
|
||||
reason = self._event.skip_reason or "User skipped"
|
||||
if self.collapsed:
|
||||
await self._content_container.mount(
|
||||
Static(f"Skipped. {self._hint()}", markup=False)
|
||||
)
|
||||
else:
|
||||
await self._content_container.mount(
|
||||
Static(f"Skipped: {reason}", markup=False)
|
||||
)
|
||||
return
|
||||
|
||||
self.remove_class("error-text")
|
||||
self.remove_class("warning-text")
|
||||
|
||||
adapter = ToolUIDataAdapter(self.event.tool_class)
|
||||
display = adapter.get_result_display(self.event)
|
||||
if self._event.tool_class is None:
|
||||
await self._render_simple()
|
||||
return
|
||||
|
||||
renderer = get_renderer(self.event.tool_name)
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
display = adapter.get_result_display(self._event)
|
||||
renderer = get_renderer(self._event.tool_name)
|
||||
widget_class, data = renderer.get_result_widget(display, self.collapsed)
|
||||
await self._content_container.mount(
|
||||
widget_class(data, collapsed=self.collapsed)
|
||||
)
|
||||
|
||||
result_widget = widget_class(data, collapsed=self.collapsed)
|
||||
await self.mount(result_widget)
|
||||
async def _render_simple(self) -> None:
|
||||
if self._content_container is None:
|
||||
return
|
||||
|
||||
if self.collapsed:
|
||||
await self._content_container.mount(
|
||||
Static(f"{self._tool_name} completed {self._hint()}", markup=False)
|
||||
)
|
||||
return
|
||||
|
||||
if self._content:
|
||||
await self._content_container.mount(Static(self._content, markup=False))
|
||||
else:
|
||||
await self._content_container.mount(
|
||||
Static(f"{self._tool_name} completed.", markup=False)
|
||||
)
|
||||
|
||||
async def set_collapsed(self, collapsed: bool) -> None:
|
||||
if self.collapsed != collapsed:
|
||||
self.collapsed = collapsed
|
||||
await self.render_result()
|
||||
if self.collapsed == collapsed:
|
||||
return
|
||||
self.collapsed = collapsed
|
||||
await self._render_result()
|
||||
|
||||
async def toggle_collapsed(self) -> None:
|
||||
self.collapsed = not self.collapsed
|
||||
await self.render_result()
|
||||
await self._render_result()
|
||||
|
|
|
|||
4
vibe/cli/textual_ui/widgets/utils.py
Normal file
4
vibe/cli/textual_ui/widgets/utils.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from __future__ import annotations
|
||||
|
||||
TOOL_SHORTCUTS = {"todo": "ctrl+t"}
|
||||
DEFAULT_TOOL_SHORTCUT = "ctrl+o"
|
||||
|
|
@ -9,7 +9,7 @@ from rich.text import Text
|
|||
from textual.color import Color
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.core import __version__
|
||||
from vibe import __version__
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
|
|
@ -104,8 +104,7 @@ class WelcomeBanner(Static):
|
|||
self._static_line5_suffix = (
|
||||
f"{self.LOGO_TEXT_GAP}[dim]{self.config.effective_workdir}[/]"
|
||||
)
|
||||
block = (self.SPACE * 4) + self.LOGO_TEXT_GAP
|
||||
self._static_line7 = f"{block}[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information[/]"
|
||||
self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]"
|
||||
|
||||
@property
|
||||
def skeleton_color(self) -> str:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import (
|
|||
UpdateCache,
|
||||
UpdateCacheRepository,
|
||||
)
|
||||
from vibe.core.config_path import VIBE_HOME
|
||||
from vibe.core.paths.global_paths import VIBE_HOME
|
||||
|
||||
|
||||
class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue