Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Corentin André <corentin.andre@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: Peter Evers <pevers90@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: MichisGitIsKing <MichisGitIsKing@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-05-19 11:56:25 +02:00 committed by GitHub
parent 626f905186
commit 228f3c65a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
158 changed files with 7235 additions and 916 deletions

View file

@ -23,6 +23,7 @@ from vibe.core.hooks.config import load_hooks_from_fs
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.session import last_session_pointer
from vibe.core.session.session_loader import SessionLoader
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.types import EntrypointMetadata
@ -140,9 +141,15 @@ def load_session(
session_to_load = None
if args.continue_session:
cwd = Path.cwd().resolve()
session_to_load = SessionLoader.find_latest_session(
config.session_logging, working_directory=cwd
)
pointer_session_id = last_session_pointer.load(config.session_logging)
if pointer_session_id:
session_to_load = SessionLoader.find_session_by_id(
pointer_session_id, config.session_logging, working_directory=cwd
)
if not session_to_load:
session_to_load = SessionLoader.find_latest_session(
config.session_logging, working_directory=cwd
)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "

View file

@ -96,6 +96,15 @@ def parse_arguments() -> argparse.Namespace:
metavar="DIR",
help="Change to this directory before running",
)
parser.add_argument(
"--add-dir",
action="append",
metavar="DIR",
default=[],
help="Additional working directory for file access and context. "
"Implicitly trusted for the session (same semantics as --trust). "
"Can be specified multiple times.",
)
parser.add_argument(
"--trust",
action="store_true",
@ -180,10 +189,22 @@ def main() -> None:
if args.trust:
trusted_folders_manager.trust_for_session(cwd)
additional_dirs: list[Path] = []
for d in args.add_dir:
resolved = Path(d).expanduser().resolve()
if not resolved.is_dir():
rprint(
f"[red]Error: --add-dir path does not exist "
f"or is not a directory: {d}[/]"
)
sys.exit(1)
additional_dirs.append(resolved)
trusted_folders_manager.trust_for_session(resolved)
is_interactive = args.prompt is None
if is_interactive:
check_and_resolve_trusted_folder(cwd)
init_harness_files_manager("user", "project")
init_harness_files_manager("user", "project", additional_dirs=additional_dirs)
from vibe.cli.cli import run_cli

View file

@ -12,12 +12,11 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
)
from vibe.core.utils.http import build_ssl_context
BASE_URL = "https://console.mistral.ai"
WHOAMI_PATH = "/api/vibe/whoami"
class HttpWhoAmIGateway:
def __init__(self, base_url: str = BASE_URL) -> None:
def __init__(self, base_url: str) -> None:
self._base_url = base_url.rstrip("/")
async def whoami(self, api_key: str) -> WhoAmIResponse:

View file

@ -11,15 +11,15 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIPlanType,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
from vibe.core.config import (
DEFAULT_CONSOLE_BASE_URL,
DEFAULT_MISTRAL_API_ENV_KEY,
ProviderConfig,
)
from vibe.core.types import Backend
logger = logging.getLogger(__name__)
CONSOLE_CLI_URL = "https://console.mistral.ai/codestral/cli"
UPGRADE_URL = CONSOLE_CLI_URL
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
class MistralCodePlanName(StrEnum):
FREE = "F"
@ -96,16 +96,21 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
return getenv(api_env_key)
def plan_offer_cta(payload: PlanInfo | None) -> str | None:
def plan_offer_cta(
payload: PlanInfo | None, console_base_url: str = DEFAULT_CONSOLE_BASE_URL
) -> str | None:
if not payload:
return
console_cli_url = f"{console_base_url.rstrip('/')}/codestral/cli"
switch_to_pro_key_url = console_cli_url
upgrade_url = console_cli_url
if payload.prompt_switching_to_pro_plan:
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
return f"### Switch to your [Le Chat Pro API key]({switch_to_pro_key_url})"
if (
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
or payload.is_free_mistral_code_plan()
):
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({upgrade_url})"
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911

