Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-03 15:56:50 +02:00 committed by GitHub
parent 9c1c32e058
commit 90763daf81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 6046 additions and 694 deletions

View file

@ -9,7 +9,7 @@ import tomli_w
from vibe import __version__
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
MissingAPIKeyError,
@ -176,12 +176,13 @@ def run_cli(args: argparse.Namespace) -> None:
try:
final_response = run_programmatic(
config=config,
prompt=programmatic_prompt,
prompt=programmatic_prompt or "",
max_turns=args.max_turns,
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name,
teleport=args.teleport and config.nuage_enabled,
)
if final_response:
print(final_response)
@ -189,6 +190,9 @@ def run_cli(args: argparse.Namespace) -> None:
except ConversationLimitException as e:
print(e, file=sys.stderr)
sys.exit(1)
except TeleportError as e:
print(f"Teleport error: {e}", file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

View file

@ -105,6 +105,11 @@ class CommandRegistry:
description="Rewind to a previous message",
handler="_start_rewind_mode",
),
"data-retention": Command(
aliases=frozenset(["/data-retention"]),
description="Show data retention information",
handler="_show_data_retention",
),
}
for command in excluded_commands:

View file

@ -11,6 +11,7 @@ import subprocess
import time
from typing import Any, ClassVar, assert_never, cast
from weakref import WeakKeyDictionary
import webbrowser
from pydantic import BaseModel
from rich import print as rprint
@ -46,6 +47,7 @@ from vibe.cli.textual_ui.notifications import (
NotificationPort,
TextualNotificationAdapter,
)
from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner
@ -109,20 +111,28 @@ from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.rewind import RewindError
from vibe.core.session.resume_sessions import (
ResumeSessionInfo,
list_local_resume_sessions,
list_remote_resume_sessions,
short_session_id,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportFetchingUrlEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportPushResponseEvent,
TeleportSendingGithubTokenEvent,
TeleportStartingWorkflowEvent,
TeleportWaitingForGitHubEvent,
)
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
@ -136,9 +146,11 @@ from vibe.core.types import (
AgentStats,
ApprovalResponse,
Backend,
BaseEvent,
LLMMessage,
RateLimitError,
Role,
WaitingForInputEvent,
)
from vibe.core.utils import (
CancellationReason,
@ -302,6 +314,7 @@ class VibeApp(App): # noqa: PLR0904
self._agent_running = False
self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None
self._remote_manager = RemoteSessionManager()
self._loading_widget: LoadingWidget | None = None
self._pending_approval: asyncio.Future | None = None
@ -398,6 +411,7 @@ class VibeApp(App): # noqa: PLR0904
mount_callback=self._mount_and_scroll,
get_tools_collapsed=lambda: self._tools_collapsed,
on_profile_changed=self._on_profile_changed,
is_remote=self._remote_manager.is_active,
)
self._chat_input_container = self.query_one(ChatInputContainer)
@ -512,11 +526,21 @@ class VibeApp(App): # noqa: PLR0904
await self._remove_loading_widget()
async def on_question_app_answered(self, message: QuestionApp.Answered) -> None:
if self._remote_manager.has_pending_input and self._remote_manager.is_active:
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
await self._handle_remote_answer(result)
return
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
self._pending_question.set_result(result)
async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
if self._remote_manager.has_pending_input:
self._remote_manager.cancel_pending_input()
await self._switch_to_input_app()
return
if self._pending_question and not self._pending_question.done():
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
@ -554,6 +578,21 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_to_input_app()
await self._switch_to_model_picker_app()
async def _ensure_loading_widget(self, status: str = "Generating") -> None:
if self._loading_widget and self._loading_widget.parent:
self._loading_widget.set_status(status)
return
loading_area = self._cached_loading_area
if loading_area is None:
try:
loading_area = self.query_one("#loading-area-content")
except Exception:
return
loading = LoadingWidget(status=status)
self._loading_widget = loading
await loading_area.mount(loading)
async def on_config_app_config_closed(
self, message: ConfigApp.ConfigClosed
) -> None:
@ -755,6 +794,10 @@ class VibeApp(App): # noqa: PLR0904
)
async def _handle_user_message(self, message: str) -> None:
if self._remote_manager.is_active:
await self._handle_remote_user_message(message)
return
# message_index is where the user message will land in agent_loop.messages
# (checkpoint is created in agent_loop.act())
message_index = len(self.agent_loop.messages)
@ -765,10 +808,46 @@ class VibeApp(App): # noqa: PLR0904
self._feedback_bar.maybe_show()
if not self._agent_running:
await self._remote_manager.stop_stream()
await self._remove_loading_widget()
self._agent_task = asyncio.create_task(
self._handle_agent_loop_turn(message)
)
async def _handle_remote_user_message(self, message: str) -> None:
warning = self._remote_manager.validate_input()
if warning:
await self._mount_and_scroll(WarningMessage(warning))
return
try:
await self._remote_manager.send_prompt(message)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to send message: {e}", collapsed=self._tools_collapsed
)
)
return
await self._ensure_loading_widget()
async def _handle_remote_waiting_input(self, event: WaitingForInputEvent) -> None:
self._remote_manager.set_pending_input(event)
if question_args := self._remote_manager.build_question_args(event):
await self._switch_to_question_app(question_args)
return
await self._switch_to_input_app()
async def _handle_remote_answer(self, result: AskUserQuestionResult) -> None:
if result.cancelled or not result.answers:
self._remote_manager.cancel_pending_input()
await self._switch_to_input_app()
return
await self._remote_manager.send_prompt(
result.answers[0].answer, require_source=False
)
await self._switch_to_input_app()
await self._ensure_loading_widget()
def _reset_ui_state(self) -> None:
self._windowing.reset()
self._tool_call_map = None
@ -881,13 +960,8 @@ class VibeApp(App): # noqa: PLR0904
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
loading_area = self._cached_loading_area or self.query_one(
"#loading-area-content"
)
loading = LoadingWidget()
self._loading_widget = loading
await loading_area.mount(loading)
await self._remove_loading_widget()
await self._ensure_loading_widget()
try:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
@ -895,6 +969,12 @@ class VibeApp(App): # noqa: PLR0904
self._narrator_manager.on_turn_start(rendered_prompt)
async for event in self.agent_loop.act(rendered_prompt):
self._narrator_manager.on_turn_event(event)
if isinstance(event, WaitingForInputEvent):
await self._remove_loading_widget()
if self._remote_manager.is_active:
await self._handle_remote_waiting_input(event)
elif self._loading_widget is None and is_progress_event(event):
await self._ensure_loading_widget()
if self.event_handler:
await self.event_handler.handle_event(
event,
@ -972,32 +1052,47 @@ class VibeApp(App): # noqa: PLR0904
teleport_msg = TeleportMessage()
await self._mount_and_scroll(teleport_msg)
if self._remote_manager.is_active:
await loading.remove()
await self._mount_and_scroll(
ErrorMessage(
"Teleport is not available for remote sessions.",
collapsed=self._tools_collapsed,
)
)
return
try:
gen = self.agent_loop.teleport_to_vibe_nuage(prompt)
async for event in gen:
match event:
case TeleportCheckingGitEvent():
teleport_msg.set_status("Checking git status...")
case TeleportPushRequiredEvent(unpushed_count=count):
teleport_msg.set_status("Preparing workspace...")
case TeleportPushRequiredEvent(
unpushed_count=count, branch_not_pushed=branch_not_pushed
):
await loading.remove()
response = await self._ask_push_approval(count)
response = await self._ask_push_approval(
count, branch_not_pushed
)
await loading_area.mount(loading)
teleport_msg.set_status("Teleporting...")
await gen.asend(response)
next_event = await gen.asend(response)
if isinstance(next_event, TeleportPushingEvent):
teleport_msg.set_status("Syncing with remote...")
case TeleportPushingEvent():
teleport_msg.set_status("Pushing to remote...")
case TeleportAuthRequiredEvent(
user_code=code, verification_uri=uri
):
teleport_msg.set_status(
f"GitHub auth required. Code: {code} (copied)\nOpen: {uri}"
)
case TeleportAuthCompleteEvent():
teleport_msg.set_status("GitHub authenticated.")
teleport_msg.set_status("Syncing with remote...")
case TeleportStartingWorkflowEvent():
teleport_msg.set_status("Starting Nuage workflow...")
case TeleportSendingGithubTokenEvent():
teleport_msg.set_status("Sending encrypted GitHub token...")
teleport_msg.set_status("Teleporting...")
case TeleportWaitingForGitHubEvent():
teleport_msg.set_status("Connecting to GitHub...")
case TeleportAuthRequiredEvent(oauth_url=url):
webbrowser.open(url)
teleport_msg.set_status("Authorizing GitHub...")
case TeleportAuthCompleteEvent():
teleport_msg.set_status("GitHub authorized")
case TeleportFetchingUrlEvent():
teleport_msg.set_status("Finalizing...")
case TeleportCompleteEvent(url=url):
teleport_msg.set_complete(url)
except TeleportError as e:
@ -1009,14 +1104,20 @@ class VibeApp(App): # noqa: PLR0904
if loading.parent:
await loading.remove()
async def _ask_push_approval(self, count: int) -> TeleportPushResponseEvent:
word = f"commit{'s' if count != 1 else ''}"
async def _ask_push_approval(
self, count: int, branch_not_pushed: bool
) -> TeleportPushResponseEvent:
if branch_not_pushed:
question = "Your branch doesn't exist on remote. Push to continue?"
else:
word = f"commit{'s' if count != 1 else ''}"
question = f"You have {count} unpushed {word}. Push to continue?"
push_label = "Push and continue"
result = await self._user_input_callback(
AskUserQuestionArgs(
questions=[
Question(
question=f"You have {count} unpushed {word}. Push to continue?",
question=question,
header="Push",
options=[Choice(label=push_label), Choice(label="Cancel")],
hide_other=True,
@ -1105,20 +1206,41 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_proxy_setup_app()
async def _show_data_retention(self) -> None:
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
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)
local_sessions = (
list_local_resume_sessions(self.config, cwd)
if self.config.session_logging.enabled
else []
)
remote_list_timeout = max(float(self.config.api_timeout), 10.0)
remote_error: str | None = None
await self._ensure_loading_widget("Loading sessions")
try:
remote_sessions = await asyncio.wait_for(
list_remote_resume_sessions(self.config), timeout=remote_list_timeout
)
except TimeoutError:
remote_sessions = []
remote_error = (
"Timed out while listing remote sessions "
f"after {remote_list_timeout:.0f}s."
)
except Exception as e:
remote_sessions = []
remote_error = f"Failed to list remote sessions: {e}"
finally:
await self._remove_loading_widget()
if remote_error is not None:
await self._mount_and_scroll(
ErrorMessage(remote_error, collapsed=self._tools_collapsed)
)
raw_sessions = [*local_sessions, *remote_sessions]
if not raw_sessions:
await self._mount_and_scroll(
@ -1126,16 +1248,20 @@ class VibeApp(App): # noqa: PLR0904
)
return
sessions = sorted(
raw_sessions, key=lambda s: s.get("end_time") or "", reverse=True
)
sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True)
latest_messages = {
s["session_id"]: SessionLoader.get_first_user_message(
s["session_id"], session_config
s.option_id: SessionLoader.get_first_user_message(
s.session_id, self.config.session_logging
)
for s in sessions
if s.source == "local"
}
for session in sessions:
if session.source == "remote":
latest_messages[session.option_id] = (
f"{session.title or 'Remote workflow'} ({(session.status or 'RUNNING').lower()})"
)
picker = SessionPickerApp(sessions=sessions, latest_messages=latest_messages)
await self._switch_from_input(picker)
@ -1144,53 +1270,21 @@ class VibeApp(App): # noqa: PLR0904
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
session = ResumeSessionInfo(
session_id=event.session_id,
source=event.source,
cwd="",
title=None,
end_time=None,
)
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:
if event.source == "local":
await self._resume_local_session(session)
elif event.source == "remote":
await self._resume_remote_session(session)
else:
raise ValueError(f"Unknown session source: {event.source}")
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to load session: {e}", collapsed=self._tools_collapsed
@ -1204,6 +1298,113 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(UserCommandMessage("Resume cancelled."))
async def _resume_local_session(self, session: ResumeSessionInfo) -> None:
await self._remote_manager.detach()
session_config = self.config.session_logging
session_path = SessionLoader.find_session_by_id(
session.session_id, session_config
)
if not session_path:
raise ValueError(
f"Session `{short_session_id(session.session_id)}` not found."
)
loaded_messages, _ = SessionLoader.load_session(session_path)
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
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 = session.session_id
self.agent_loop.session_logger.resume_existing_session(
session.session_id, session_path
)
self.agent_loop.messages.reset(current_system_messages + non_system_messages)
self._refresh_profile_widgets()
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()
if self.event_handler:
self.event_handler.is_remote = False
await self._resume_history_from_messages()
await self._mount_and_scroll(
UserCommandMessage(
f"Resumed session `{short_session_id(session.session_id)}`"
)
)
async def _resume_remote_session(self, session: ResumeSessionInfo) -> None:
await self._remote_manager.attach(
session_id=session.session_id, config=self.config
)
self._refresh_profile_widgets()
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
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()
if self.event_handler:
self.event_handler.is_remote = True
self._remote_manager.start_stream(self)
async def on_remote_event(
self, event: BaseEvent, loading_active: bool, loading_widget: Any
) -> None:
if self.event_handler:
await self.event_handler.handle_event(
event, loading_active=loading_active, loading_widget=loading_widget
)
async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None:
await self._handle_remote_waiting_input(event)
async def on_remote_user_message_cleared_input(self) -> None:
await self._switch_to_input_app()
async def on_remote_stream_error(self, error: str) -> None:
await self._mount_and_scroll(
ErrorMessage(error, collapsed=self._tools_collapsed)
)
async def on_remote_stream_ended(self, msg_type: str, text: str) -> None:
if msg_type == "error":
widget = ErrorMessage(text, collapsed=self._tools_collapsed)
elif msg_type == "warning":
widget = WarningMessage(text)
else:
widget = UserCommandMessage(text)
await self._mount_and_scroll(widget)
if self._chat_input_container:
self._chat_input_container.set_custom_border("Remote session ended")
async def on_remote_finalize_streaming(self) -> None:
if self.event_handler:
await self.event_handler.finalize_streaming()
async def remove_loading(self) -> None:
await self._remove_loading_widget()
async def ensure_loading(self, status: str = "Generating") -> None:
await self._ensure_loading_widget(status)
@property
def loading_widget(self) -> LoadingWidget | None:
return self._loading_widget
async def _reload_config(self) -> None:
try:
self._reset_ui_state()
@ -1254,6 +1455,13 @@ class VibeApp(App): # noqa: PLR0904
async def _clear_history(self) -> None:
try:
self._reset_ui_state()
if self._remote_manager.is_active:
await self._remote_manager.detach()
self._refresh_profile_widgets()
if self.event_handler:
self.event_handler.is_remote = False
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
await self.agent_loop.clear_history()
if self.event_handler:
await self.event_handler.finalize_streaming()
@ -1348,6 +1556,8 @@ class VibeApp(App): # noqa: PLR0904
self.event_handler.current_compact = None
def _get_session_resume_info(self) -> str | None:
if self._remote_manager.is_active:
return None
if not self.agent_loop.session_logger.enabled:
return None
if not self.agent_loop.session_logger.session_id:
@ -1358,7 +1568,7 @@ class VibeApp(App): # noqa: PLR0904
)
if session_path is None:
return None
return self.agent_loop.session_logger.session_id[:8]
return short_session_id(self.agent_loop.session_logger.session_id)
async def _exit_app(self) -> None:
await self._narrator_manager.close()
@ -1918,6 +2128,14 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container:
self._chat_input_container.set_safety(profile.safety)
self._chat_input_container.set_agent_name(profile.display_name.lower())
if self._remote_manager.is_active:
session_id = self._remote_manager.session_id
self._chat_input_container.set_custom_border(
f"Remote session {short_session_id(session_id, source='remote') if session_id else ''}",
ChatInputContainer.REMOTE_BORDER_CLASS,
)
else:
self._chat_input_container.set_custom_border(None)
async def _cycle_agent(self) -> None:
new_profile = self.agent_loop.agent_manager.next_agent(
@ -1965,6 +2183,7 @@ class VibeApp(App): # noqa: PLR0904
def action_force_quit(self) -> None:
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
self._remote_manager.cancel_stream_task()
self._narrator_manager.cancel()
self.exit(result=self._get_session_resume_info())

View file

@ -118,6 +118,11 @@ TextArea > .text-area--cursor {
border: solid $mistral_orange;
border-title-color: $mistral_orange;
}
&.border-remote {
border: solid $mistral_orange;
border-title-color: $mistral_orange;
}
}
#input-body {

View file

@ -4,7 +4,11 @@ from collections.abc import Callable
from typing import TYPE_CHECKING
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, ReasoningMessage
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
ReasoningMessage,
UserMessage,
)
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.tools.ui import ToolUIDataAdapter
@ -19,6 +23,7 @@ from vibe.core.types import (
ToolResultEvent,
ToolStreamEvent,
UserMessageEvent,
WaitingForInputEvent,
)
from vibe.core.utils import TaggedText
@ -32,10 +37,12 @@ class EventHandler:
mount_callback: Callable,
get_tools_collapsed: Callable[[], bool],
on_profile_changed: Callable[[], None] | None = None,
is_remote: bool = False,
) -> None:
self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed
self.on_profile_changed = on_profile_changed
self.is_remote = is_remote
self.tool_calls: dict[str, ToolCallMessage] = {}
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
@ -72,6 +79,10 @@ class EventHandler:
self.on_profile_changed()
case UserMessageEvent():
await self.finalize_streaming()
if self.is_remote:
await self.mount_callback(UserMessage(event.content))
case WaitingForInputEvent():
await self.finalize_streaming()
case _:
await self.finalize_streaming()
await self._handle_unknown_event(event)

View file

@ -0,0 +1,8 @@
from __future__ import annotations
from vibe.cli.textual_ui.remote.remote_session_manager import (
RemoteSessionManager,
is_progress_event,
)
__all__ = ["RemoteSessionManager", "is_progress_event"]

View file

@ -0,0 +1,216 @@
from __future__ import annotations
import asyncio
from typing import Any, Protocol
from vibe.core.config import VibeConfig
from vibe.core.nuage.remote_events_source import RemoteEventsSource
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
from vibe.core.types import (
AssistantEvent,
BaseEvent,
ReasoningEvent,
ToolCallEvent,
ToolStreamEvent,
UserMessageEvent,
WaitingForInputEvent,
)
_MIN_QUESTION_OPTIONS = 2
_MAX_QUESTION_OPTIONS = 4
class RemoteSessionUI(Protocol):
async def on_remote_event(
self, event: BaseEvent, loading_active: bool, loading_widget: Any
) -> None: ...
async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None: ...
async def on_remote_user_message_cleared_input(self) -> None: ...
async def on_remote_stream_error(self, error: str) -> None: ...
async def on_remote_stream_ended(self, msg_type: str, text: str) -> None: ...
async def on_remote_finalize_streaming(self) -> None: ...
async def remove_loading(self) -> None: ...
async def ensure_loading(self, status: str = "Generating") -> None: ...
@property
def loading_widget(self) -> Any: ...
def is_progress_event(event: object) -> bool:
return isinstance(
event, (AssistantEvent, ReasoningEvent, ToolCallEvent, ToolStreamEvent)
)
class RemoteSessionManager:
def __init__(self) -> None:
self._events_source: RemoteEventsSource | None = None
self._stream_task: asyncio.Task | None = None
self._pending_waiting_input: WaitingForInputEvent | None = None
@property
def is_active(self) -> bool:
return self._events_source is not None
@property
def is_terminated(self) -> bool:
if self._events_source is None:
return False
return self._events_source.is_terminated
@property
def is_waiting_for_input(self) -> bool:
if self._events_source is None:
return False
return self._events_source.is_waiting_for_input
@property
def has_pending_input(self) -> bool:
return self._pending_waiting_input is not None
@property
def session_id(self) -> str | None:
if self._events_source is None:
return None
return self._events_source.session_id
async def attach(self, session_id: str, config: VibeConfig) -> None:
await self.detach()
self._events_source = RemoteEventsSource(session_id=session_id, config=config)
async def detach(self) -> None:
await self._stop_stream()
if self._events_source is not None:
await self._events_source.close()
self._events_source = None
self._pending_waiting_input = None
def validate_input(self) -> str | None:
if self.is_terminated:
return (
"Remote session has ended. Use /clear to start a new session"
" or /resume to attach to another."
)
if not self.is_waiting_for_input:
return (
"Remote session is not waiting for input. Please wait for the"
" current task to complete."
)
return None
async def send_prompt(self, message: str, *, require_source: bool = True) -> None:
if self._events_source is None:
if require_source:
raise RuntimeError("No active remote session")
return
saved_pending = self._pending_waiting_input
self._pending_waiting_input = None
try:
await self._events_source.send_prompt(message)
except Exception:
self._pending_waiting_input = saved_pending
raise
def cancel_pending_input(self) -> None:
self._pending_waiting_input = None
def build_question_args(
self, event: WaitingForInputEvent
) -> AskUserQuestionArgs | None:
if (
not event.predefined_answers
or len(event.predefined_answers) < _MIN_QUESTION_OPTIONS
):
return None
question = event.label or "Choose an answer"
return AskUserQuestionArgs(
questions=[
Question(
question=question,
options=[
Choice(label=answer)
for answer in event.predefined_answers[:_MAX_QUESTION_OPTIONS]
],
)
]
)
def set_pending_input(self, event: WaitingForInputEvent) -> None:
self._pending_waiting_input = event
def start_stream(self, ui: RemoteSessionUI) -> None:
if self._events_source is None:
return
if self._stream_task and not self._stream_task.done():
return
self._stream_task = asyncio.create_task(
self._consume_stream(ui), name="remote-session-stream"
)
async def stop_stream(self) -> None:
await self._stop_stream()
def build_terminal_message(self) -> tuple[str, str]:
if self._events_source is None:
return ("info", "Remote session completed")
if self._events_source.is_failed:
return ("error", "Remote session failed")
if self._events_source.is_canceled:
return ("warning", "Remote session was canceled")
return ("info", "Remote session completed")
def cancel_stream_task(self) -> None:
if self._stream_task and not self._stream_task.done():
self._stream_task.cancel()
async def _stop_stream(self) -> None:
if self._stream_task is None or self._stream_task.done():
self._stream_task = None
return
self._stream_task.cancel()
try:
await self._stream_task
except asyncio.CancelledError:
pass
self._stream_task = None
async def _consume_stream(self, ui: RemoteSessionUI) -> None:
events_source = self._events_source
if events_source is None:
return
await ui.ensure_loading("Generating")
try:
async for event in events_source.attach():
if isinstance(event, WaitingForInputEvent):
await ui.remove_loading()
self._pending_waiting_input = event
await ui.on_remote_waiting_input(event)
elif (
isinstance(event, UserMessageEvent)
and self._pending_waiting_input is not None
):
self._pending_waiting_input = None
await ui.on_remote_user_message_cleared_input()
elif ui.loading_widget is None and is_progress_event(event):
await ui.ensure_loading()
await ui.on_remote_event(
event,
loading_active=ui.loading_widget is not None,
loading_widget=ui.loading_widget,
)
except asyncio.CancelledError:
raise
except Exception as e:
await ui.on_remote_stream_error(f"Remote stream stopped: {e}")
finally:
await ui.on_remote_finalize_streaming()
await ui.remove_loading()
self._stream_task = None
self._pending_waiting_input = None
if events_source.is_terminated:
msg_type, text = self.build_terminal_message()
await ui.on_remote_stream_ended(msg_type, text)

View file

@ -30,6 +30,7 @@ SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = {
class ChatInputContainer(Vertical):
ID_INPUT_BOX = "input-box"
REMOTE_BORDER_CLASS = "border-remote"
class Submitted(Message):
def __init__(self, value: str) -> None:
@ -59,6 +60,8 @@ class ChatInputContainer(Vertical):
)
self._nuage_enabled = nuage_enabled
self._voice_manager = voice_manager
self._custom_border_label: str | None = None
self._custom_border_class: str | None = None
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
@ -86,9 +89,9 @@ class ChatInputContainer(Vertical):
self._completion_popup = CompletionPopup()
yield self._completion_popup
border_class = SAFETY_BORDER_CLASSES.get(self._safety, "")
border_class = self._get_border_class()
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
input_box.border_title = self._agent_name
input_box.border_title = self._get_border_title()
self._body = ChatInputBody(
history_file=self._history_file,
id="input-body",
@ -195,23 +198,43 @@ class ChatInputContainer(Vertical):
def set_safety(self, safety: AgentSafety) -> None:
self._safety = safety
self._apply_input_box_chrome()
def set_agent_name(self, name: str) -> None:
self._agent_name = name
self._apply_input_box_chrome()
def set_custom_border(
self, label: str | None, border_class: str | None = None
) -> None:
self._custom_border_label = label
self._custom_border_class = border_class
self._apply_input_box_chrome()
def _get_border_class(self) -> str:
if self._custom_border_class is not None:
return self._custom_border_class
if self._custom_border_label is not None:
return ""
return SAFETY_BORDER_CLASSES.get(self._safety, "")
def _get_border_title(self) -> str:
if self._custom_border_label is not None:
return self._custom_border_label
return self._agent_name
def _apply_input_box_chrome(self) -> None:
try:
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
except Exception:
return
input_box.remove_class(self.REMOTE_BORDER_CLASS)
for border_class in SAFETY_BORDER_CLASSES.values():
input_box.remove_class(border_class)
if safety in SAFETY_BORDER_CLASSES:
input_box.add_class(SAFETY_BORDER_CLASSES[safety])
border_class = self._get_border_class()
if border_class:
input_box.add_class(border_class)
def set_agent_name(self, name: str) -> None:
self._agent_name = name
try:
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
input_box.border_title = name
except Exception:
pass
input_box.border_title = self._get_border_title()

View file

@ -78,6 +78,7 @@ class LoadingWidget(SpinnerMixin, Static):
self.current_color_index = 0
self._color_direction = 1
self.transition_progress = 0
self._indicator_widget: Static | None = None
self._status_widget: Static | None = None
self.hint_widget: Static | None = None
self.start_time: float | None = None

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, ClassVar
from typing import Any, ClassVar, cast
from rich.text import Text
from textual.app import ComposeResult
@ -12,9 +12,11 @@ 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
from vibe.core.session.resume_sessions import (
ResumeSessionInfo,
ResumeSessionSource,
short_session_id,
)
_SECONDS_PER_MINUTE = 60
_SECONDS_PER_HOUR = 3600
@ -46,12 +48,15 @@ def _format_relative_time(iso_time: str | None) -> str:
return "unknown"
def _build_option_text(session: SessionInfo, message: str) -> Text:
def _build_option_text(session: ResumeSessionInfo, message: str) -> Text:
text = Text(no_wrap=True)
time_str = _format_relative_time(session.get("end_time"))
session_id = session["session_id"][:8]
time_str = _format_relative_time(session.end_time)
session_id = short_session_id(session.session_id, source=session.source)
source = session.source
text.append(f"{time_str:10}", style="dim")
text.append(" ")
text.append(f"{source:6}", style="cyan")
text.append(" ")
text.append(f"{session_id} ", style="dim")
text.append(message)
return text
@ -67,7 +72,15 @@ class SessionPickerApp(Container):
]
class SessionSelected(Message):
def __init__(self, session_id: str) -> None:
option_id: str
source: ResumeSessionSource
session_id: str
def __init__(
self, option_id: str, source: ResumeSessionSource, session_id: str
) -> None:
self.option_id = option_id
self.source = source
self.session_id = session_id
super().__init__()
@ -76,7 +89,7 @@ class SessionPickerApp(Container):
def __init__(
self,
sessions: list[SessionInfo],
sessions: list[ResumeSessionInfo],
latest_messages: dict[str, str],
**kwargs: Any,
) -> None:
@ -89,9 +102,9 @@ class SessionPickerApp(Container):
Option(
_build_option_text(
session,
self._latest_messages.get(session["session_id"], "(empty session)"),
self._latest_messages.get(session.option_id, "(empty session)"),
),
id=session["session_id"],
id=session.option_id,
)
for session in self._sessions
]
@ -106,7 +119,15 @@ class SessionPickerApp(Container):
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if event.option.id:
self.post_message(self.SessionSelected(event.option.id))
option_id = event.option.id
source, _, session_id = option_id.partition(":")
self.post_message(
self.SessionSelected(
option_id=option_id,
source=cast(ResumeSessionSource, source),
session_id=session_id,
)
)
def action_cancel(self) -> None:
self.post_message(self.Cancelled())

View file

@ -76,9 +76,7 @@ class ToolCallMessage(StatusMessage):
self._stream_widget.display = True
def stop_spinning(self, success: bool = True) -> None:
"""Stop the spinner and hide the stream widget."""
if self._stream_widget:
self._stream_widget.display = False
"""Stop the spinner while keeping stream row stable to avoid layout jumps."""
super().stop_spinning(success)
def set_result_text(self, text: str) -> None: