v2.3.0 (#429)
Co-authored-by: Carlo <carloantonio.patti@mistral.ai> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
a560a47ce8
commit
5d2e01a6d7
139 changed files with 7152 additions and 1457 deletions
|
|
@ -1,10 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
|
|
@ -14,11 +16,12 @@ from vibe.core.config import (
|
|||
VibeConfig,
|
||||
load_dotenv_values,
|
||||
)
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.types import LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException, logger
|
||||
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
||||
|
||||
|
|
@ -74,7 +77,7 @@ def bootstrap_config_files() -> None:
|
|||
|
||||
def load_session(
|
||||
args: argparse.Namespace, config: VibeConfig
|
||||
) -> list[LLMMessage] | None:
|
||||
) -> tuple[list[LLMMessage], Path] | None:
|
||||
if not args.continue_session and not args.resume:
|
||||
return None
|
||||
|
||||
|
|
@ -107,18 +110,26 @@ def load_session(
|
|||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_to_load)
|
||||
return loaded_messages
|
||||
return loaded_messages, session_to_load
|
||||
except Exception as e:
|
||||
rprint(f"[red]Failed to load session: {e}[/]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _load_messages_from_previous_session(
|
||||
agent_loop: AgentLoop, loaded_messages: list[LLMMessage]
|
||||
def _resume_previous_session(
|
||||
agent_loop: AgentLoop, loaded_messages: list[LLMMessage], session_path: Path
|
||||
) -> None:
|
||||
non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system]
|
||||
agent_loop.messages.extend(non_system_messages)
|
||||
logger.info("Loaded %d messages from previous session", len(non_system_messages))
|
||||
|
||||
_, metadata = SessionLoader.load_session(session_path)
|
||||
session_id = metadata.get("session_id", agent_loop.session_id)
|
||||
agent_loop.session_id = session_id
|
||||
agent_loop.session_logger.resume_existing_session(session_id, session_path)
|
||||
|
||||
logger.info(
|
||||
"Resumed session %s with %d messages", session_id, len(non_system_messages)
|
||||
)
|
||||
|
||||
|
||||
def run_cli(args: argparse.Namespace) -> None:
|
||||
|
|
@ -136,7 +147,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
if args.enabled_tools:
|
||||
config.enabled_tools = args.enabled_tools
|
||||
|
||||
loaded_messages = load_session(args, config)
|
||||
loaded_session = load_session(args, config)
|
||||
|
||||
stdin_prompt = get_prompt_from_stdin()
|
||||
if args.prompt is not None:
|
||||
|
|
@ -157,7 +168,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
max_turns=args.max_turns,
|
||||
max_price=args.max_price,
|
||||
output_format=output_format,
|
||||
previous_messages=loaded_messages,
|
||||
previous_messages=loaded_session[0] if loaded_session else None,
|
||||
agent_name=initial_agent_name,
|
||||
)
|
||||
if final_response:
|
||||
|
|
@ -171,11 +182,19 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
sys.exit(1)
|
||||
else:
|
||||
agent_loop = AgentLoop(
|
||||
config, agent_name=initial_agent_name, enable_streaming=True
|
||||
config,
|
||||
agent_name=initial_agent_name,
|
||||
enable_streaming=True,
|
||||
entrypoint_metadata=EntrypointMetadata(
|
||||
agent_entrypoint="cli",
|
||||
agent_version=__version__,
|
||||
client_name="vibe_cli",
|
||||
client_version=__version__,
|
||||
),
|
||||
)
|
||||
|
||||
if loaded_messages:
|
||||
_load_messages_from_previous_session(agent_loop, loaded_messages)
|
||||
if loaded_session:
|
||||
_resume_previous_session(agent_loop, *loaded_session)
|
||||
|
||||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ class CommandRegistry:
|
|||
description="Configure proxy and SSL certificate settings",
|
||||
handler="_show_proxy_setup",
|
||||
),
|
||||
"resume": Command(
|
||||
aliases=frozenset(["/resume", "/continue"]),
|
||||
description="Browse and resume past sessions",
|
||||
handler="_show_session_picker",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class Terminal(Enum):
|
|||
VSCODE = "vscode"
|
||||
VSCODE_INSIDERS = "vscode_insiders"
|
||||
CURSOR = "cursor"
|
||||
JETBRAINS = "jetbrains"
|
||||
ITERM2 = "iterm2"
|
||||
WEZTERM = "wezterm"
|
||||
GHOSTTY = "ghostty"
|
||||
|
|
@ -50,6 +51,16 @@ def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDE
|
|||
return Terminal.VSCODE
|
||||
|
||||
|
||||
def _detect_terminal_from_env() -> Terminal | None:
|
||||
if os.environ.get("WEZTERM_PANE"):
|
||||
return Terminal.WEZTERM
|
||||
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
|
||||
return Terminal.GHOSTTY
|
||||
if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower():
|
||||
return Terminal.JETBRAINS
|
||||
return None
|
||||
|
||||
|
||||
def detect_terminal() -> Terminal:
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
|
|
@ -66,12 +77,7 @@ def detect_terminal() -> Terminal:
|
|||
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
|
||||
return _detect_terminal_from_env() or Terminal.UNKNOWN
|
||||
|
||||
|
||||
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
|
||||
|
|
@ -303,6 +309,13 @@ def setup_terminal() -> SetupResult:
|
|||
match terminal:
|
||||
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
|
||||
return _setup_vscode_like_terminal(terminal)
|
||||
case Terminal.JETBRAINS:
|
||||
return SetupResult(
|
||||
success=False,
|
||||
terminal=Terminal.JETBRAINS,
|
||||
message="Jetbrains terminal is not supported.\n"
|
||||
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
|
||||
)
|
||||
case Terminal.ITERM2:
|
||||
return _setup_iterm2()
|
||||
case Terminal.WEZTERM:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ from vibe.cli.plan_offer.decide_plan_offer import (
|
|||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
|
||||
from vibe.cli.terminal_setup import setup_terminal
|
||||
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
|
||||
from vibe.cli.textual_ui.notifications import (
|
||||
NotificationContext,
|
||||
NotificationPort,
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
|
|
@ -55,6 +60,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
|
||||
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
||||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
|
|
@ -84,6 +90,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
|
|||
from vibe.core.agents import AgentProfile
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths.config_paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.teleport.types import (
|
||||
|
|
@ -115,7 +122,6 @@ from vibe.core.utils import (
|
|||
CancellationReason,
|
||||
get_user_cancellation_message,
|
||||
is_dangerous_directory,
|
||||
logger,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -132,11 +138,16 @@ class BottomApp(StrEnum):
|
|||
Input = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
SessionPicker = auto()
|
||||
|
||||
|
||||
class ChatScroll(VerticalScroll):
|
||||
"""Optimized scroll container that skips cascading style recalculations."""
|
||||
|
||||
@property
|
||||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_offset.y >= (self.max_scroll_y - 3)
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -145,34 +156,40 @@ PRUNE_LOW_MARK = 1000
|
|||
PRUNE_HIGH_MARK = 1500
|
||||
|
||||
|
||||
async def prune_by_height(messages_area: Widget, low_mark: int, high_mark: int) -> bool:
|
||||
"""Remove older children to keep virtual height within bounds.
|
||||
Implementation from https://github.com/batrachianai/toad/blob/a335b56c9015514d5f38654e3909aaa78850c510/src/toad/widgets/conversation.py#L1495
|
||||
async def prune_oldest_children(
|
||||
messages_area: Widget, low_mark: int, high_mark: int
|
||||
) -> bool:
|
||||
"""Remove the oldest children so the virtual height stays within bounds.
|
||||
|
||||
Walks children back-to-front to find how much to keep (up to *low_mark*
|
||||
of visible height), then removes everything before that point.
|
||||
"""
|
||||
height = messages_area.virtual_size.height
|
||||
if height <= high_mark:
|
||||
total_height = messages_area.virtual_size.height
|
||||
if total_height <= high_mark:
|
||||
return False
|
||||
prune_children: list[Widget] = []
|
||||
bottom_margin = 0
|
||||
prune_height = 0
|
||||
for child in messages_area.children:
|
||||
|
||||
children = messages_area.children
|
||||
if not children:
|
||||
return False
|
||||
|
||||
accumulated = 0
|
||||
cut = len(children)
|
||||
|
||||
for child in reversed(children):
|
||||
if not child.display:
|
||||
prune_children.append(child)
|
||||
cut -= 1
|
||||
continue
|
||||
top, _, bottom, _ = child.styles.margin
|
||||
child_height = child.outer_size.height
|
||||
prune_height = (
|
||||
(prune_height - bottom_margin + max(bottom_margin, top))
|
||||
+ bottom
|
||||
+ child_height
|
||||
)
|
||||
bottom_margin = bottom
|
||||
if height - prune_height <= low_mark:
|
||||
accumulated += child.outer_size.height
|
||||
cut -= 1
|
||||
if accumulated >= low_mark:
|
||||
break
|
||||
prune_children.append(child)
|
||||
if prune_children:
|
||||
await messages_area.remove_children(prune_children)
|
||||
return bool(prune_children)
|
||||
|
||||
to_remove = list(children[:cut])
|
||||
if not to_remove:
|
||||
return False
|
||||
|
||||
await messages_area.remove_children(to_remove)
|
||||
return True
|
||||
|
||||
|
||||
class VibeApp(App): # noqa: PLR0904
|
||||
|
|
@ -203,10 +220,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
plan_offer_gateway: WhoAmIGateway | None = None,
|
||||
terminal_notifier: NotificationPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.agent_loop = agent_loop
|
||||
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
|
||||
self,
|
||||
get_enabled=lambda: self.config.enable_notifications,
|
||||
default_title="Vibe",
|
||||
)
|
||||
self._agent_running = False
|
||||
self._interrupt_requested = False
|
||||
self._agent_task: asyncio.Task | None = None
|
||||
|
|
@ -246,6 +269,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_messages_area: Widget | None = None
|
||||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
self._switch_agent_generation = 0
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
|
|
@ -279,6 +303,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def on_mount(self) -> None:
|
||||
self.theme = "textual-ansi"
|
||||
self._terminal_notifier.restore()
|
||||
|
||||
self._cached_messages_area = self.query_one("#messages")
|
||||
self._cached_chat = self.query_one("#chat", ChatScroll)
|
||||
|
|
@ -311,7 +336,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._resume_history_from_messages()
|
||||
await self._check_and_show_whats_new()
|
||||
self._schedule_update_notification()
|
||||
self.agent_loop.emit_new_session_telemetry("cli")
|
||||
self.agent_loop.emit_new_session_telemetry()
|
||||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
|
||||
|
|
@ -575,12 +600,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_agent_loop_turn(message)
|
||||
)
|
||||
|
||||
def _reset_ui_state(self) -> None:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
|
||||
async def _resume_history_from_messages(self) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
if not should_resume_history(list(messages_area.children)):
|
||||
return
|
||||
|
||||
self._windowing.reset()
|
||||
history_messages = non_system_history_messages(self.agent_loop.messages)
|
||||
if (
|
||||
plan := create_resume_plan(history_messages, HISTORY_RESUME_TAIL_MESSAGES)
|
||||
|
|
@ -592,7 +621,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
plan.tool_call_map,
|
||||
start_index=plan.tail_start_index,
|
||||
)
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
self.call_after_refresh(chat.anchor)
|
||||
self._tool_call_map = plan.tool_call_map
|
||||
self._windowing.set_backfill(plan.backfill_messages)
|
||||
await self._load_more.set_visible(
|
||||
|
|
@ -643,6 +673,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return (ApprovalResponse.YES, None)
|
||||
|
||||
self._pending_approval = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
|
|
@ -654,6 +685,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
question_args = cast(AskUserQuestionArgs, args)
|
||||
|
||||
self._pending_question = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_question_app(question_args)
|
||||
result = await self._pending_question
|
||||
|
|
@ -714,6 +746,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self.event_handler:
|
||||
await self.event_handler.finalize_streaming()
|
||||
await self._refresh_windowing_from_history()
|
||||
self._terminal_notifier.notify(NotificationContext.COMPLETE)
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
await self._handle_teleport_command(show_message=False)
|
||||
|
|
@ -864,11 +897,108 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
async def _show_session_picker(self) -> None:
|
||||
session_config = self.config.session_logging
|
||||
|
||||
if not session_config.enabled:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
"Session logging is disabled in configuration.",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
cwd = str(Path.cwd())
|
||||
raw_sessions = SessionLoader.list_sessions(session_config, cwd=cwd)
|
||||
|
||||
if not raw_sessions:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("No sessions found for this directory.")
|
||||
)
|
||||
return
|
||||
|
||||
sessions = sorted(
|
||||
raw_sessions, key=lambda s: s.get("end_time") or "", reverse=True
|
||||
)
|
||||
|
||||
latest_messages = {
|
||||
s["session_id"]: SessionLoader.get_first_user_message(
|
||||
s["session_id"], session_config
|
||||
)
|
||||
for s in sessions
|
||||
}
|
||||
|
||||
picker = SessionPickerApp(sessions=sessions, latest_messages=latest_messages)
|
||||
await self._switch_from_input(picker)
|
||||
|
||||
async def on_session_picker_app_session_selected(
|
||||
self, event: SessionPickerApp.SessionSelected
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
session_config = self.config.session_logging
|
||||
session_path = SessionLoader.find_session_by_id(
|
||||
event.session_id, session_config
|
||||
)
|
||||
|
||||
if not session_path:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Session `{event.session_id[:8]}` not found.",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
loaded_messages, _ = SessionLoader.load_session(session_path)
|
||||
|
||||
current_system_messages = [
|
||||
msg for msg in self.agent_loop.messages if msg.role == Role.system
|
||||
]
|
||||
non_system_messages = [
|
||||
msg for msg in loaded_messages if msg.role != Role.system
|
||||
]
|
||||
|
||||
self.agent_loop.session_id = event.session_id
|
||||
self.agent_loop.session_logger.resume_existing_session(
|
||||
event.session_id, session_path
|
||||
)
|
||||
|
||||
self.agent_loop.messages.reset(
|
||||
current_system_messages + non_system_messages
|
||||
)
|
||||
|
||||
self._reset_ui_state()
|
||||
await self._load_more.hide()
|
||||
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
await messages_area.remove_children()
|
||||
|
||||
await self._resume_history_from_messages()
|
||||
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(f"Resumed session `{event.session_id[:8]}`")
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Failed to load session: {e}", collapsed=self._tools_collapsed
|
||||
)
|
||||
)
|
||||
|
||||
async def on_session_picker_app_cancelled(
|
||||
self, event: SessionPickerApp.Cancelled
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Resume cancelled."))
|
||||
|
||||
async def _reload_config(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
self._reset_ui_state()
|
||||
await self._load_more.hide()
|
||||
base_config = VibeConfig.load()
|
||||
|
||||
|
|
@ -886,9 +1016,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _clear_history(self) -> None:
|
||||
try:
|
||||
self._windowing.reset()
|
||||
self._tool_call_map = None
|
||||
self._history_widget_indices = WeakKeyDictionary()
|
||||
self._reset_ui_state()
|
||||
await self.agent_loop.clear_history()
|
||||
if self.event_handler:
|
||||
await self.event_handler.finalize_streaming()
|
||||
|
|
@ -1019,6 +1147,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
should_scroll = scroll and chat.is_at_bottom
|
||||
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.display = False
|
||||
|
|
@ -1028,8 +1158,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await bottom_container.mount(widget)
|
||||
|
||||
self.call_after_refresh(widget.focus)
|
||||
if scroll:
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
if should_scroll:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
|
||||
async def _switch_to_config_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
|
|
@ -1069,7 +1199,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.display = True
|
||||
self._current_bottom_app = BottomApp.Input
|
||||
self.call_after_refresh(self._chat_input_container.focus_input)
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
|
||||
def _focus_current_bottom_app(self) -> None:
|
||||
try:
|
||||
|
|
@ -1084,6 +1216,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(ApprovalApp).focus()
|
||||
case BottomApp.Question:
|
||||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case app:
|
||||
assert_never(app)
|
||||
except Exception:
|
||||
|
|
@ -1115,6 +1249,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_session_picker_app_escape(self) -> None:
|
||||
try:
|
||||
session_picker = self.query_one(SessionPickerApp)
|
||||
session_picker.post_message(SessionPickerApp.Cancelled())
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_input_app_escape(self) -> None:
|
||||
try:
|
||||
input_widget = self.query_one(ChatInputContainer)
|
||||
|
|
@ -1151,6 +1293,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_question_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.SessionPicker:
|
||||
self._handle_session_picker_app_escape()
|
||||
return
|
||||
|
||||
if (
|
||||
self._current_bottom_app == BottomApp.Input
|
||||
and self._last_escape_time is not None
|
||||
|
|
@ -1163,7 +1309,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_agent_running_escape()
|
||||
|
||||
self._last_escape_time = current_time
|
||||
self.call_after_refresh(self._scroll_chat_to_end)
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
if chat.is_at_bottom:
|
||||
self.call_after_refresh(chat.anchor)
|
||||
self._focus_current_bottom_app()
|
||||
|
||||
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
|
||||
|
|
@ -1235,9 +1383,32 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.agent_profile
|
||||
)
|
||||
self._update_profile_widgets(new_profile)
|
||||
await self.agent_loop.switch_agent(new_profile.name)
|
||||
self.agent_loop.set_approval_callback(self._approval_callback)
|
||||
self.agent_loop.set_user_input_callback(self._user_input_callback)
|
||||
if self._chat_input_container:
|
||||
self._chat_input_container.switching_mode = True
|
||||
|
||||
def schedule_switch() -> None:
|
||||
self._switch_agent_generation += 1
|
||||
my_gen = self._switch_agent_generation
|
||||
|
||||
def switch_agent_sync() -> None:
|
||||
try:
|
||||
asyncio.run(self.agent_loop.switch_agent(new_profile.name))
|
||||
self.agent_loop.set_approval_callback(self._approval_callback)
|
||||
self.agent_loop.set_user_input_callback(self._user_input_callback)
|
||||
finally:
|
||||
if (
|
||||
self._chat_input_container
|
||||
and self._switch_agent_generation == my_gen
|
||||
):
|
||||
self.call_from_thread(
|
||||
setattr, self._chat_input_container, "switching_mode", False
|
||||
)
|
||||
|
||||
self.run_worker(
|
||||
switch_agent_sync, group="switch_agent", exclusive=True, thread=True
|
||||
)
|
||||
|
||||
self.call_after_refresh(schedule_switch)
|
||||
|
||||
def action_clear_quit(self) -> None:
|
||||
input_widgets = self.query(ChatInputContainer)
|
||||
|
|
@ -1296,9 +1467,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
whats_new_message.add_class("after-history")
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
should_anchor = chat.is_at_bottom
|
||||
await chat.mount(whats_new_message, after=messages_area)
|
||||
self._whats_new_message = whats_new_message
|
||||
chat.anchor()
|
||||
if should_anchor:
|
||||
chat.anchor()
|
||||
await mark_version_as_seen(self._current_version, self._update_cache_repository)
|
||||
|
||||
async def _plan_offer_cta(self) -> str | None:
|
||||
|
|
@ -1324,29 +1497,37 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
return
|
||||
|
||||
def _scroll_chat_to_end(self) -> None:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
chat.scroll_end(animate=False)
|
||||
|
||||
async def _mount_and_scroll(self, widget: Widget) -> None:
|
||||
async def _mount_and_scroll(
|
||||
self, widget: Widget, after: Widget | None = None
|
||||
) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
|
||||
await messages_area.mount(widget)
|
||||
is_user_initiated = isinstance(widget, (UserMessage, UserCommandMessage))
|
||||
should_anchor = is_user_initiated or chat.is_at_bottom
|
||||
|
||||
if after is not None and after.parent is messages_area:
|
||||
await messages_area.mount(widget, after=after)
|
||||
else:
|
||||
await messages_area.mount(widget)
|
||||
if isinstance(widget, StreamingMessageBase):
|
||||
await widget.write_initial_content()
|
||||
|
||||
self.call_after_refresh(self._try_prune)
|
||||
chat.anchor()
|
||||
if should_anchor:
|
||||
chat.anchor()
|
||||
|
||||
async def _try_prune(self) -> None:
|
||||
messages_area = self._cached_messages_area or self.query_one("#messages")
|
||||
pruned = await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK)
|
||||
pruned = await prune_oldest_children(
|
||||
messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK
|
||||
)
|
||||
if self._load_more.widget and not self._load_more.widget.parent:
|
||||
self._load_more.widget = None
|
||||
if pruned:
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
self.call_later(chat.anchor)
|
||||
if chat.is_at_bottom:
|
||||
self.call_later(chat.anchor)
|
||||
|
||||
async def _refresh_windowing_from_history(self) -> None:
|
||||
if self._load_more.widget is None:
|
||||
|
|
@ -1425,10 +1606,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
|
||||
|
||||
def on_app_blur(self, event: AppBlur) -> None:
|
||||
self._terminal_notifier.on_blur()
|
||||
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:
|
||||
self._terminal_notifier.on_focus()
|
||||
if self._chat_input_container and self._chat_input_container.input_widget:
|
||||
self._chat_input_container.input_widget.set_app_focus(True)
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ TextArea > .text-area--cursor {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
#prompt {
|
||||
#prompt,
|
||||
#prompt-spinner {
|
||||
width: auto;
|
||||
background: transparent;
|
||||
color: $mistral_orange;
|
||||
|
|
@ -719,17 +720,6 @@ StatusMessage {
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.proxy-label {
|
||||
height: auto;
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.proxy-description {
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
.proxy-label-line {
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -739,7 +729,6 @@ StatusMessage {
|
|||
height: auto;
|
||||
border: none;
|
||||
border-left: wide ansi_bright_black;
|
||||
margin-top: 1;
|
||||
padding: 0 0 0 1;
|
||||
}
|
||||
|
||||
|
|
@ -1020,3 +1009,36 @@ ContextProgress {
|
|||
.whats-new-message.after-history {
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#sessionpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#sessionpicker-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#sessionpicker-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#sessionpicker-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sessionpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class EventHandler:
|
|||
) -> None:
|
||||
self.mount_callback = mount_callback
|
||||
self.get_tools_collapsed = get_tools_collapsed
|
||||
self.current_tool_call: ToolCallMessage | None = None
|
||||
self.tool_calls: dict[str, ToolCallMessage] = {}
|
||||
self.current_compact: CompactMessage | None = None
|
||||
self.current_streaming_message: AssistantMessage | None = None
|
||||
self.current_streaming_reasoning: ReasoningMessage | None = None
|
||||
|
|
@ -90,30 +90,40 @@ class EventHandler:
|
|||
async def _handle_tool_call(
|
||||
self, event: ToolCallEvent, loading_widget: LoadingWidget | None = None
|
||||
) -> ToolCallMessage | None:
|
||||
tool_call = ToolCallMessage(event)
|
||||
tool_call_id = event.tool_call_id
|
||||
existing_tool_call = self.tool_calls.get(tool_call_id) if tool_call_id else None
|
||||
if existing_tool_call:
|
||||
existing_tool_call.update_event(event)
|
||||
tool_call = existing_tool_call
|
||||
else:
|
||||
tool_call = ToolCallMessage(event)
|
||||
if tool_call_id:
|
||||
self.tool_calls[tool_call_id] = tool_call
|
||||
await self.mount_callback(tool_call)
|
||||
|
||||
if loading_widget and event.tool_class:
|
||||
adapter = ToolUIDataAdapter(event.tool_class)
|
||||
status_text = adapter.get_status_text()
|
||||
loading_widget.set_status(status_text)
|
||||
|
||||
self.current_tool_call = tool_call
|
||||
await self.mount_callback(tool_call)
|
||||
loading_widget.set_status(adapter.get_status_text())
|
||||
|
||||
return tool_call
|
||||
|
||||
async def _handle_tool_result(self, event: ToolResultEvent) -> None:
|
||||
tools_collapsed = self.get_tools_collapsed()
|
||||
tool_result = ToolResultMessage(
|
||||
event, self.current_tool_call, collapsed=tools_collapsed
|
||||
)
|
||||
await self.mount_callback(tool_result)
|
||||
|
||||
self.current_tool_call = None
|
||||
call_widget = (
|
||||
self.tool_calls.get(event.tool_call_id) if event.tool_call_id else None
|
||||
)
|
||||
|
||||
tool_result = ToolResultMessage(event, call_widget, collapsed=tools_collapsed)
|
||||
await self.mount_callback(tool_result, after=call_widget)
|
||||
|
||||
if event.tool_call_id and event.tool_call_id in self.tool_calls:
|
||||
del self.tool_calls[event.tool_call_id]
|
||||
|
||||
async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.set_stream_message(event.message)
|
||||
tool_call = self.tool_calls.get(event.tool_call_id)
|
||||
if tool_call:
|
||||
tool_call.set_stream_message(event.message)
|
||||
|
||||
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
|
||||
if self.current_streaming_reasoning is not None:
|
||||
|
|
@ -131,6 +141,8 @@ class EventHandler:
|
|||
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
|
||||
if self.current_streaming_message is not None:
|
||||
await self.current_streaming_message.stop_stream()
|
||||
if self.current_streaming_message.is_stripped_content_empty():
|
||||
await self.current_streaming_message.remove()
|
||||
self.current_streaming_message = None
|
||||
|
||||
if self.current_streaming_reasoning is None:
|
||||
|
|
@ -166,9 +178,9 @@ class EventHandler:
|
|||
self.current_streaming_message = None
|
||||
|
||||
def stop_current_tool_call(self, success: bool = True) -> None:
|
||||
if self.current_tool_call:
|
||||
self.current_tool_call.stop_spinning(success=success)
|
||||
self.current_tool_call = None
|
||||
for tool_call in self.tool_calls.values():
|
||||
tool_call.stop_spinning(success=success)
|
||||
self.tool_calls.clear()
|
||||
|
||||
def stop_current_compact(self) -> None:
|
||||
if self.current_compact:
|
||||
|
|
|
|||
11
vibe/cli/textual_ui/notifications/__init__.py
Normal file
11
vibe/cli/textual_ui/notifications/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.notifications.adapters.textual_notification_adapter import (
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
from vibe.cli.textual_ui.notifications.ports.notification_port import (
|
||||
NotificationContext,
|
||||
NotificationPort,
|
||||
)
|
||||
|
||||
__all__ = ["NotificationContext", "NotificationPort", "TextualNotificationAdapter"]
|
||||
0
vibe/cli/textual_ui/notifications/adapters/__init__.py
Normal file
0
vibe/cli/textual_ui/notifications/adapters/__init__.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import time
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from vibe.cli.textual_ui.notifications.ports.notification_port import (
|
||||
NotificationContext,
|
||||
)
|
||||
|
||||
NOTIFICATION_TITLE_SUFFIXES: dict[NotificationContext, str] = {
|
||||
NotificationContext.ACTION_REQUIRED: "Action Required",
|
||||
NotificationContext.COMPLETE: "Task Complete",
|
||||
}
|
||||
|
||||
NOTIFICATION_THROTTLE_SECONDS: float = 1.0
|
||||
|
||||
|
||||
class TextualNotificationAdapter:
|
||||
def __init__(
|
||||
self, app: App, *, get_enabled: Callable[[], bool], default_title: str = "App"
|
||||
) -> None:
|
||||
self._app = app
|
||||
self._get_enabled = get_enabled
|
||||
self._default_title = default_title
|
||||
self._has_focus: bool = True
|
||||
self._last_notification_time: float = 0.0
|
||||
|
||||
def notify(self, context: NotificationContext) -> None:
|
||||
if not self._get_enabled() or self._has_focus:
|
||||
return
|
||||
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_notification_time < NOTIFICATION_THROTTLE_SECONDS:
|
||||
return
|
||||
|
||||
self._last_notification_time = current_time
|
||||
self._app.bell()
|
||||
self._set_title(self._get_notification_title(context))
|
||||
|
||||
def on_focus(self) -> None:
|
||||
self._has_focus = True
|
||||
self.restore()
|
||||
|
||||
def on_blur(self) -> None:
|
||||
self._has_focus = False
|
||||
|
||||
def restore(self) -> None:
|
||||
self._set_title(self._default_title)
|
||||
|
||||
def _get_notification_title(self, context: NotificationContext) -> str:
|
||||
suffix = NOTIFICATION_TITLE_SUFFIXES.get(context)
|
||||
if suffix is None:
|
||||
return self._default_title
|
||||
return f"{self._default_title} - {suffix}"
|
||||
|
||||
def _set_title(self, title: str) -> None:
|
||||
if not self._app.is_headless and self._app._driver is not None:
|
||||
self._app._driver.write(f"\x1b]0;{title}\x07")
|
||||
0
vibe/cli/textual_ui/notifications/ports/__init__.py
Normal file
0
vibe/cli/textual_ui/notifications/ports/__init__.py
Normal file
16
vibe/cli/textual_ui/notifications/ports/notification_port.py
Normal file
16
vibe/cli/textual_ui/notifications/ports/notification_port.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum, auto
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class NotificationContext(StrEnum):
|
||||
ACTION_REQUIRED = auto()
|
||||
COMPLETE = auto()
|
||||
|
||||
|
||||
class NotificationPort(Protocol):
|
||||
def notify(self, context: NotificationContext) -> None: ...
|
||||
def on_focus(self) -> None: ...
|
||||
def on_blur(self) -> None: ...
|
||||
def restore(self) -> None: ...
|
||||
|
|
@ -2,16 +2,31 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.history_manager import HistoryManager
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
||||
class _PromptSpinner(SpinnerMixin, Static):
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.BRAILLE
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._indicator_widget: Static | None = None
|
||||
self.init_spinner()
|
||||
super().__init__(self._spinner.current_frame(), id="prompt-spinner")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._indicator_widget = self
|
||||
self.start_spinner_timer()
|
||||
|
||||
|
||||
class ChatInputBody(Widget):
|
||||
|
|
@ -30,6 +45,7 @@ class ChatInputBody(Widget):
|
|||
self.input_widget: ChatTextArea | None = None
|
||||
self.prompt_widget: NoMarkupStatic | None = None
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._switching_mode = False
|
||||
|
||||
if history_file:
|
||||
self.history = HistoryManager(history_file)
|
||||
|
|
@ -159,6 +175,9 @@ class ChatInputBody(Widget):
|
|||
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
|
||||
event.stop()
|
||||
|
||||
if self._switching_mode:
|
||||
return
|
||||
|
||||
if not self.input_widget:
|
||||
return
|
||||
|
||||
|
|
@ -175,6 +194,25 @@ class ChatInputBody(Widget):
|
|||
|
||||
self.post_message(self.Submitted(value))
|
||||
|
||||
@property
|
||||
def switching_mode(self) -> bool:
|
||||
return self._switching_mode
|
||||
|
||||
@switching_mode.setter
|
||||
def switching_mode(self, value: bool) -> None:
|
||||
self._switching_mode = value
|
||||
if value:
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = False
|
||||
if not self.query(_PromptSpinner):
|
||||
self.query_one(Horizontal).mount(_PromptSpinner(), before=0)
|
||||
else:
|
||||
for spinner in self.query(_PromptSpinner):
|
||||
spinner.remove()
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = True
|
||||
self._update_prompt()
|
||||
|
||||
@property
|
||||
def value(self) -> str:
|
||||
if not self.input_widget:
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ class ChatInputContainer(Vertical):
|
|||
event.stop()
|
||||
self.post_message(self.Submitted(event.value))
|
||||
|
||||
@property
|
||||
def switching_mode(self) -> bool:
|
||||
return self._body.switching_mode if self._body else False
|
||||
|
||||
@switching_mode.setter
|
||||
def switching_mode(self, value: bool) -> None:
|
||||
if self._body:
|
||||
self._body.switching_mode = value
|
||||
|
||||
def set_safety(self, safety: AgentSafety) -> None:
|
||||
self._safety = safety
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
self.init_spinner()
|
||||
self.status = status or self._get_default_status()
|
||||
self.current_color_index = 0
|
||||
self._color_direction = 1
|
||||
self.transition_progress = 0
|
||||
self._status_widget: Static | None = None
|
||||
self.hint_widget: Static | None = None
|
||||
|
|
@ -141,11 +142,12 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
return
|
||||
self._update_animation()
|
||||
|
||||
def _next_color_index(self) -> int:
|
||||
return self.current_color_index + self._color_direction
|
||||
|
||||
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)
|
||||
]
|
||||
next_color = self.TARGET_COLORS[self._next_color_index()]
|
||||
if position < self.transition_progress:
|
||||
return next_color
|
||||
return current_color
|
||||
|
|
@ -173,9 +175,9 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
|
||||
self.transition_progress += 1
|
||||
if self.transition_progress > total_elements:
|
||||
self.current_color_index = (self.current_color_index + 1) % len(
|
||||
self.TARGET_COLORS
|
||||
)
|
||||
self.current_color_index = self._next_color_index()
|
||||
if not 0 < self.current_color_index < len(self.TARGET_COLORS) - 1:
|
||||
self._color_direction *= -1
|
||||
self.transition_progress = 0
|
||||
|
||||
if self.hint_widget and self.start_time is not None:
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ class StreamingMessageBase(Static):
|
|||
def _should_write_content(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_stripped_content_empty(self) -> bool:
|
||||
return self._content.strip() == ""
|
||||
|
||||
|
||||
class AssistantMessage(StreamingMessageBase):
|
||||
def __init__(self, content: str) -> None:
|
||||
|
|
|
|||
|
|
@ -43,26 +43,20 @@ class ProxySetupApp(Container):
|
|||
|
||||
with Vertical(id="proxysetup-content"):
|
||||
yield NoMarkupStatic("Proxy Configuration", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for key, description in SUPPORTED_PROXY_VARS.items():
|
||||
yield Static(
|
||||
f"[bold ansi_blue]{key}[/] [dim]{description}[/dim]",
|
||||
classes="proxy-label-line",
|
||||
)
|
||||
yield Static(f"[bold ansi_blue]{key}[/]", classes="proxy-label-line")
|
||||
|
||||
initial_value = self.initial_values.get(key) or ""
|
||||
input_widget = Input(
|
||||
value=initial_value,
|
||||
placeholder="NOT SET",
|
||||
placeholder=description,
|
||||
id=f"proxy-input-{key}",
|
||||
classes="proxy-input",
|
||||
)
|
||||
self.inputs[key] = input_widget
|
||||
yield input_widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ navigate Enter save & exit ESC cancel", classes="settings-help"
|
||||
)
|
||||
|
|
@ -92,7 +86,7 @@ class ProxySetupApp(Container):
|
|||
prev_idx = (idx - 1) % len(inputs)
|
||||
inputs[prev_idx].focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
def on_input_submitted(self, _event: Input.Submitted) -> None:
|
||||
self._save_and_close()
|
||||
|
||||
def on_blur(self, _event: events.Blur) -> None:
|
||||
|
|
|
|||
113
vibe/cli/textual_ui/widgets/session_picker.py
Normal file
113
vibe/cli/textual_ui/widgets/session_picker.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.session.session_loader import SessionInfo
|
||||
|
||||
_SECONDS_PER_MINUTE = 60
|
||||
_SECONDS_PER_HOUR = 3600
|
||||
_SECONDS_PER_DAY = 86400
|
||||
_SECONDS_PER_WEEK = 604800
|
||||
|
||||
|
||||
def _format_relative_time(iso_time: str | None) -> str:
|
||||
if not iso_time:
|
||||
return "unknown"
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_time.replace("Z", "+00:00"))
|
||||
now = datetime.now(UTC)
|
||||
delta = now - dt
|
||||
seconds = int(delta.total_seconds())
|
||||
|
||||
if seconds < _SECONDS_PER_MINUTE:
|
||||
return "just now"
|
||||
for threshold, divisor, unit in [
|
||||
(_SECONDS_PER_HOUR, _SECONDS_PER_MINUTE, "m"),
|
||||
(_SECONDS_PER_DAY, _SECONDS_PER_HOUR, "h"),
|
||||
(_SECONDS_PER_WEEK, _SECONDS_PER_DAY, "d"),
|
||||
(float("inf"), _SECONDS_PER_WEEK, "w"),
|
||||
]:
|
||||
if seconds < threshold:
|
||||
return f"{seconds // divisor}{unit} ago"
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _build_option_text(session: SessionInfo, message: str) -> Text:
|
||||
text = Text(no_wrap=True)
|
||||
time_str = _format_relative_time(session.get("end_time"))
|
||||
session_id = session["session_id"][:8]
|
||||
text.append(f"{time_str:10}", style="dim")
|
||||
text.append(" ")
|
||||
text.append(f"{session_id} ", style="dim")
|
||||
text.append(message)
|
||||
return text
|
||||
|
||||
|
||||
class SessionPickerApp(Container):
|
||||
"""Session picker for /resume command."""
|
||||
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "cancel", "Cancel", show=False)
|
||||
]
|
||||
|
||||
class SessionSelected(Message):
|
||||
def __init__(self, session_id: str) -> None:
|
||||
self.session_id = session_id
|
||||
super().__init__()
|
||||
|
||||
class Cancelled(Message):
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sessions: list[SessionInfo],
|
||||
latest_messages: dict[str, str],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(id="sessionpicker-app", **kwargs)
|
||||
self._sessions = sessions
|
||||
self._latest_messages = latest_messages
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
options = [
|
||||
Option(
|
||||
_build_option_text(
|
||||
session,
|
||||
self._latest_messages.get(session["session_id"], "(empty session)"),
|
||||
),
|
||||
id=session["session_id"],
|
||||
)
|
||||
for session in self._sessions
|
||||
]
|
||||
with Vertical(id="sessionpicker-content"):
|
||||
yield OptionList(*options, id="sessionpicker-options")
|
||||
yield NoMarkupStatic(
|
||||
"Up/Down Navigate Enter Select Esc Cancel",
|
||||
classes="sessionpicker-help",
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
if event.option.id:
|
||||
self.post_message(self.SessionSelected(event.option.id))
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.post_message(self.Cancelled())
|
||||
|
|
@ -42,8 +42,8 @@ def parse_search_replace_to_diff(content: str) -> list[str]:
|
|||
for i, (search_text, replace_text) in enumerate(matches):
|
||||
if i > 0:
|
||||
all_diff_lines.append("") # Separator between blocks
|
||||
search_lines = search_text.strip().split("\n")
|
||||
replace_lines = replace_text.strip().split("\n")
|
||||
search_lines = search_text.strip("\n").split("\n")
|
||||
replace_lines = replace_text.strip("\n").split("\n")
|
||||
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
|
||||
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
|
||||
|
||||
|
|
@ -74,8 +74,10 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
MAX_MSG_SIZE = 150
|
||||
for field_name in type(self.args).model_fields:
|
||||
value = getattr(self.args, field_name)
|
||||
model_cls = type(self.args)
|
||||
field_names = model_cls.model_fields or self.args.model_extra or {}
|
||||
for field_name in field_names:
|
||||
value = getattr(self.args, field_name, None)
|
||||
if value is None or value in ("", []):
|
||||
continue
|
||||
value_str = str(value)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class ToolCallMessage(StatusMessage):
|
|||
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._tool_name = tool_name or (event.tool_name if event else None) or "unknown"
|
||||
self._is_history = event is None
|
||||
self._stream_widget: NoMarkupStatic | None = None
|
||||
|
||||
|
|
@ -44,6 +44,7 @@ class ToolCallMessage(StatusMessage):
|
|||
yield self._stream_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
super().on_mount()
|
||||
siblings = list(self.parent.children) if self.parent else []
|
||||
idx = siblings.index(self) if self in siblings else -1
|
||||
if idx > 0 and isinstance(
|
||||
|
|
@ -51,13 +52,23 @@ class ToolCallMessage(StatusMessage):
|
|||
):
|
||||
self.add_class("no-gap")
|
||||
|
||||
@property
|
||||
def tool_call_id(self) -> str | None:
|
||||
return self._event.tool_call_id if self._event else None
|
||||
|
||||
def get_content(self) -> str:
|
||||
if self._event and self._event.tool_class:
|
||||
if self._event:
|
||||
adapter = ToolUIDataAdapter(self._event.tool_class)
|
||||
display = adapter.get_call_display(self._event)
|
||||
return display.summary
|
||||
return self._tool_name
|
||||
|
||||
def update_event(self, event: ToolCallEvent) -> None:
|
||||
self._event = event
|
||||
self._tool_name = event.tool_name
|
||||
if self._text_widget:
|
||||
self._text_widget.update(self.get_content())
|
||||
|
||||
def set_stream_message(self, message: str) -> None:
|
||||
"""Update the stream message displayed below the tool call indicator."""
|
||||
if self._stream_widget:
|
||||
|
|
@ -151,7 +162,13 @@ class ToolResultMessage(Static):
|
|||
await self._content_container.remove_children()
|
||||
|
||||
if self._event is None:
|
||||
self.display = False
|
||||
if self._content:
|
||||
await self._content_container.mount(
|
||||
NoMarkupStatic(self._content, classes="tool-result-detail")
|
||||
)
|
||||
self.display = not self.collapsed
|
||||
else:
|
||||
self.display = False
|
||||
return
|
||||
|
||||
if self._event.error:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from textual.widget import Widget
|
||||
|
|
@ -13,11 +14,11 @@ from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
|||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def non_system_history_messages(messages: list[LLMMessage]) -> list[LLMMessage]:
|
||||
def non_system_history_messages(messages: Sequence[LLMMessage]) -> list[LLMMessage]:
|
||||
return [msg for msg in messages if msg.role != Role.system]
|
||||
|
||||
|
||||
def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
|
||||
def build_tool_call_map(messages: Sequence[LLMMessage]) -> dict[str, str]:
|
||||
tool_call_map: dict[str, str] = {}
|
||||
for msg in messages:
|
||||
if msg.role != Role.assistant or not msg.tool_calls:
|
||||
|
|
@ -29,7 +30,7 @@ def build_tool_call_map(messages: list[LLMMessage]) -> dict[str, str]:
|
|||
|
||||
|
||||
def build_history_widgets(
|
||||
batch: list[LLMMessage],
|
||||
batch: Sequence[LLMMessage],
|
||||
tool_call_map: dict[str, str],
|
||||
*,
|
||||
start_index: int,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue