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:
Quentin 2026-02-27 17:31:58 +01:00 committed by GitHub
parent a560a47ce8
commit 5d2e01a6d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 7152 additions and 1457 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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:

View file

@ -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)

View file

@ -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;
}

View file

@ -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:

View 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"]

View 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")

View 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: ...

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View 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())

View file

@ -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)

View file

@ -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:

View file

@ -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,