Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Quentin <torroba.q@gmail.com>
Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-03-23 18:45:21 +01:00 committed by GitHub
parent 5103019b01
commit eb580209d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
180 changed files with 11136 additions and 1030 deletions

View file

@ -8,7 +8,7 @@ from rich import print as rprint
import tomli_w
from vibe import __version__
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
@ -22,6 +22,7 @@ from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.session.session_loader import SessionLoader
from vibe.core.tracing import setup_tracing
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
@ -104,6 +105,8 @@ def load_session(
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
elif args.resume is True:
return None
else:
session_to_load = SessionLoader.find_session_by_id(
args.resume, config.session_logging
@ -150,6 +153,7 @@ def run_cli(args: argparse.Namespace) -> None:
try:
initial_agent_name = get_initial_agent_name(args)
config = load_config_or_exit()
setup_tracing(config)
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
@ -206,8 +210,11 @@ def run_cli(args: argparse.Namespace) -> None:
run_textual_ui(
agent_loop=agent_loop,
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
startup=StartupOptions(
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
show_resume_picker=args.resume is True,
),
)
except (KeyboardInterrupt, EOFError):

View file

@ -22,10 +22,15 @@ class CommandRegistry:
handler="_show_help",
),
"config": Command(
aliases=frozenset(["/config", "/model"]),
aliases=frozenset(["/config"]),
description="Edit config settings",
handler="_show_config",
),
"model": Command(
aliases=frozenset(["/model"]),
description="Select active model",
handler="_show_model",
),
"reload": Command(
aliases=frozenset(["/reload"]),
description="Reload configuration from disk",
@ -79,8 +84,8 @@ class CommandRegistry:
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Toggle voice mode on/off",
handler="_toggle_voice_mode",
description="Configure voice settings",
handler="_show_voice_settings",
),
"leanstall": Command(
aliases=frozenset(["/leanstall"]),

View file

@ -97,8 +97,11 @@ def parse_arguments() -> argparse.Namespace:
)
continuation_group.add_argument(
"--resume",
nargs="?",
const=True,
default=None,
metavar="SESSION_ID",
help="Resume a specific session by its ID (supports partial matching)",
help="Resume a session. Without SESSION_ID, shows an interactive picker.",
)
return parser.parse_args()

View file

@ -11,7 +11,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIPlanType,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
from vibe.core.types import Backend
logger = logging.getLogger(__name__)

View file

@ -9,6 +9,8 @@ import platform
import subprocess
from typing import Any, Literal
from vibe.core.utils.io import read_safe
class Terminal(Enum):
VSCODE = "vscode"
@ -189,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists():
content = keybindings_path.read_text()
content = read_safe(keybindings_path)
return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return []

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum, auto
import gc
import os
@ -40,12 +42,15 @@ from vibe.cli.textual_ui.notifications import (
NotificationPort,
TextualNotificationAdapter,
)
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
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
from vibe.cli.textual_ui.widgets.messages import (
@ -58,6 +63,8 @@ from vibe.cli.textual_ui.widgets.messages import (
WarningMessage,
WhatsNewMessage,
)
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus
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
@ -65,6 +72,7 @@ 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.widgets.voice_app import VoiceApp
from vibe.cli.textual_ui.windowing import (
HISTORY_RESUME_TAIL_MESSAGES,
LOAD_MORE_BATCH_SIZE,
@ -76,6 +84,13 @@ from vibe.cli.textual_ui.windowing import (
should_resume_history,
sync_backfill_state,
)
from vibe.cli.turn_summary import (
NoopTurnSummary,
TurnSummaryPort,
TurnSummaryResult,
TurnSummaryTracker,
create_narrator_backend,
)
from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository,
PyPIUpdateGateway,
@ -92,9 +107,11 @@ from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_player.audio_player_port import AudioFormat
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import Backend, VibeConfig
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader
@ -109,17 +126,20 @@ from vibe.core.teleport.types import (
TeleportSendingGithubTokenEvent,
TeleportStartingWorkflowEvent,
)
from vibe.core.tools.base import ToolPermission
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
AskUserQuestionResult,
Choice,
Question,
)
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
from vibe.core.tts.factory import make_tts_client
from vibe.core.tts.tts_client_port import TTSClientPort
from vibe.core.types import (
AgentStats,
ApprovalResponse,
Backend,
LLMMessage,
RateLimitError,
Role,
@ -129,6 +149,7 @@ from vibe.core.utils import (
get_user_cancellation_message,
is_dangerous_directory,
)
from vibe.core.utils.io import read_safe
class BottomApp(StrEnum):
@ -142,9 +163,11 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
Input = auto()
ModelPicker = auto()
ProxySetup = auto()
Question = auto()
SessionPicker = auto()
Voice = auto()
class ChatScroll(VerticalScroll):
@ -154,9 +177,31 @@ class ChatScroll(VerticalScroll):
def is_at_bottom(self) -> bool:
return self.scroll_target_y >= (self.max_scroll_y - 3)
def _check_anchor(self) -> None:
if self._anchored and self._anchor_released and self.is_at_bottom:
self._anchor_released = False
_reanchor_pending: bool = False
_scrolling_down: bool = False
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
super().watch_scroll_y(old_value, new_value)
self._scrolling_down = new_value >= old_value
def release_anchor(self) -> None:
super().release_anchor()
# Textual's MRO dispatch calls Widget._on_mouse_scroll_down AFTER
# our override, so any re-anchor we do gets immediately undone.
# Defer the re-check until all handlers for this event have finished.
if not self._reanchor_pending:
self._reanchor_pending = True
self.call_later(self._maybe_reanchor)
def _maybe_reanchor(self) -> None:
self._reanchor_pending = False
if (
self._anchored
and self._anchor_released
and self.is_at_bottom
and self._scrolling_down
):
self.anchor()
def update_node_styles(self, animate: bool = True) -> None:
pass
@ -202,6 +247,13 @@ async def prune_oldest_children(
return True
@dataclass(frozen=True, slots=True)
class StartupOptions:
initial_prompt: str | None = None
teleport_on_start: bool = False
show_resume_picker: bool = False
class VibeApp(App): # noqa: PLR0904
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "app.tcss"
@ -225,8 +277,7 @@ class VibeApp(App): # noqa: PLR0904
def __init__(
self,
agent_loop: AgentLoop,
initial_prompt: str | None = None,
teleport_on_start: bool = False,
startup: StartupOptions | None = None,
update_notifier: UpdateGateway | None = None,
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
@ -277,8 +328,10 @@ class VibeApp(App): # noqa: PLR0904
self._update_cache_repository = update_cache_repository
self._current_version = current_version
self._plan_offer_gateway = plan_offer_gateway
self._initial_prompt = initial_prompt
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled
self._show_resume_picker = opts.show_resume_picker
self._last_escape_time: float | None = None
self._banner: Banner | None = None
self._whats_new_message: WhatsNewMessage | None = None
@ -287,6 +340,12 @@ class VibeApp(App): # noqa: PLR0904
self._cached_loading_area: Widget | None = None
self._switch_agent_generation = 0
self._plan_info: PlanInfo | None = None
self._turn_summary: TurnSummaryPort = self._make_turn_summary()
self._turn_summary_close_tasks: set[asyncio.Task[Any]] = set()
self._tts_client: TTSClientPort | None = self._make_tts_client()
self._audio_player = AudioPlayer()
self._speak_task: asyncio.Task[None] | None = None
self._cancel_summary: Callable[[], bool] | None = None
@property
def config(self) -> VibeConfig:
@ -299,7 +358,9 @@ class VibeApp(App): # noqa: PLR0904
yield VerticalGroup(id="messages")
with Horizontal(id="loading-area"):
yield NarratorStatus()
yield Static(id="loading-area-content")
yield FeedbackBar()
with Static(id="bottom-app-container"):
yield ChatInputContainer(
@ -326,10 +387,12 @@ class VibeApp(App): # noqa: PLR0904
self._cached_messages_area = self.query_one("#messages")
self._cached_chat = self.query_one("#chat", ChatScroll)
self._cached_loading_area = self.query_one("#loading-area-content")
self._feedback_bar = self.query_one(FeedbackBar)
self.event_handler = EventHandler(
mount_callback=self._mount_and_scroll,
get_tools_collapsed=lambda: self._tools_collapsed,
on_profile_changed=self._on_profile_changed,
)
self._chat_input_container = self.query_one(ChatInputContainer)
@ -359,7 +422,9 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(self._refresh_banner)
if self._initial_prompt or self._teleport_on_start:
if self._show_resume_picker:
self.run_worker(self._show_session_picker(), exclusive=False)
elif self._initial_prompt or self._teleport_on_start:
self.call_after_refresh(self._process_initial_prompt)
gc.collect()
@ -424,9 +489,7 @@ class VibeApp(App): # noqa: PLR0904
async def on_approval_app_approval_granted_always_tool(
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
) -> None:
self._set_tool_permission_always(
message.tool_name, save_permanently=message.save_permanently
)
self.agent_loop.approve_always(message.tool_name, message.required_permissions)
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
@ -453,22 +516,107 @@ class VibeApp(App): # noqa: PLR0904
result = AskUserQuestionResult(answers=[], cancelled=True)
self._pending_question.set_result(result)
def on_chat_text_area_feedback_key_pressed(
self, message: ChatTextArea.FeedbackKeyPressed
) -> None:
self._feedback_bar.handle_feedback_key(message.rating)
def on_chat_text_area_non_feedback_key_pressed(
self, message: ChatTextArea.NonFeedbackKeyPressed
) -> None:
self._feedback_bar.hide()
def on_feedback_bar_feedback_given(
self, message: FeedbackBar.FeedbackGiven
) -> None:
self.agent_loop.telemetry_client.send_user_rating_feedback(
rating=message.rating, model=self.config.active_model
)
async def _remove_loading_widget(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
self._loading_widget = None
async def on_config_app_open_model_picker(
self, _message: ConfigApp.OpenModelPicker
) -> None:
config_app = self.query_one(ConfigApp)
changes = config_app._convert_changes_for_save()
if changes:
VibeConfig.save_updates(changes)
await self._reload_config()
await self._switch_to_input_app()
await self._switch_to_model_picker_app()
async def on_config_app_config_closed(
self, message: ConfigApp.ConfigClosed
) -> None:
if message.changes:
VibeConfig.save_updates(message.changes)
await self._handle_config_settings_closed(message.changes)
await self._switch_to_input_app()
async def on_voice_app_config_closed(self, message: VoiceApp.ConfigClosed) -> None:
await self._handle_voice_settings_closed(message.changes)
await self._switch_to_input_app()
async def _handle_config_settings_closed(
self, changes: dict[str, str | bool]
) -> None:
if changes:
VibeConfig.save_updates(changes)
await self._reload_config()
else:
await self._mount_and_scroll(
UserCommandMessage("Configuration closed (no changes saved).")
)
async def _handle_voice_settings_closed(
self, changes: dict[str, str | bool]
) -> None:
if not changes:
await self._mount_and_scroll(
UserCommandMessage("Voice settings closed (no changes saved).")
)
return
if "voice_mode_enabled" in changes:
current = self._voice_manager.is_enabled
desired = changes["voice_mode_enabled"]
if current != desired:
self._voice_manager.toggle_voice_mode()
self.agent_loop.telemetry_client.send_telemetry_event(
"vibe.voice_mode_toggled", {"enabled": desired}
)
self.agent_loop.refresh_config()
if desired:
await self._mount_and_scroll(
UserCommandMessage(
"Voice mode enabled. Press ctrl+r to start recording."
)
)
else:
await self._mount_and_scroll(
UserCommandMessage("Voice mode disabled.")
)
non_voice_changes = {
k: v for k, v in changes.items() if k != "voice_mode_enabled"
}
if non_voice_changes:
VibeConfig.save_updates(non_voice_changes)
self.agent_loop.refresh_config()
self._sync_turn_summary()
async def on_model_picker_app_model_selected(
self, message: ModelPickerApp.ModelSelected
) -> None:
VibeConfig.save_updates({"active_model": message.alias})
await self._reload_config()
await self._switch_to_input_app()
async def on_model_picker_app_cancelled(
self, _event: ModelPickerApp.Cancelled
) -> None:
await self._switch_to_input_app()
async def on_proxy_setup_app_proxy_setup_closed(
@ -507,13 +655,6 @@ class VibeApp(App): # noqa: PLR0904
for widget in children[:compact_index]:
await widget.remove()
def _set_tool_permission_always(
self, tool_name: str, save_permanently: bool = False
) -> None:
self.agent_loop.set_tool_permission(
tool_name, ToolPermission.ALWAYS, save_permanently
)
async def _handle_command(self, user_input: str) -> bool:
if command := self.commands.find_command(user_input):
if cmd_name := self.commands.get_command_name(user_input):
@ -557,7 +698,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
skill_content = read_safe(skill_info.skill_path)
except OSError as e:
await self._mount_and_scroll(
ErrorMessage(
@ -612,6 +753,8 @@ class VibeApp(App): # noqa: PLR0904
user_message = UserMessage(message)
await self._mount_and_scroll(user_message)
if self.agent_loop.telemetry_client.is_active():
self._feedback_bar.maybe_show()
if not self._agent_running:
self._agent_task = asyncio.create_task(
@ -682,7 +825,11 @@ class VibeApp(App): # noqa: PLR0904
return tool in self.agent_loop.tool_manager.available_tools
async def _approval_callback(
self, tool: str, args: BaseModel, tool_call_id: str
self,
tool: str,
args: BaseModel,
tool_call_id: str,
required_permissions: list[RequiredPermission] | None,
) -> tuple[ApprovalResponse, str | None]:
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
# This ensures subagents respect the main agent's tool restrictions
@ -695,7 +842,7 @@ class VibeApp(App): # noqa: PLR0904
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
with paused_timer(self._loading_widget):
await self._switch_to_approval_app(tool, args)
await self._switch_to_approval_app(tool, args, required_permissions)
result = await self._pending_approval
return result
finally:
@ -717,6 +864,12 @@ class VibeApp(App): # noqa: PLR0904
self._pending_question = None
await self._switch_to_input_app()
async def _handle_turn_error(self) -> None:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
@ -730,7 +883,10 @@ class VibeApp(App): # noqa: PLR0904
try:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._cancel_speak()
self._turn_summary.start_turn(rendered_prompt)
async for event in self.agent_loop.act(rendered_prompt):
self._turn_summary.track(event)
if self.event_handler:
await self.event_handler.handle_event(
event,
@ -739,25 +895,29 @@ class VibeApp(App): # noqa: PLR0904
)
except asyncio.CancelledError:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
await self._handle_turn_error()
self._turn_summary.cancel_turn()
raise
except Exception as e:
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
await self._handle_turn_error()
message = str(e)
if isinstance(e, RateLimitError):
message = self._rate_limit_message()
self._turn_summary.set_error(message)
await self._mount_and_scroll(
ErrorMessage(message, collapsed=self._tools_collapsed)
)
finally:
cancel_summary = self._turn_summary.end_turn()
if (
cancel_summary is not None
and self.config.narrator_enabled
and self._tts_client is not None
):
self._cancel_summary = cancel_summary
self.query_one(NarratorStatus).state = NarratorState.SUMMARIZING
self._agent_running = False
self._interrupt_requested = False
self._agent_task = None
@ -933,6 +1093,12 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_config_app()
async def _show_model(self) -> None:
"""Switch to the model picker in the bottom panel."""
if self._current_bottom_app == BottomApp.ModelPicker:
return
await self._switch_to_model_picker_app()
async def _show_proxy_setup(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@ -1045,6 +1211,7 @@ class VibeApp(App): # noqa: PLR0904
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
await self._resolve_plan()
self._sync_turn_summary()
if self._banner:
self._banner.set_state(
@ -1229,16 +1396,13 @@ class VibeApp(App): # noqa: PLR0904
lambda: self.config,
audio_recorder=AudioRecorder(),
transcribe_client=transcribe_client,
telemetry_client=self.agent_loop.telemetry_client,
)
async def _toggle_voice_mode(self) -> None:
result = self._voice_manager.toggle_voice_mode()
self.agent_loop.refresh_config()
if result.enabled:
msg = "Voice mode enabled. Press ctrl+r to start recording."
else:
msg = "Voice mode disabled."
await self._mount_and_scroll(UserCommandMessage(msg))
async def _show_voice_settings(self) -> None:
if self._current_bottom_app == BottomApp.Voice:
return
await self._switch_to_voice_app()
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
bottom_container = self.query_one("#bottom-app-container")
@ -1249,6 +1413,8 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.display = False
self._chat_input_container.disabled = True
self._feedback_bar.hide()
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
await bottom_container.mount(widget)
@ -1263,6 +1429,23 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
await self._switch_from_input(ConfigApp(self.config))
async def _switch_to_voice_app(self) -> None:
if self._current_bottom_app == BottomApp.Voice:
return
await self._mount_and_scroll(UserCommandMessage("Voice settings opened..."))
await self._switch_from_input(VoiceApp(self.config))
async def _switch_to_model_picker_app(self) -> None:
if self._current_bottom_app == BottomApp.ModelPicker:
return
model_aliases = [m.alias for m in self.config.models]
current_model = str(self.config.active_model)
await self._switch_from_input(
ModelPickerApp(model_aliases=model_aliases, current_model=current_model)
)
async def _switch_to_proxy_setup_app(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@ -1271,10 +1454,16 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_from_input(ProxySetupApp())
async def _switch_to_approval_app(
self, tool_name: str, tool_args: BaseModel
self,
tool_name: str,
tool_args: BaseModel,
required_permissions: list[RequiredPermission] | None = None,
) -> None:
approval_app = ApprovalApp(
tool_name=tool_name, tool_args=tool_args, config=self.config
tool_name=tool_name,
tool_args=tool_args,
config=self.config,
required_permissions=required_permissions,
)
await self._switch_from_input(approval_app, scroll=True)
@ -1306,6 +1495,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ChatInputContainer).focus_input()
case BottomApp.Config:
self.query_one(ConfigApp).focus()
case BottomApp.ModelPicker:
self.query_one(ModelPickerApp).focus()
case BottomApp.ProxySetup:
self.query_one(ProxySetupApp).focus()
case BottomApp.Approval:
@ -1314,6 +1505,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(QuestionApp).focus()
case BottomApp.SessionPicker:
self.query_one(SessionPickerApp).focus()
case BottomApp.Voice:
self.query_one(VoiceApp).focus()
case app:
assert_never(app)
except Exception:
@ -1327,6 +1520,14 @@ class VibeApp(App): # noqa: PLR0904
pass
self._last_escape_time = None
def _handle_voice_app_escape(self) -> None:
try:
voice_app = self.query_one(VoiceApp)
voice_app.action_close()
except Exception:
pass
self._last_escape_time = None
def _handle_approval_app_escape(self) -> None:
try:
approval_app = self.query_one(ApprovalApp)
@ -1345,6 +1546,14 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
def _handle_model_picker_app_escape(self) -> None:
try:
model_picker = self.query_one(ModelPickerApp)
model_picker.post_message(ModelPickerApp.Cancelled())
except Exception:
pass
self._last_escape_time = None
def _handle_session_picker_app_escape(self) -> None:
try:
session_picker = self.query_one(SessionPickerApp)
@ -1376,6 +1585,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_config_app_escape()
return
if self._current_bottom_app == BottomApp.Voice:
self._handle_voice_app_escape()
return
if self._current_bottom_app == BottomApp.ProxySetup:
try:
proxy_setup_app = self.query_one(ProxySetupApp)
@ -1393,6 +1606,10 @@ class VibeApp(App): # noqa: PLR0904
self._handle_question_app_escape()
return
if self._current_bottom_app == BottomApp.ModelPicker:
self._handle_model_picker_app_escape()
return
if self._current_bottom_app == BottomApp.SessionPicker:
self._handle_session_picker_app_escape()
return
@ -1405,6 +1622,11 @@ class VibeApp(App): # noqa: PLR0904
self._handle_input_app_escape()
return
narrator_status = self.query_one(NarratorStatus)
if self._audio_player.is_playing or narrator_status.state != NarratorState.IDLE:
self._cancel_speak()
return
if self._agent_running:
self._handle_agent_running_escape()
@ -1469,6 +1691,10 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_profile_widgets(self) -> None:
self._update_profile_widgets(self.agent_loop.agent_profile)
def _on_profile_changed(self) -> None:
self._refresh_profile_widgets()
self._refresh_banner()
def _refresh_banner(self) -> None:
if self._banner:
self._banner.set_state(
@ -1730,31 +1956,99 @@ class VibeApp(App): # noqa: PLR0904
# force a full layout refresh so the UI isn't garbled.
self.refresh(layout=True)
def _make_turn_summary(self) -> TurnSummaryPort:
if not self.config.narrator_enabled:
return NoopTurnSummary()
result = create_narrator_backend(self.config)
if result is None:
return NoopTurnSummary()
backend, model = result
return TurnSummaryTracker(
backend=backend, model=model, on_summary=self._on_turn_summary
)
def _print_session_resume_message(session_id: str | None) -> None:
if not session_id:
return
def _on_turn_summary(self, result: TurnSummaryResult) -> None:
self._cancel_summary = None
if result.generation != self._turn_summary.generation:
self._set_narrator_state(NarratorState.IDLE)
return
if result.summary is None:
self._set_narrator_state(NarratorState.IDLE)
return
if self._tts_client is not None:
self._speak_task = asyncio.create_task(self._speak_summary(result.summary))
else:
self._set_narrator_state(NarratorState.IDLE)
print()
print("To continue this session, run: vibe --continue")
print(f"Or: vibe --resume {session_id}")
async def _speak_summary(self, text: str) -> None:
if self._tts_client is None:
return
try:
loop = asyncio.get_running_loop()
tts_result = await self._tts_client.speak(text)
self._set_narrator_state(NarratorState.SPEAKING)
self._audio_player.play(
tts_result.audio_data,
AudioFormat.WAV,
on_finished=lambda: loop.call_soon_threadsafe(
self._set_narrator_state, NarratorState.IDLE
),
)
except Exception:
logger.warning("TTS speak failed", exc_info=True)
self._set_narrator_state(NarratorState.IDLE)
def _cancel_speak(self) -> None:
if self._cancel_summary is not None:
self._cancel_summary()
self._cancel_summary = None
if self._speak_task is not None and not self._speak_task.done():
self._speak_task.cancel()
self._speak_task = None
self._audio_player.stop()
self._set_narrator_state(NarratorState.IDLE)
def _set_narrator_state(self, state: NarratorState) -> None:
self.query_one(NarratorStatus).state = state
def _make_tts_client(self) -> TTSClientPort | None:
if not self.config.narrator_enabled:
return None
try:
model = self.config.get_active_tts_model()
provider = self.config.get_tts_provider_for_model(model)
return make_tts_client(provider, model)
except (ValueError, KeyError) as exc:
logger.error("Failed to initialize TTS client", exc_info=exc)
return None
def _sync_turn_summary(self) -> None:
self._cancel_speak()
task = asyncio.create_task(self._turn_summary.close())
self._turn_summary_close_tasks.add(task)
task.add_done_callback(self._turn_summary_close_tasks.discard)
self._turn_summary = self._make_turn_summary()
old_tts = self._tts_client
self._tts_client = self._make_tts_client()
if old_tts is not None:
close_task = asyncio.create_task(old_tts.close())
self._turn_summary_close_tasks.add(close_task)
close_task.add_done_callback(self._turn_summary_close_tasks.discard)
def run_textual_ui(
agent_loop: AgentLoop,
initial_prompt: str | None = None,
teleport_on_start: bool = False,
agent_loop: AgentLoop, startup: StartupOptions | None = None
) -> None:
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway()
app = VibeApp(
agent_loop=agent_loop,
initial_prompt=initial_prompt,
teleport_on_start=teleport_on_start,
startup=startup,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
)
session_id = app.run()
_print_session_resume_message(session_id)
print_session_resume_message(session_id, agent_loop.stats)

View file

@ -160,7 +160,7 @@ Markdown {
color: ansi_default;
.code_inline {
color: ansi_yellow;
color: ansi_green;
background: transparent;
text-style: bold;
}
@ -625,7 +625,8 @@ StatusMessage {
.loading-hint {
width: auto;
height: auto;
color: $foreground;
color: ansi_bright_black;
}
.history-load-more-message {
@ -655,7 +656,7 @@ StatusMessage {
}
#config-app {
#config-app, #voice-app {
width: 100%;
height: auto;
background: transparent;
@ -664,7 +665,7 @@ StatusMessage {
margin: 0;
}
#config-content {
#config-content, #voice-content {
width: 100%;
height: auto;
}
@ -675,41 +676,14 @@ StatusMessage {
color: ansi_blue;
}
.settings-option {
height: auto;
color: ansi_default;
#config-options {
width: 100%;
max-height: 50vh;
border: none;
}
.settings-cursor-selected {
color: ansi_blue;
text-style: bold;
}
.settings-label-selected {
color: ansi_default;
text-style: bold;
}
.settings-value-toggle-on-selected {
color: ansi_green;
text-style: bold;
}
.settings-value-toggle-on-unselected {
color: ansi_green;
}
.settings-value-toggle-off {
color: ansi_bright_black;
}
.settings-value-cycle-selected {
color: ansi_blue;
text-style: bold;
}
.settings-value-cycle-unselected {
color: ansi_blue;
#config-options:focus {
border: none;
}
.settings-help {
@ -953,6 +927,14 @@ ContextProgress {
color: ansi_bright_black;
}
NarratorStatus {
width: auto;
height: auto;
background: transparent;
padding: 0;
margin: 0 0 0 1;
}
#banner-container {
align: left middle;
padding: 0 1 0 0;
@ -1083,3 +1065,54 @@ ContextProgress {
color: ansi_bright_black;
margin-top: 1;
}
#modelpicker-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
padding: 0 1;
margin: 0;
}
#modelpicker-content {
width: 100%;
height: auto;
}
.modelpicker-title {
height: auto;
text-style: bold;
color: ansi_blue;
}
#modelpicker-options {
width: 100%;
max-height: 50vh;
text-wrap: nowrap;
text-overflow: ellipsis;
border: none;
}
#modelpicker-options:focus {
border: none;
}
.modelpicker-help {
width: 100%;
height: auto;
color: ansi_bright_black;
margin-top: 1;
}
FeedbackBar {
width: auto;
height: auto;
margin-left: 1;
}
#feedback-text {
width: auto;
height: auto;
color: ansi_default;
}

View file

@ -0,0 +1,11 @@
from __future__ import annotations
from enum import StrEnum
class MistralColors(StrEnum):
RED = "#E10500"
ORANGE_DARK = "#FA500F"
ORANGE = "#FF8205"
ORANGE_LIGHT = "#FFAF00"
YELLOW = "#FFD800"

View file

@ -6,6 +6,8 @@ import shlex
import subprocess
import tempfile
from vibe.core.utils.io import read_safe
class ExternalEditor:
"""Handles opening an external editor to edit prompt content."""
@ -24,7 +26,7 @@ class ExternalEditor:
parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True)
content = Path(filepath).read_text().rstrip()
content = read_safe(Path(filepath)).rstrip()
return content if content != initial_content else None
except (OSError, subprocess.CalledProcessError):
return

View file

@ -9,6 +9,7 @@ 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
from vibe.core.types import (
AgentProfileChangedEvent,
AssistantEvent,
BaseEvent,
CompactEndEvent,
@ -27,10 +28,14 @@ if TYPE_CHECKING:
class EventHandler:
def __init__(
self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool]
self,
mount_callback: Callable,
get_tools_collapsed: Callable[[], bool],
on_profile_changed: Callable[[], None] | None = None,
) -> None:
self.mount_callback = mount_callback
self.get_tools_collapsed = get_tools_collapsed
self.on_profile_changed = on_profile_changed
self.tool_calls: dict[str, ToolCallMessage] = {}
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
@ -62,6 +67,9 @@ class EventHandler:
case CompactEndEvent():
await self.finalize_streaming()
await self._handle_compact_end(event)
case AgentProfileChangedEvent():
if self.on_profile_changed:
self.on_profile_changed()
case UserMessageEvent():
await self.finalize_streaming()
case _:

View file

@ -0,0 +1,25 @@
from __future__ import annotations
from rich import print as rprint
from vibe.core.types import AgentStats
def format_session_usage(stats: AgentStats) -> str:
return (
"Total tokens used this session: "
f"input={stats.session_prompt_tokens:,} "
f"output={stats.session_completion_tokens:,} "
f"(total={stats.session_total_llm_tokens:,})"
)
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
if not session_id:
return
print()
print(format_session_usage(stats))
print()
rprint("To continue this session, run: [bold dark_orange]vibe --continue[/]")
rprint(f"Or: [bold dark_orange]vibe --resume {session_id}[/]")

View file

@ -13,6 +13,7 @@ from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
from vibe.core.config import VibeConfig
from vibe.core.tools.permissions import RequiredPermission
class ApprovalApp(Container):
@ -38,12 +39,15 @@ class ApprovalApp(Container):
class ApprovalGrantedAlwaysTool(Message):
def __init__(
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
self,
tool_name: str,
tool_args: BaseModel,
required_permissions: list[RequiredPermission],
) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
self.save_permanently = save_permanently
self.required_permissions = required_permissions
class ApprovalRejected(Message):
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
@ -52,12 +56,17 @@ class ApprovalApp(Container):
self.tool_args = tool_args
def __init__(
self, tool_name: str, tool_args: BaseModel, config: VibeConfig
self,
tool_name: str,
tool_args: BaseModel,
config: VibeConfig,
required_permissions: list[RequiredPermission] | None = None,
) -> None:
super().__init__(id="approval-app")
self.tool_name = tool_name
self.tool_args = tool_args
self.config = config
self.required_permissions = required_permissions or []
self.selected_option = 0
self.content_container: Vertical | None = None
self.title_widget: Static | None = None
@ -104,9 +113,15 @@ class ApprovalApp(Container):
await self.tool_info_container.mount(approval_widget)
def _update_options(self) -> None:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
always_text = f"Yes and always allow for this session: {labels}"
else:
always_text = f"Yes and always allow {self.tool_name} for this session"
options = [
("Yes", "yes"),
(f"Yes and always allow {self.tool_name} for this session", "yes"),
(always_text, "yes"),
("No and tell the agent what to do instead", "no"),
]
@ -178,7 +193,7 @@ class ApprovalApp(Container):
self.ApprovalGrantedAlwaysTool(
tool_name=self.tool_name,
tool_args=self.tool_args,
save_permanently=False,
required_permissions=self.required_permissions,
)
)
case 2:

View file

@ -161,6 +161,16 @@ class ChatTextArea(TextArea):
self.post_message(self.HistoryNext())
return True
class FeedbackKeyPressed(Message):
def __init__(self, rating: int) -> None:
self.rating = rating
super().__init__()
class NonFeedbackKeyPressed(Message):
pass
feedback_active: bool = False
async def _handle_voice_key(self, event: events.Key) -> bool:
if not self._voice_manager:
return False
@ -193,6 +203,15 @@ class ChatTextArea(TextArea):
self._mark_cursor_moved_if_needed()
if self.feedback_active:
if event.character in {"1", "2", "3"}:
event.prevent_default()
event.stop()
self.post_message(self.FeedbackKeyPressed(int(event.character)))
return
if event.character is not None:
self.post_message(self.NonFeedbackKeyPressed())
manager = self._completion_manager
if manager:
match manager.on_key(

View file

@ -1,13 +1,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, TypedDict
from typing import TYPE_CHECKING, ClassVar
from textual import events
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.events import DescendantBlur
from textual.message import Message
from textual.widgets import Static
from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
@ -15,22 +17,13 @@ if TYPE_CHECKING:
from vibe.core.config import VibeConfig
class SettingDefinition(TypedDict):
key: str
label: str
type: str
options: list[str]
class ConfigApp(Container):
can_focus = True
can_focus_children = False
"""Settings panel with navigatable option picker."""
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("space", "toggle_setting", "Toggle", show=False),
Binding("enter", "cycle", "Next", show=False),
Binding("escape", "close", "Close", show=False)
]
class SettingChanged(Message):
@ -44,122 +37,92 @@ class ConfigApp(Container):
super().__init__()
self.changes = changes
class OpenModelPicker(Message):
pass
def __init__(self, config: VibeConfig) -> None:
super().__init__(id="config-app")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
self.settings: list[SettingDefinition] = [
{
"key": "active_model",
"label": "Model",
"type": "cycle",
"options": [m.alias for m in self.config.models],
},
{
"key": "autocopy_to_clipboard",
"label": "Auto-copy",
"type": "cycle",
"options": ["On", "Off"],
},
{
"key": "file_watcher_for_autocomplete",
"label": "Autocomplete watcher (may delay first autocompletion)",
"type": "cycle",
"options": ["On", "Off"],
},
self._toggle_settings: list[tuple[str, str]] = [
("autocopy_to_clipboard", "Auto-copy"),
(
"file_watcher_for_autocomplete",
"Autocomplete watcher (may delay first autocompletion)",
),
]
self.title_widget: Static | None = None
self.setting_widgets: list[Static] = []
self.help_widget: Static | None = None
def _get_current_model(self) -> str:
return str(getattr(self.config, "active_model", ""))
def compose(self) -> ComposeResult:
with Vertical(id="config-content"):
self.title_widget = NoMarkupStatic("Settings", classes="settings-title")
yield self.title_widget
yield NoMarkupStatic("")
for _ in self.settings:
widget = NoMarkupStatic("", classes="settings-option")
self.setting_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
)
yield self.help_widget
def on_mount(self) -> None:
self._update_display()
self.focus()
def _get_display_value(self, setting: SettingDefinition) -> str:
key = setting["key"]
def _get_toggle_value(self, key: str) -> str:
if key in self.changes:
return self.changes[key]
raw_value = getattr(self.config, key, "")
if isinstance(raw_value, bool):
return "On" if raw_value else "Off"
return str(raw_value)
raw = getattr(self.config, key, False)
if isinstance(raw, bool):
return "On" if raw else "Off"
return str(raw)
def _update_display(self) -> None:
for i, (setting, widget) in enumerate(
zip(self.settings, self.setting_widgets, strict=True)
):
is_selected = i == self.selected_index
cursor = " " if is_selected else " "
def _model_prompt(self) -> Text:
text = Text(no_wrap=True)
text.append("Model: ")
text.append(self._get_current_model(), style="bold")
return text
label: str = setting["label"]
value: str = self._get_display_value(setting)
def _toggle_prompt(self, key: str, label: str) -> Text:
value = self._get_toggle_value(key)
text = Text(no_wrap=True)
text.append(f"{label}: ")
if value == "On":
text.append("On", style="green bold")
else:
text.append("Off", style="dim")
return text
text = f"{cursor}{label}: {value}"
def compose(self) -> ComposeResult:
options: list[Option] = [Option(self._model_prompt(), id="action:active_model")]
for key, label in self._toggle_settings:
options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}"))
widget.update(text)
with Vertical(id="config-content"):
yield NoMarkupStatic("Settings", classes="settings-title")
yield NoMarkupStatic("")
yield OptionList(*options, id="config-options")
yield NoMarkupStatic("")
yield NoMarkupStatic(
"↑↓ Navigate Enter Select/Toggle Esc Exit", classes="settings-help"
)
widget.remove_class("settings-cursor-selected")
widget.remove_class("settings-value-cycle-selected")
widget.remove_class("settings-value-cycle-unselected")
def on_mount(self) -> None:
self.query_one(OptionList).focus()
if is_selected:
widget.add_class("settings-value-cycle-selected")
else:
widget.add_class("settings-value-cycle-unselected")
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
def action_move_up(self) -> None:
self.selected_index = (self.selected_index - 1) % len(self.settings)
self._update_display()
def _refresh_options(self) -> None:
option_list = self.query_one(OptionList)
option_list.replace_option_prompt("action:active_model", self._model_prompt())
for key, label in self._toggle_settings:
option_list.replace_option_prompt(
f"toggle:{key}", self._toggle_prompt(key, label)
)
def action_move_down(self) -> None:
self.selected_index = (self.selected_index + 1) % len(self.settings)
self._update_display()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id
if not option_id:
return
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self._get_display_value(setting)
if option_id == "action:active_model":
self.post_message(self.OpenModelPicker())
return
options: list[str] = setting["options"]
new_value = ""
try:
current_idx = options.index(current)
next_idx = (current_idx + 1) % len(options)
new_value = options[next_idx]
except (ValueError, IndexError):
new_value = options[0] if options else current
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._update_display()
def action_cycle(self) -> None:
self.action_toggle_setting()
if option_id.startswith("toggle:"):
key = option_id.removeprefix("toggle:")
current = self._get_toggle_value(key)
new_value = "Off" if current == "On" else "On"
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._refresh_options()
def _convert_changes_for_save(self) -> dict[str, str | bool]:
result: dict[str, str | bool] = {}
@ -172,6 +135,3 @@ class ConfigApp(Container):
def action_close(self) -> None:
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View file

@ -0,0 +1,69 @@
from __future__ import annotations
import random
from rich.text import Text
from textual.app import ComposeResult
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
FEEDBACK_PROBABILITY = 0.02
THANK_YOU_DURATION = 2.0
class FeedbackBar(Widget):
class FeedbackGiven(Message):
def __init__(self, rating: int) -> None:
super().__init__()
self.rating = rating
@staticmethod
def _prompt_text() -> Text:
text = Text()
text.append("How is Vibe doing so far? ")
text.append("1", style="blue")
text.append(": good ")
text.append("2", style="blue")
text.append(": fine ")
text.append("3", style="blue")
text.append(": bad")
return text
def compose(self) -> ComposeResult:
yield Static(self._prompt_text(), id="feedback-text")
def on_mount(self) -> None:
self.display = False
def maybe_show(self) -> None:
if self.display:
return
if random.random() <= FEEDBACK_PROBABILITY:
self._set_active(True)
def hide(self) -> None:
if self.display:
self._set_active(False)
def handle_feedback_key(self, rating: int) -> None:
try:
self.app.query_one(ChatTextArea).feedback_active = False
except Exception:
pass
self.query_one("#feedback-text", Static).update(
Text("Thank you for your feedback!")
)
self.post_message(self.FeedbackGiven(rating))
self.set_timer(THANK_YOU_DURATION, lambda: self._set_active(False))
def _set_active(self, active: bool) -> None:
if active:
self.query_one("#feedback-text", Static).update(self._prompt_text())
self.display = active
try:
self.app.query_one(ChatTextArea).feedback_active = active
except Exception:
pass

View file

@ -11,6 +11,7 @@ from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
from vibe.cli.textual_ui.constants import MistralColors
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
@ -28,7 +29,13 @@ def _format_elapsed(seconds: int) -> str:
class LoadingWidget(SpinnerMixin, Static):
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
TARGET_COLORS = (
MistralColors.YELLOW,
MistralColors.ORANGE_LIGHT,
MistralColors.ORANGE,
MistralColors.ORANGE_DARK,
MistralColors.RED,
)
SPINNER_TYPE = SpinnerType.SNAKE
EASTER_EGGS: ClassVar[list[str]] = [

View file

@ -0,0 +1,75 @@
from __future__ import annotations
from typing import 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
def _build_option_text(alias: str, is_current: bool) -> Text:
text = Text(no_wrap=True)
marker = " " if is_current else " "
style = "bold" if is_current else ""
text.append(marker, style="green" if is_current else "")
text.append(alias, style=style)
return text
class ModelPickerApp(Container):
"""Model picker bottom app for selecting the active model."""
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Cancel", show=False)
]
class ModelSelected(Message):
def __init__(self, alias: str) -> None:
self.alias = alias
super().__init__()
class Cancelled(Message):
pass
def __init__(
self, model_aliases: list[str], current_model: str, **kwargs: Any
) -> None:
super().__init__(id="modelpicker-app", **kwargs)
self._model_aliases = model_aliases
self._current_model = current_model
def compose(self) -> ComposeResult:
options = [
Option(_build_option_text(alias, alias == self._current_model), id=alias)
for alias in self._model_aliases
]
with Vertical(id="modelpicker-content"):
yield NoMarkupStatic("Select Model", classes="modelpicker-title")
yield OptionList(*options, id="modelpicker-options")
yield NoMarkupStatic(
"↑↓ Navigate Enter Select Esc Cancel", classes="modelpicker-help"
)
def on_mount(self) -> None:
option_list = self.query_one(OptionList)
# Pre-select the current model
for i, alias in enumerate(self._model_aliases):
if alias == self._current_model:
option_list.highlighted = i
break
option_list.focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if event.option.id:
self.post_message(self.ModelSelected(event.option.id))
def action_cancel(self) -> None:
self.post_message(self.Cancelled())

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from enum import StrEnum, auto
from typing import Any
from textual.reactive import reactive
from textual.timer import Timer
from textual.widgets import Static
SHRINK_FRAMES = "█▇▆▅▄▃▂▁"
BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"]
ANIMATION_INTERVAL = 0.15
class NarratorState(StrEnum):
IDLE = auto()
SUMMARIZING = auto()
SPEAKING = auto()
class NarratorStatus(Static):
state = reactive(NarratorState.IDLE)
def __init__(self, **kwargs: Any) -> None:
super().__init__("", **kwargs)
self._timer: Timer | None = None
self._frame: int = 0
def watch_state(self, new_state: NarratorState) -> None:
self._stop_timer()
match new_state:
case NarratorState.IDLE:
self.update("")
case NarratorState.SUMMARIZING | NarratorState.SPEAKING:
self._frame = 0
self._tick()
self._timer = self.set_interval(ANIMATION_INTERVAL, self._tick)
def _tick(self) -> None:
match self.state:
case NarratorState.SUMMARIZING:
char = SHRINK_FRAMES[self._frame % len(SHRINK_FRAMES)]
self.update(
f"[bold orange]{char}[/bold orange] summarizing [dim]esc to stop[/dim]"
)
case NarratorState.SPEAKING:
bars = BAR_FRAMES[self._frame % len(BAR_FRAMES)]
self.update(
f"[bold orange]{bars}[/bold orange] speaking [dim]esc to stop[/dim]"
)
self._frame += 1
def _stop_timer(self) -> None:
if self._timer is not None:
self._timer.stop()
self._timer = None

View file

@ -0,0 +1,173 @@
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, TypedDict
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
from textual.message import Message
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
class SettingDefinition(TypedDict):
key: str
label: str
type: str
options: list[str]
class VoiceApp(Container):
can_focus = True
can_focus_children = False
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("space", "toggle_setting", "Toggle", show=False),
Binding("enter", "cycle", "Next", show=False),
]
class SettingChanged(Message):
def __init__(self, key: str, value: str) -> None:
super().__init__()
self.key = key
self.value = value
class ConfigClosed(Message):
def __init__(self, changes: dict[str, str | bool]) -> None:
super().__init__()
self.changes = changes
def __init__(self, config: VibeConfig) -> None:
super().__init__(id="voice-app")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
self.settings: list[SettingDefinition] = [
{
"key": "voice_mode_enabled",
"label": "Voice mode",
"type": "cycle",
"options": ["On", "Off"],
},
{
"key": "narrator_enabled",
"label": "Narrator (experimental)",
"type": "cycle",
"options": ["On", "Off"],
},
]
self.title_widget: Static | None = None
self.setting_widgets: list[Static] = []
self.help_widget: Static | None = None
def compose(self) -> ComposeResult:
with Vertical(id="voice-content"):
self.title_widget = NoMarkupStatic(
"Voice Settings", classes="settings-title"
)
yield self.title_widget
yield NoMarkupStatic("")
for _ in self.settings:
widget = NoMarkupStatic("", classes="settings-option")
self.setting_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
)
yield self.help_widget
def on_mount(self) -> None:
self._update_display()
self.focus()
def _get_display_value(self, setting: SettingDefinition) -> str:
key = setting["key"]
if key in self.changes:
return self.changes[key]
raw_value = getattr(self.config, key, "")
if isinstance(raw_value, bool):
return "On" if raw_value else "Off"
return str(raw_value)
def _update_display(self) -> None:
for i, (setting, widget) in enumerate(
zip(self.settings, self.setting_widgets, strict=True)
):
is_selected = i == self.selected_index
cursor = " " if is_selected else " "
label: str = setting["label"]
value: str = self._get_display_value(setting)
text = f"{cursor}{label}: {value}"
widget.update(text)
widget.remove_class("settings-cursor-selected")
widget.remove_class("settings-value-cycle-selected")
widget.remove_class("settings-value-cycle-unselected")
if is_selected:
widget.add_class("settings-value-cycle-selected")
else:
widget.add_class("settings-value-cycle-unselected")
def action_move_up(self) -> None:
self.selected_index = (self.selected_index - 1) % len(self.settings)
self._update_display()
def action_move_down(self) -> None:
self.selected_index = (self.selected_index + 1) % len(self.settings)
self._update_display()
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self._get_display_value(setting)
options: list[str] = setting["options"]
new_value = ""
try:
current_idx = options.index(current)
next_idx = (current_idx + 1) % len(options)
new_value = options[next_idx]
except (ValueError, IndexError):
new_value = options[0] if options else current
self.changes[key] = new_value
self.post_message(self.SettingChanged(key=key, value=new_value))
self._update_display()
def action_cycle(self) -> None:
self.action_toggle_setting()
def _convert_changes_for_save(self) -> dict[str, str | bool]:
result: dict[str, str | bool] = {}
for key, value in self.changes.items():
if value in {"On", "Off"}:
result[key] = value == "On"
else:
result[key] = value
return result
def action_close(self) -> None:
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)

View file

@ -14,7 +14,7 @@ def patch_vscode_space(event: events.Key) -> None:
silently drop the keystroke because there is no printable character.
Assigning ``event.character = " "`` restores normal behaviour.
"""
if event.key == "space" and event.character is None:
if event.key in {"space", "shift+space"} and event.character is None:
event.character = " "

View file

@ -0,0 +1,20 @@
from __future__ import annotations
from vibe.cli.turn_summary.noop import NoopTurnSummary
from vibe.cli.turn_summary.port import (
TurnSummaryData,
TurnSummaryPort,
TurnSummaryResult,
)
from vibe.cli.turn_summary.tracker import TurnSummaryTracker
from vibe.cli.turn_summary.utils import NARRATOR_MODEL, create_narrator_backend
__all__ = [
"NARRATOR_MODEL",
"NoopTurnSummary",
"TurnSummaryData",
"TurnSummaryPort",
"TurnSummaryResult",
"TurnSummaryTracker",
"create_narrator_backend",
]

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from collections.abc import Callable
from vibe.cli.turn_summary.port import TurnSummaryPort
from vibe.core.types import BaseEvent
class NoopTurnSummary(TurnSummaryPort):
@property
def generation(self) -> int:
return 0
def start_turn(self, user_message: str) -> None:
pass
def track(self, event: BaseEvent) -> None:
pass
def set_error(self, message: str) -> None:
pass
def cancel_turn(self) -> None:
pass
def end_turn(self) -> Callable[[], bool] | None:
return None
async def close(self) -> None:
pass

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
from pydantic import BaseModel, Field
from vibe.core.types import BaseEvent
class TurnSummaryData(BaseModel):
user_message: str
assistant_fragments: list[str] = Field(default_factory=list)
error: str | None = None
class TurnSummaryResult(BaseModel):
generation: int
summary: str | None
class TurnSummaryPort(ABC):
@property
@abstractmethod
def generation(self) -> int: ...
@abstractmethod
def start_turn(self, user_message: str) -> None: ...
@abstractmethod
def track(self, event: BaseEvent) -> None: ...
@abstractmethod
def set_error(self, message: str) -> None: ...
@abstractmethod
def cancel_turn(self) -> None: ...
@abstractmethod
def end_turn(self) -> Callable[[], bool] | None: ...
@abstractmethod
async def close(self) -> None: ...

View file

@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any
from vibe.cli.turn_summary.port import (
TurnSummaryData,
TurnSummaryPort,
TurnSummaryResult,
)
from vibe.core.config import ModelConfig
from vibe.core.llm.types import BackendLike
from vibe.core.logger import logger
from vibe.core.prompts import UtilityPrompt
from vibe.core.types import AssistantEvent, BaseEvent, LLMMessage, Role
class TurnSummaryTracker(TurnSummaryPort):
def __init__(
self,
backend: BackendLike,
model: ModelConfig,
on_summary: Callable[[TurnSummaryResult], None],
max_tokens: int = 512,
) -> None:
self._backend = backend
self._model = model
self._on_summary = on_summary
self._max_tokens = max_tokens
self._tasks: set[asyncio.Task[Any]] = set()
self._data: TurnSummaryData | None = None
self._generation: int = 0
@property
def generation(self) -> int:
return self._generation
def start_turn(self, user_message: str) -> None:
self._generation += 1
self._data = TurnSummaryData(user_message=user_message)
def track(self, event: BaseEvent) -> None:
if self._data is None:
return
match event:
case AssistantEvent(content=c) if c:
self._data.assistant_fragments.append(c)
def set_error(self, message: str) -> None:
if self._data is not None:
self._data.error = message
def cancel_turn(self) -> None:
self._data = None
def end_turn(self) -> Callable[[], bool] | None:
if self._data is None:
return None
gen = self._generation
task = asyncio.create_task(self._generate_summary(self._data, gen))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
self._data = None
return task.cancel
async def close(self) -> None:
for task in self._tasks:
task.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
async def _generate_summary(self, data: TurnSummaryData, gen: int) -> None:
try:
prompt_text = UtilityPrompt.TURN_SUMMARY.read()
sections: list[str] = []
sections.append(f"## User Request\n{data.user_message}")
full_text = "".join(data.assistant_fragments)
if full_text:
sections.append(f"## Assistant Response\n{full_text}")
if data.error:
sections.append(f"## Error\n{data.error}")
extraction_text = "\n\n".join(sections)
summary_messages = [
LLMMessage(role=Role.system, content=prompt_text),
LLMMessage(role=Role.user, content=extraction_text),
]
result = await self._backend.complete(
model=self._model,
messages=summary_messages,
temperature=0.0,
tools=None,
tool_choice=None,
max_tokens=self._max_tokens,
extra_headers={},
metadata={},
)
summary = result.message.content or ""
self._on_summary(TurnSummaryResult(generation=gen, summary=summary))
except Exception:
logger.warning("Turn summary generation failed", exc_info=True)
self._on_summary(TurnSummaryResult(generation=gen, summary=None))

View file

@ -0,0 +1,30 @@
from __future__ import annotations
import os
from vibe.core.config import ModelConfig, VibeConfig
from vibe.core.llm.backend.factory import BACKEND_FACTORY
from vibe.core.llm.types import BackendLike
NARRATOR_MODEL = ModelConfig(
name="mistral-vibe-cli-fast",
provider="mistral",
alias="mistral-small",
input_price=0.1,
output_price=0.3,
)
def create_narrator_backend(
config: VibeConfig,
) -> tuple[BackendLike, ModelConfig] | None:
try:
provider = config.get_provider_for_model(NARRATOR_MODEL)
except ValueError:
return None
if provider.api_key_env_var and not os.getenv(provider.api_key_env_var):
return None
backend = BACKEND_FACTORY[provider.backend](
provider=provider, timeout=config.api_timeout
)
return backend, NARRATOR_MODEL

View file

@ -7,6 +7,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.core.utils.io import read_safe
async def should_show_whats_new(
@ -23,7 +24,7 @@ def load_whats_new_content() -> str | None:
if not whats_new_file.exists():
return None
try:
content = whats_new_file.read_text(encoding="utf-8").strip()
content = read_safe(whats_new_file).strip()
return content if content else None
except OSError:
return None

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass, field
import time
@dataclass
class TranscriptionTrackingState:
recording_id: str = ""
start_time: float = field(default_factory=time.monotonic)
accumulated_transcript_length: int = 0
last_recording_duration_ms: float | None = None
def reset(self) -> None:
self.recording_id = ""
self.start_time = time.monotonic()
self.accumulated_transcript_length = 0
self.last_recording_duration_ms = None
def set_recording_id(self, recording_id: str) -> None:
self.recording_id = recording_id
def record_text(self, text: str) -> None:
self.accumulated_transcript_length += len(text)
def elapsed_ms(self) -> float:
return (time.monotonic() - self.start_time) * 1000
def set_recording_duration(self, duration_s: float) -> None:
self.last_recording_duration_ms = duration_s * 1000

View file

@ -1,15 +1,14 @@
from __future__ import annotations
from asyncio import CancelledError, Task, create_task, wait_for
from collections.abc import Callable
from asyncio import CancelledError, create_task, wait_for
from typing import TYPE_CHECKING
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
TranscribeState,
VoiceManagerListener,
VoiceToggleResult,
)
from vibe.core.audio_recorder import AudioRecorderPort
from vibe.core.audio_recorder.audio_recorder_port import (
AlreadyRecordingError,
AudioBackendUnavailableError,
@ -19,13 +18,21 @@ from vibe.core.audio_recorder.audio_recorder_port import (
from vibe.core.config import VibeConfig
from vibe.core.logger import logger
from vibe.core.transcribe.transcribe_client_port import (
TranscribeClientPort,
TranscribeDone,
TranscribeError,
TranscribeSessionCreated,
TranscribeTextDelta,
)
if TYPE_CHECKING:
from asyncio import Task
from collections.abc import Callable
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerListener
from vibe.core.audio_recorder import AudioRecorderPort
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort
TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
@ -35,13 +42,16 @@ class VoiceManager:
config_getter: Callable[[], VibeConfig],
audio_recorder: AudioRecorderPort,
transcribe_client: TranscribeClientPort | None,
telemetry_client: TelemetryClient | None = None,
) -> None:
self._config_getter = config_getter
self._audio_recorder = audio_recorder
self._transcribe_client = transcribe_client
self._telemetry_client = telemetry_client
self._transcribe_state = TranscribeState.IDLE
self._transcribe_task: Task[None] | None = None
self._listeners: list[VoiceManagerListener] = []
self._tracking = TranscriptionTrackingState()
@property
def is_enabled(self) -> bool:
@ -91,6 +101,7 @@ class VoiceManager:
except NoAudioInputDeviceError:
raise RecordingStartError("No audio input device found")
self._tracking.reset()
self._set_state(TranscribeState.RECORDING)
self._transcribe_task = create_task(self._run_transcription())
@ -101,7 +112,8 @@ class VoiceManager:
if should_flush_queue:
self._set_state(TranscribeState.FLUSHING)
self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
recording = self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
self._tracking.set_recording_duration(recording.duration)
if self._transcribe_task is not None:
try:
@ -111,6 +123,7 @@ class VoiceManager:
except TimeoutError:
logger.warning("Transcription task timed out, cancelling")
self._transcribe_task.cancel()
self._on_audio_transcription_error("Transcription timed out")
except CancelledError:
pass
self._transcribe_task = None
@ -129,6 +142,7 @@ class VoiceManager:
self._transcribe_task = None
self._set_state(TranscribeState.IDLE)
self._on_audio_transcription_cancel()
def add_listener(self, listener: VoiceManagerListener) -> None:
if listener not in self._listeners:
@ -150,6 +164,7 @@ class VoiceManager:
async for event in self._transcribe_client.transcribe(audio_stream):
match event:
case TranscribeTextDelta(text=text):
self._tracking.record_text(text)
for listener in self._listeners:
try:
listener.on_transcribe_text(text)
@ -158,22 +173,80 @@ class VoiceManager:
"Listener raised during transcribe text",
exc_info=True,
)
case TranscribeDone():
pass
case TranscribeError(message=msg):
raise RuntimeError(msg)
case TranscribeSessionCreated():
case TranscribeSessionCreated(request_id=request_id):
self._tracking.set_recording_id(request_id)
self._on_audio_transcription_start()
case TranscribeDone():
pass
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
self._on_audio_transcription_done()
except CancelledError:
raise
except Exception as exc:
logger.error("Transcription failed", exc_info=exc)
self._audio_recorder.cancel()
if self._transcribe_state != TranscribeState.IDLE:
self._set_state(TranscribeState.IDLE)
self._on_audio_transcription_error(str(exc))
def _on_audio_transcription_start(self) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.audio.transcription.start",
{"recording_id": self._tracking.recording_id},
)
def _on_audio_transcription_cancel(self) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.audio.transcription.cancel_recording",
{
"recording_id": self._tracking.recording_id,
"recording_duration_ms": self._tracking.elapsed_ms(),
},
)
def _on_audio_transcription_done(self) -> None:
if not self._telemetry_client:
return
transcription_duration_ms = self._tracking.elapsed_ms()
recording_duration_ms = (
self._tracking.last_recording_duration_ms
if self._tracking.last_recording_duration_ms is not None
else transcription_duration_ms
)
self._telemetry_client.send_telemetry_event(
"vibe.audio.transcription.done",
{
"recording_id": self._tracking.recording_id,
"transcript_length": self._tracking.accumulated_transcript_length,
"transcription_duration_ms": transcription_duration_ms,
"recording_duration_ms": recording_duration_ms,
},
)
def _on_audio_transcription_error(self, error_message: str) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.audio.transcription.error",
{
"recording_id": self._tracking.recording_id,
"error_message": error_message,
"transcription_duration_ms": self._tracking.elapsed_ms(),
"recording_duration_ms": self._tracking.last_recording_duration_ms,
},
)
def _set_state(self, state: TranscribeState) -> None:
if self._transcribe_state == state:
return