View file

@ -160,7 +160,7 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp_settings import persist_mcp_toggle
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
@ -257,6 +257,19 @@ PRUNE_LOW_MARK = 1000
PRUNE_HIGH_MARK = 1500
DOUBLE_ESC_DELAY = 0.2
_DEFAULT_TYPING_DEBOUNCE_MS = 1000
_TYPING_DEBOUNCE_ENV_VAR = "VIBE_TYPING_GRACE_PERIOD_MS"
def _resolve_typing_debounce_s() -> float:
try:
ms = int(os.environ[_TYPING_DEBOUNCE_ENV_VAR])
if ms < 0:
raise ValueError
except (KeyError, ValueError):
ms = _DEFAULT_TYPING_DEBOUNCE_MS
return ms / 1000
async def prune_oldest_children(
messages_area: Widget, low_mark: int, high_mark: int
@ -320,6 +333,9 @@ class VibeApp(App): # noqa: PLR0904
Binding(
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
),
Binding(
"ctrl+g", "open_plan_in_editor", "Edit Plan", show=False, priority=False
),
Binding("ctrl+backslash", "toggle_debug_console", "Debug Console", show=False),
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
@ -425,7 +441,7 @@ class VibeApp(App): # noqa: PLR0904
@property
def _connectors_enabled(self) -> bool:
return connectors_enabled() and self.agent_loop.connector_registry is not None
return self.agent_loop.connector_registry is not None
def _get_command_availability_context(self) -> CommandAvailabilityContext:
return CommandAvailabilityContext(
@ -519,8 +535,10 @@ class VibeApp(App): # noqa: PLR0904
self._loop_runner.start()
await self._check_and_show_whats_new()
self._schedule_update_notification()
if not self._is_resuming_session:
self.agent_loop.emit_new_session_telemetry()
if self._is_resuming_session:
await self.agent_loop.hydrate_experiments_from_session()
else:
self.agent_loop.start_initialize_experiments()
self.call_after_refresh(self._refresh_banner)
self._show_hook_config_issues_once()
@ -1250,6 +1268,29 @@ class VibeApp(App): # noqa: PLR0904
def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
return tool in self.agent_loop.tool_manager.available_tools
async def _wait_for_typing_pause(self) -> None:
try:
text_area = self.query_one(ChatTextArea)
except Exception:
return
debounce_s = _resolve_typing_debounce_s()
if text_area.time_since_last_keystroke() >= debounce_s:
return
if self._loading_widget:
self._loading_widget.show_debounce_hint()
try:
while True:
elapsed = text_area.time_since_last_keystroke()
if elapsed >= debounce_s:
return
await asyncio.sleep(debounce_s - elapsed)
finally:
if self._loading_widget:
self._loading_widget.hide_debounce_hint()
async def _approval_callback(
self,
tool: str,
@ -1264,6 +1305,7 @@ class VibeApp(App): # noqa: PLR0904
return (ApprovalResponse.YES, None)
async with self._user_interaction_lock:
await self._wait_for_typing_pause()
self._pending_approval = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
@ -1279,6 +1321,7 @@ class VibeApp(App): # noqa: PLR0904
question_args = cast(AskUserQuestionArgs, args)
async with self._user_interaction_lock:
await self._wait_for_typing_pause()
self._pending_question = asyncio.Future()
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
try:
@ -1620,12 +1663,9 @@ class VibeApp(App): # noqa: PLR0904
connector_registry is not None and connector_registry.connector_count > 0
)
if not mcp_servers and not has_connectors:
msg = (
"No MCP servers or connectors configured."
if self._connectors_enabled
else "No MCP servers configured."
await self._mount_and_scroll(
UserCommandMessage("No MCP servers or connectors configured.")
)
await self._mount_and_scroll(UserCommandMessage(msg))
return
if self._current_bottom_app == BottomApp.MCP:
@ -1854,9 +1894,6 @@ class VibeApp(App): # noqa: PLR0904
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
]
@ -1866,6 +1903,10 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.session_logger.resume_existing_session(
session.session_id, session_path
)
await self.agent_loop.hydrate_experiments_from_session()
current_system_messages = [
msg for msg in self.agent_loop.messages if msg.role == Role.system
]
self.agent_loop.messages.reset(current_system_messages + non_system_messages)
self._refresh_profile_widgets()
@ -2321,19 +2362,25 @@ class VibeApp(App): # noqa: PLR0904
def _handle_approval_app_escape(self) -> None:
try:
approval_app = self.query_one(ApprovalApp)
approval_app.action_reject()
if not approval_app.is_within_grace_period():
approval_app.action_reject()
self.agent_loop.telemetry_client.send_user_cancelled_action(
"reject_approval"
)
except Exception:
pass
self.agent_loop.telemetry_client.send_user_cancelled_action("reject_approval")
self._last_escape_time = None
def _handle_question_app_escape(self) -> None:
try:
question_app = self.query_one(QuestionApp)
question_app.action_cancel()
if not question_app.is_within_grace_period():
question_app.action_cancel()
self.agent_loop.telemetry_client.send_user_cancelled_action(
"cancel_question"
)
except Exception:
pass
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
self._last_escape_time = None
def _handle_model_picker_app_escape(self) -> None:
@ -2869,7 +2916,9 @@ class VibeApp(App): # noqa: PLR0904
content = load_whats_new_content()
if content is not None:
whats_new_message = WhatsNewMessage(content)
plan_offer = plan_offer_cta(self._plan_info)
plan_offer = plan_offer_cta(
self._plan_info, console_base_url=self.config.console_base_url
)
if plan_offer is not None:
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
if self._history_widget_indices:
@ -3024,6 +3073,13 @@ class VibeApp(App): # noqa: PLR0904
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(True)
def action_open_plan_in_editor(self) -> None:
if self.event_handler is None:
return
if plan_file_message := self.event_handler.plan_file_message:
plan_file_message.open_in_editor()
def action_suspend_with_message(self) -> None:
if WINDOWS or self._driver is None or not self._driver.can_suspend:
return
@ -3053,7 +3109,7 @@ def run_textual_ui(
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway()
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
with stderr_guard():
app = VibeApp(
@ -3065,4 +3121,6 @@ def run_textual_ui(
)
session_id = app.run()
print_session_resume_message(session_id, agent_loop.stats)
print_session_resume_message(
session_id, agent_loop.stats, agent_loop.config.session_logging
)

View file

@ -248,6 +248,7 @@ Markdown {
margin-top: 1;
width: 100%;
height: auto;
padding-left: 2;
Markdown {
width: 100%;
@ -325,6 +326,35 @@ Markdown {
width: 100%;
}
.plan-file-message {
margin-top: 1;
margin-bottom: 0;
height: auto;
width: 100%;
.plan-file-wrapper {
height: auto;
width: 100%;
border: solid ansi_bright_black;
padding: 0 1;
}
Markdown {
width: 100%;
height: auto;
padding: 0;
margin: 0;
& > MarkdownBlock:first-child {
margin: 0 0 1 0;
}
& > MarkdownBlock:last-child {
margin: 1 0 0 0;
}
}
}
.interrupt-container,
.error-container,
.warning-container,
@ -669,7 +699,12 @@ StatusMessage {
width: auto;
height: auto;
color: ansi_bright_black;
}
.loading-debounce {
width: auto;
height: auto;
margin-left: 1;
}
.history-load-more-message {
@ -877,10 +912,6 @@ StatusMessage {
height: auto;
}
#question-content.question-content-docked {
dock: bottom;
}
.question-tabs {
height: auto;
color: ansi_blue;
@ -935,34 +966,19 @@ StatusMessage {
margin-top: 1;
}
.question-footer-note {
height: auto;
color: ansi_bright_black;
text-style: italic;
margin-top: 1;
}
.question-help {
height: auto;
color: ansi_bright_black;
margin-top: 1;
}
.question-content-preview {
width: 100%;
height: auto;
max-height: 50vh;
border: none;
border-left: wide ansi_bright_black;
padding: 0 0 0 1;
margin-bottom: 1;
}
.question-content-preview-text {
width: 100%;
height: auto;
color: ansi_default;
padding: 0;
margin: 0;
& > MarkdownBlock:last-child {
margin-bottom: 0;
}
}
ExpandingBorder {
width: auto;
height: 100%;

View file

@ -16,15 +16,19 @@ class ExternalEditor:
def get_editor() -> str:
return os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
@classmethod
def edit_file(cls, file_path: Path, *, check: bool = False) -> None:
editor = cls.get_editor()
parts = shlex.split(editor)
subprocess.run([*parts, str(file_path)], check=check)
def edit(self, initial_content: str = "") -> str | None:
editor = self.get_editor()
fd, filepath = tempfile.mkstemp(suffix=".md", prefix="vibe_")
try:
with os.fdopen(fd, "w") as f:
f.write(initial_content)
parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True)
self.edit_file(Path(filepath), check=True)
content = read_safe(Path(filepath)).text.rstrip()
return content if content != initial_content else None

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING
from vibe.cli.textual_ui.widgets.compact import CompactMessage
@ -9,6 +10,7 @@ from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
HookRunContainer,
HookSystemMessageLine,
PlanFileMessage,
ReasoningMessage,
UserMessage,
)
@ -28,6 +30,8 @@ from vibe.core.types import (
BaseEvent,
CompactEndEvent,
CompactStartEvent,
PlanReviewEndedEvent,
PlanReviewRequestedEvent,
ReasoningEvent,
ToolCallEvent,
ToolResultEvent,
@ -57,6 +61,7 @@ class EventHandler:
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
self.current_streaming_reasoning: ReasoningMessage | None = None
self.plan_file_message: PlanFileMessage | None = None
self._hook_run_container: HookRunContainer | None = None
async def _handle_hook_event(
@ -85,7 +90,7 @@ class EventHandler:
if loading_widget:
loading_widget.set_status(DEFAULT_LOADING_STATUS)
async def handle_event(
async def handle_event( # noqa: PLR0912
self, event: BaseEvent, loading_widget: LoadingWidget | None = None
) -> ToolCallMessage | None:
match event:
@ -117,6 +122,10 @@ class EventHandler:
await self.mount_callback(UserMessage(event.content))
case HookEvent():
await self._handle_hook_event(event, loading_widget)
case PlanReviewRequestedEvent():
await self._handle_start_plan_review(file_path=event.file_path)
case PlanReviewEndedEvent():
self._handle_stop_plan_review()
case WaitingForInputEvent():
await self.finalize_streaming()
case _:
@ -245,3 +254,16 @@ class EventHandler:
if self.current_compact:
self.current_compact.stop_spinning(success=False)
self.current_compact = None
async def _handle_start_plan_review(self, file_path: Path) -> None:
file_path.touch()
msg = PlanFileMessage(file_path=file_path)
self.plan_file_message = msg
await self.mount_callback(msg)
def _handle_stop_plan_review(self) -> None:
if self.plan_file_message is None:
return
self.plan_file_message.stop_watching()
self.plan_file_message = None

View file

@ -2,6 +2,8 @@ from __future__ import annotations
from rich import print as rprint
from vibe.core.config import SessionLoggingConfig
from vibe.core.session import last_session_pointer
from vibe.core.types import AgentStats
@ -14,10 +16,14 @@ def format_session_usage(stats: AgentStats) -> str:
)
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
def print_session_resume_message(
session_id: str | None, stats: AgentStats, session_logging: SessionLoggingConfig
) -> None:
if not session_id:
return
last_session_pointer.record(session_logging, session_id)
print()
print(format_session_usage(stats))
print()

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import time
from typing import ClassVar
from pydantic import BaseModel
@ -15,6 +16,8 @@ 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
_INPUT_GRACE_PERIOD_S = 0.5
class ApprovalApp(Container):
can_focus = True
@ -88,6 +91,7 @@ class ApprovalApp(Container):
self.tool_info_container: Vertical | None = None
self.option_widgets: list[Static] = []
self.help_widget: Static | None = None
self._mount_time: float = 0.0
def compose(self) -> ComposeResult:
with Vertical(id="approval-options"):
@ -119,10 +123,14 @@ class ApprovalApp(Container):
return f"Permission for the {self.tool_name} tool"
async def on_mount(self) -> None:
self._mount_time = time.monotonic()
await self._update_tool_info()
self._update_options()
self.focus()
def is_within_grace_period(self) -> bool:
return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S
async def _update_tool_info(self) -> None:
if not self.tool_info_container:
return
@ -175,28 +183,29 @@ class ApprovalApp(Container):
self.selected_option = (self.selected_option + 1) % self.NUM_OPTIONS
self._update_options()
def _select_if_unguarded(self, option: int) -> None:
if self.is_within_grace_period():
return
self.selected_option = option
self._handle_selection(option)
def action_select(self) -> None:
self._handle_selection(self.selected_option)
self._select_if_unguarded(self.selected_option)
def action_select_1(self) -> None:
self.selected_option = 0
self._handle_selection(0)
self._select_if_unguarded(0)
def action_select_2(self) -> None:
self.selected_option = 1
self._handle_selection(1)
self._select_if_unguarded(1)
def action_select_3(self) -> None:
self.selected_option = 2
self._handle_selection(2)
self._select_if_unguarded(2)
def action_select_4(self) -> None:
self.selected_option = 3
self._handle_selection(3)
self._select_if_unguarded(3)
def action_reject(self) -> None:
self.selected_option = 3
self._handle_selection(3)
self._select_if_unguarded(3)
def _handle_selection(self, option: int) -> None:
match option:

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import time
from typing import Any, ClassVar, Literal
from textual import events
@ -75,6 +76,7 @@ class ChatTextArea(TextArea):
self._completion_manager: MultiCompletionManager | None = None
self._app_has_focus: bool = True
self._voice_manager = voice_manager
self._last_keystroke_time: float = 0.0
def on_blur(self, event: events.Blur) -> None:
if self._app_has_focus:
@ -137,8 +139,15 @@ class ChatTextArea(TextArea):
)
if should_intercept:
self._navigating_history = True
self.post_message(self.HistoryPrevious())
if (
self.text
and self.cursor_location != (0, 0)
and not history_loaded_and_cursor_unmoved
):
self.move_cursor((0, 0))
else:
self._navigating_history = True
self.post_message(self.HistoryPrevious())
return True
return False
@ -198,7 +207,12 @@ class ChatTextArea(TextArea):
return False
def time_since_last_keystroke(self) -> float:
return time.monotonic() - self._last_keystroke_time
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
self._last_keystroke_time = time.monotonic()
if await self._handle_voice_key(event):
return

View file

@ -16,6 +16,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
DEFAULT_LOADING_STATUS = "Generating"
_DEBOUNCE_HINT_TEXT = "[dim italic]typing detected, waiting…[/]"
def _format_elapsed(seconds: int) -> str:
@ -84,6 +85,7 @@ class LoadingWidget(SpinnerMixin, Static):
self._status_widget: Static | None = None
self.hint_widget: Static | None = None
self._show_hint = show_hint
self.debounce_widget: Static | None = None
self.start_time: float | None = None
self._last_elapsed: int = -1
self._paused_total: float = 0.0
@ -112,6 +114,15 @@ class LoadingWidget(SpinnerMixin, Static):
def _apply_easter_egg(self, status: str) -> str:
return self._get_easter_egg() or status
def show_debounce_hint(self) -> None:
if self.debounce_widget:
self.debounce_widget.update(_DEBOUNCE_HINT_TEXT)
self.debounce_widget.display = True
def hide_debounce_hint(self) -> None:
if self.debounce_widget:
self.debounce_widget.display = False
def pause_timer(self) -> None:
if self._pause_start is None:
self._pause_start = time()
@ -142,6 +153,10 @@ class LoadingWidget(SpinnerMixin, Static):
)
yield self.hint_widget
self.debounce_widget = Static("", classes="loading-debounce")
self.debounce_widget.display = False
yield self.debounce_widget
def on_mount(self) -> None:
self.start_time = time()
self._update_animation()

View file

@ -16,7 +16,7 @@ from textual.worker import Worker
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import ConnectorConfig
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.tools.mcp_settings import updated_tool_list
@ -44,7 +44,7 @@ def collect_mcp_tool_index(
registered = tool_manager.registered_tools
available = tool_manager.available_tools
configured_servers = {server.name for server in mcp_servers}
connector_set = set(connector_names) if connectors_enabled() else set()
connector_set = set(connector_names)
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
connector_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
@ -410,7 +410,7 @@ class MCPApp(Container):
def _show_list_view(self, option_list: OptionList, index: MCPToolIndex) -> None:
self._viewing_server = None
self._viewing_kind = None
has_connectors = connectors_enabled() and bool(self._connector_names)
has_connectors = bool(self._connector_names)
title = "MCP Servers & Connectors" if has_connectors else "MCP Servers"
self.query_one("#mcp-title", NoMarkupStatic).update(title)
self._set_help_text(_LIST_VIEW_HELP_TOOLS)
@ -424,12 +424,9 @@ class MCPApp(Container):
option_list.add_option(Option(Text("", no_wrap=True), disabled=True))
self._list_connectors(option_list=option_list, index=index)
if not has_servers and not has_connectors:
empty_msg = (
"No MCP servers or connectors configured"
if connectors_enabled()
else "No MCP servers configured"
option_list.add_option(
Option("No MCP servers or connectors configured", disabled=True)
)
option_list.add_option(Option(empty_msg, disabled=True))
if has_servers or has_connectors:
# Skip disabled header options (e.g. section labels).

View file

@ -1,16 +1,25 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from vibe.core.hooks.models import HookMessageSeverity
from vibe.core.logger import logger
from vibe.core.utils.io import read_safe_async
if TYPE_CHECKING:
from vibe.cli.textual_ui.app import ChatScroll
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Static
from textual.widgets._markdown import MarkdownStream
from watchfiles import awatch
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
@ -443,3 +452,60 @@ class WarningMessage(Static):
if self._show_border:
yield ExpandingBorder(classes="warning-border")
yield NoMarkupStatic(self._message, classes="warning-content")
class PlanFileMessage(Widget):
content: reactive[str] = reactive("")
def __init__(self, file_path: Path) -> None:
super().__init__()
self.add_class("plan-file-message")
self._file_path = file_path
self._watch_task: asyncio.Task | None = None
def compose(self) -> ComposeResult:
with Vertical(classes="plan-file-wrapper"):
yield Markdown(self.content, classes="plan-file-content")
def watch_content(self, new_content: str) -> None:
try:
self.query_one(Markdown).update(new_content)
except NoMatches:
pass
async def on_mount(self) -> None:
self.content = (await read_safe_async(self._file_path)).text
self._watch_task = asyncio.create_task(self._watch_file())
async def _watch_file(self) -> None:
try:
async for _ in awatch(self._file_path):
self.content = (await read_safe_async(self._file_path)).text
except (asyncio.CancelledError, FileNotFoundError):
pass
def open_in_editor(self) -> None:
from vibe.cli.textual_ui.external_editor import ExternalEditor
try:
self._file_path.parent.mkdir(parents=True, exist_ok=True)
with self.app.suspend():
ExternalEditor.edit_file(self._file_path)
except OSError:
logger.warning(
"Failed to open plan file in editor: %s", self._file_path, exc_info=True
)
self.app.notify(
f"Could not open plan in editor: {self._file_path}",
severity="error",
timeout=6,
)
def stop_watching(self) -> None:
if self._watch_task is None:
return
if not self._watch_task.done():
self._watch_task.cancel()
self._watch_task = None

View file

@ -1,20 +1,22 @@
from __future__ import annotations
import itertools
import time
from typing import TYPE_CHECKING, ClassVar
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.containers import Container, Horizontal, Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Input
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.vscode_compat import VscodeCompatInput
_INPUT_GRACE_PERIOD_S = 0.5
if TYPE_CHECKING:
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
@ -66,6 +68,7 @@ class QuestionApp(Container):
self.submit_widget: NoMarkupStatic | None = None
self.help_widget: NoMarkupStatic | None = None
self.tabs_widget: NoMarkupStatic | None = None
self._mount_time: float = 0.0
@property
def _current_question(self) -> Question:
@ -110,16 +113,7 @@ class QuestionApp(Container):
)
def compose(self) -> ComposeResult:
if self.args.content_preview:
with VerticalScroll(classes="question-content-preview"):
yield AnsiMarkdown(
self.args.content_preview, classes="question-content-preview-text"
)
question_content_classes = (
"question-content-docked" if self.args.content_preview else ""
)
with Vertical(id="question-content", classes=question_content_classes):
with Vertical(id="question-content"):
if len(self.questions) > 1:
self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
yield self.tabs_widget
@ -147,13 +141,22 @@ class QuestionApp(Container):
self.submit_widget = NoMarkupStatic("", classes="question-submit")
yield self.submit_widget
if self.args.footer_note:
yield NoMarkupStatic(
self.args.footer_note, classes="question-footer-note"
)
self.help_widget = NoMarkupStatic("", classes="question-help")
yield self.help_widget
async def on_mount(self) -> None:
self._mount_time = time.monotonic()
self._update_display()
self.focus()
def is_within_grace_period(self) -> bool:
return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S
def _watch_current_question_idx(self) -> None:
self._update_display()
@ -361,6 +364,8 @@ class QuestionApp(Container):
self._switch_question(new_idx)
def action_select(self) -> None:
if self.is_within_grace_period():
return
if self._current_question.multi_select:
self._handle_multi_select_action()
else:
@ -415,6 +420,8 @@ class QuestionApp(Container):
self._switch_question(new_idx)
def action_cancel(self) -> None:
if self.is_within_grace_period():
return
self.post_message(self.Cancelled())
def on_input_submitted(self, _event: Input.Submitted) -> None:
@ -463,8 +470,11 @@ class QuestionApp(Container):
event.stop()
event.prevent_default()
self.selected_option = option_idx
if self.is_within_grace_period():
return True
self.selected_option = option_idx
if not (self._has_other and option_idx == self._other_option_idx):
self.action_select()
return True

View file

@ -23,7 +23,7 @@ from vibe.core.types import (
)
def _empty_session_metadata() -> dict[str, str]:
def _empty_session_metadata() -> dict[str, Any]:
return {}
@ -34,13 +34,13 @@ class TurnSummaryTracker(TurnSummaryPort):
model: ModelConfig,
on_summary: Callable[[TurnSummaryResult], None] | None = None,
max_tokens: int = 512,
session_metadata_getter: Callable[[], dict[str, str]] | None = None,
session_metadata_getter: Callable[[], dict[str, Any]] | None = None,
) -> None:
self._backend = backend
self._model = model
self._on_summary = on_summary
self._max_tokens = max_tokens
self._session_metadata_getter: Callable[[], dict[str, str]] = (
self._session_metadata_getter: Callable[[], dict[str, Any]] = (
_empty_session_metadata
if session_metadata_getter is None
else session_metadata_getter