Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Mert Unsal <mertunsal1905@gmail.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: maximevoisin-pm <maxime.voisin@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-05-27 17:10:40 +02:00 committed by GitHub
parent adb1ca74ce
commit cf3f4ca58f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
143 changed files with 3457 additions and 1351 deletions

View file

@ -11,13 +11,7 @@ import tomli_w
from vibe import __version__
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
MissingAPIKeyError,
MissingPromptFileError,
VibeConfig,
load_dotenv_values,
)
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.hooks.config import load_hooks_from_fs
from vibe.core.logger import logger
@ -44,8 +38,6 @@ def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
def get_initial_agent_name(args: argparse.Namespace, config: VibeConfig) -> str:
if args.prompt is not None and args.agent is None:
return BuiltinAgentName.AUTO_APPROVE
return args.agent or config.default_agent
@ -77,9 +69,6 @@ def load_config_or_exit(*, interactive: bool) -> VibeConfig:
sys.exit(1)
run_onboarding(entrypoint_metadata=_build_cli_entrypoint_metadata())
return VibeConfig.load()
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
sys.exit(1)
except ValueError as e:
rprint(f"[yellow]{e}[/]")
sys.exit(1)
@ -238,6 +227,7 @@ def run_cli(args: argparse.Namespace) -> None:
prompt=programmatic_prompt or "",
max_turns=args.max_turns,
max_price=args.max_price,
max_session_tokens=args.max_tokens,
output_format=output_format,
previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name,

View file

@ -114,7 +114,7 @@ class CommandRegistry:
),
"teleport": Command(
aliases=frozenset(["/teleport"]),
description="Teleport session to Vibe Code",
description="Teleport session to Vibe Code Web",
handler="_teleport_command",
is_available=CommandAvailabilityContext.is_teleport_available,
),

View file

@ -44,8 +44,9 @@ def parse_arguments() -> argparse.Namespace:
nargs="?",
const="",
metavar="TEXT",
help="Run in programmatic mode: send prompt, auto-approve all tools, "
"output response, and exit.",
help="Run in programmatic mode: send prompt, output response, and exit. "
"Tool approval follows the selected --agent (or 'default_agent' config); "
"pass --agent auto-approve for the previous YOLO behavior.",
)
parser.add_argument(
"--max-turns",
@ -61,6 +62,14 @@ def parse_arguments() -> argparse.Namespace:
help="Maximum cost in dollars (only applies in programmatic mode with -p). "
"Session will be interrupted if cost exceeds this limit.",
)
parser.add_argument(
"--max-tokens",
type=int,
metavar="N",
help="Maximum total prompt + completion tokens across the session "
"(only applies in programmatic mode with -p). "
"Session will be interrupted if usage exceeds this limit.",
)
parser.add_argument(
"--enabled-tools",
action="append",
@ -84,10 +93,10 @@ def parse_arguments() -> argparse.Namespace:
metavar="NAME",
default=None,
help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
"or custom from ~/.vibe/agents/NAME.toml). In interactive mode, "
"defaults to the 'default_agent' config setting. In programmatic "
"mode (-p/--prompt), defaults to auto-approve and 'default_agent' "
"is ignored.",
"or custom from ~/.vibe/agents/NAME.toml). Defaults to the "
"'default_agent' config setting in both interactive and programmatic "
"(-p/--prompt) mode. Pass --agent auto-approve for non-interactive "
"automation that needs tools to run without approval.",
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
parser.add_argument(

View file

@ -44,6 +44,7 @@ from vibe.cli.plan_offer.decide_plan_offer import (
resolve_api_key_for_plan,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway, WhoAmIPlanType
from vibe.cli.terminal_detect import Terminal, detect_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.notifications import (
NotificationContext,
@ -73,13 +74,17 @@ from vibe.cli.textual_ui.widgets.loading import (
)
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp, MCPSourceKind
from vibe.cli.textual_ui.widgets.messages import (
VSCODE_EXTENSION_PROMO_WHATS_NEW_SUFFIX,
AssistantMessage,
BashOutputMessage,
ErrorMessage,
InterruptMessage,
SlashCommandMessage,
StreamingMessageBase,
TeleportUserMessage,
UserCommandMessage,
UserMessage,
VscodeExtensionPromoMessage,
WarningMessage,
WhatsNewMessage,
)
@ -121,6 +126,12 @@ from vibe.cli.update_notifier import (
from vibe.cli.update_notifier.update import do_update
from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
from vibe.cli.vscode_extension_promo import (
FileSystemVscodeExtensionPromoRepository,
VscodeExtensionPromo,
VscodeExtensionPromoState,
should_show_promo,
)
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer
@ -186,6 +197,12 @@ from vibe.core.utils import (
is_dangerous_directory,
)
_VSCODE_FAMILY_TERMINALS = {Terminal.VSCODE, Terminal.VSCODE_INSIDERS, Terminal.CURSOR}
def _is_vscode_family_terminal() -> bool:
return detect_terminal() in _VSCODE_FAMILY_TERMINALS
def _compute_connector_counts(
config: VibeConfig, connector_registry: ConnectorRegistry | None
@ -362,6 +379,7 @@ class VibeApp(App): # noqa: PLR0904
terminal_notifier: NotificationPort | None = None,
voice_manager: VoiceManagerPort | None = None,
narrator_manager: NarratorManagerPort | None = None,
vscode_extension_promo: VscodeExtensionPromo | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -404,6 +422,12 @@ 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._vscode_extension_promo = vscode_extension_promo
self._show_vscode_extension_promo = (
vscode_extension_promo is not None
and _is_vscode_family_terminal()
and should_show_promo(vscode_extension_promo.initial_state)
)
self._configure_startup_options(startup)
self._last_escape_time: float | None = None
self._quit_manager = QuitManager(self)
@ -643,6 +667,9 @@ class VibeApp(App): # noqa: PLR0904
if self._agent_running:
await self._interrupt_agent_loop()
await self._dispatch_submitted_input(value)
async def _dispatch_submitted_input(self, value: str) -> None:
if value.startswith("!"):
self._bash_task = asyncio.create_task(self._handle_bash_command(value[1:]))
return
@ -956,7 +983,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(
cmd_name, "builtin"
)
await self._mount_and_scroll(UserMessage(user_input))
await self._mount_and_scroll(SlashCommandMessage(user_input[1:]))
handler = getattr(self, command.handler)
if asyncio.iscoroutinefunction(handler):
await handler(cmd_args=cmd_args)
@ -1510,7 +1537,7 @@ class VibeApp(App): # noqa: PLR0904
has_history = any(msg.role != Role.system for msg in self.agent_loop.messages)
if not value:
if show_message:
await self._mount_and_scroll(UserMessage("/teleport"))
await self._mount_and_scroll(SlashCommandMessage("teleport"))
if not has_history:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
@ -1526,7 +1553,7 @@ class VibeApp(App): # noqa: PLR0904
)
return
elif show_message:
await self._mount_and_scroll(UserMessage(value))
await self._mount_and_scroll(TeleportUserMessage(value))
self.run_worker(self._teleport(value), exclusive=False)
async def _teleport(self, prompt: str | None = None) -> None:
@ -2106,7 +2133,7 @@ class VibeApp(App): # noqa: PLR0904
messages_area = self._cached_messages_area or self.query_one("#messages")
await messages_area.remove_children()
await messages_area.mount(UserMessage("/clear"))
await messages_area.mount(SlashCommandMessage("clear"))
await self._mount_and_scroll(
UserCommandMessage("Conversation history cleared!")
)
@ -2986,23 +3013,45 @@ class VibeApp(App): # noqa: PLR0904
)
await self._mount_and_scroll(WarningMessage(warning, show_border=False))
async def _record_vscode_extension_promo_shown(self) -> None:
if self._vscode_extension_promo is None:
return
previous_count = (
self._vscode_extension_promo.initial_state.shown_count
if self._vscode_extension_promo.initial_state is not None
else 0
)
try:
await self._vscode_extension_promo.repository.set(
VscodeExtensionPromoState(shown_count=previous_count + 1)
)
except Exception:
logger.warning(
"Failed to persist VSCode extension promo shown count", exc_info=True
)
async def _check_and_show_whats_new(self) -> None:
if self._update_cache_repository is None:
await self._maybe_show_vscode_extension_promo()
return
if not await should_show_whats_new(
self._current_version, self._update_cache_repository
):
await self._maybe_show_vscode_extension_promo()
return
content = load_whats_new_content()
if content is not None:
whats_new_message = WhatsNewMessage(content)
body = content
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}")
body = f"{body}\n\n{plan_offer}"
if self._show_vscode_extension_promo:
body = f"{body}{VSCODE_EXTENSION_PROMO_WHATS_NEW_SUFFIX}"
whats_new_message = WhatsNewMessage(body)
if self._history_widget_indices:
whats_new_message.add_class("after-history")
messages_area = self._cached_messages_area or self.query_one("#messages")
@ -3012,8 +3061,28 @@ class VibeApp(App): # noqa: PLR0904
self._whats_new_message = whats_new_message
if should_anchor:
chat.anchor()
if self._show_vscode_extension_promo:
self.run_worker(
self._record_vscode_extension_promo_shown(), exclusive=False
)
else:
await self._maybe_show_vscode_extension_promo()
await mark_version_as_seen(self._current_version, self._update_cache_repository)
async def _maybe_show_vscode_extension_promo(self) -> None:
if not self._show_vscode_extension_promo:
return
promo_message = VscodeExtensionPromoMessage()
if self._history_widget_indices:
promo_message.add_class("after-history")
messages_area = self._cached_messages_area or self.query_one("#messages")
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
should_anchor = chat.is_at_bottom
await chat.mount(promo_message, after=messages_area)
if should_anchor:
chat.anchor()
self.run_worker(self._record_vscode_extension_promo_shown(), exclusive=False)
async def _resolve_plan(self) -> None:
if self._plan_offer_gateway is None:
self._plan_info = None
@ -3192,6 +3261,11 @@ def run_textual_ui(
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository()
vscode_extension_promo = VscodeExtensionPromo(
repository=vscode_extension_promo_repository,
initial_state=asyncio.run(vscode_extension_promo_repository.get()),
)
with stderr_guard():
app = VibeApp(
@ -3200,6 +3274,7 @@ def run_textual_ui(
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
vscode_extension_promo=vscode_extension_promo,
)
session_id = app.run()

View file

@ -154,33 +154,39 @@ OptionList, OptionList:focus {
height: auto;
width: 100%;
background: transparent;
border: solid $foreground-muted;
border: none;
border-top: solid $foreground-muted;
border-bottom: solid $foreground-muted;
border-title-align: right;
border-title-color: $text-muted;
padding: 0 1;
&.border-warning {
border: solid $warning;
border-top: solid $warning;
border-bottom: solid $warning;
border-title-color: $warning;
}
&.border-safe {
border: solid $success;
border-top: solid $success;
border-bottom: solid $success;
border-title-color: $success;
}
&.border-error {
border: solid $error;
border-top: solid $error;
border-bottom: solid $error;
border-title-color: $error;
}
&.border-recording {
border: solid $mistral_orange;
border-top: solid $mistral_orange;
border-bottom: solid $mistral_orange;
border-title-color: $mistral_orange;
}
&.border-remote {
border: solid $mistral_orange;
border-top: solid $mistral_orange;
border-bottom: solid $mistral_orange;
border-title-color: $mistral_orange;
}
}
@ -275,6 +281,12 @@ Markdown {
}
}
.user-message-wrapper {
margin-top: 1;
width: 100%;
height: auto;
}
.user-message-container {
width: 100%;
height: auto;
@ -282,7 +294,8 @@ Markdown {
.user-message-prompt {
width: auto;
height: 100%;
height: auto;
color: $mistral_orange;
text-style: bold;
}
@ -290,8 +303,22 @@ Markdown {
width: 1fr;
height: auto;
text-style: bold;
padding-left: 1;
border-left: heavy $mistral_orange;
}
.slash-command-message {
.user-message-content {
color: $mistral_orange;
}
}
.user-message-separator {
width: 100%;
height: 1;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.assistant-message {
@ -1135,7 +1162,7 @@ NarratorStatus {
#banner-container {
align: left middle;
padding: 0 1 0 0;
padding: 1 1 0 0;
width: auto;
}
@ -1231,6 +1258,33 @@ NarratorStatus {
margin-top: 1;
}
.vscode-extension-promo-message {
border-left: heavy $mistral_orange;
margin-bottom: 1;
Markdown {
padding: 0 0 0 1;
margin: 0;
& > MarkdownBlock:first-child {
margin-top: 0;
}
& > MarkdownBlock:last-child {
margin-bottom: 0;
}
}
link-style: none;
link-background-hover: transparent;
link-style-hover: underline;
link-color-hover: $mistral_orange;
}
.vscode-extension-promo-message.after-history {
margin-top: 1;
}
#sessionpicker-app {
width: 100%;
height: auto;

View file

@ -263,9 +263,8 @@ class ChatInputContainer(Vertical):
return SAFETY_BORDER_CLASSES.get(self._safety, "")
def _get_border_title(self) -> str:
if self._custom_border_label is not None:
return self._custom_border_label
return self._agent_name
label = self._custom_border_label or self._agent_name
return f" {label} " if label else ""
def _apply_input_box_chrome(self) -> None:
try:

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, ClassVar, cast
from vibe.core.hooks.models import HookMessageSeverity
from vibe.core.logger import logger
@ -47,7 +47,20 @@ class ExpandingBorder(NonSelectableStatic):
self.refresh()
# Mimic a border bottom with this component in order to have dimmed colors in ANSI themes
# Move back to border when Textual supports dimmed borders or foreground-muted in ANSI themes
class ExpandingSeparator(NonSelectableStatic):
def render(self) -> str:
return "" * max(self.size.width, 1)
def on_resize(self) -> None:
self.refresh()
class UserMessage(Static):
PROMPT_CHAR: ClassVar[str] = ">"
SHOW_SEPARATOR: ClassVar[bool] = True
def __init__(
self, content: str, pending: bool = False, message_index: int | None = None
) -> None:
@ -61,10 +74,16 @@ class UserMessage(Static):
return self._content
def compose(self) -> ComposeResult:
with Horizontal(classes="user-message-container"):
yield NoMarkupStatic(self._content, classes="user-message-content")
if self._pending:
self.add_class("pending")
with Vertical(classes="user-message-wrapper"):
with Horizontal(classes="user-message-container"):
yield NonSelectableStatic(
f"{self.PROMPT_CHAR} ", classes="user-message-prompt"
)
yield NoMarkupStatic(self._content, classes="user-message-content")
if self.SHOW_SEPARATOR:
yield ExpandingSeparator(classes="user-message-separator")
if self._pending:
self.add_class("pending")
async def set_pending(self, pending: bool) -> None:
if pending == self._pending:
@ -79,6 +98,19 @@ class UserMessage(Static):
self.remove_class("pending")
class SlashCommandMessage(UserMessage):
PROMPT_CHAR = "/"
SHOW_SEPARATOR = False
def __init__(self, content: str) -> None:
super().__init__(content)
self.add_class("slash-command-message")
class TeleportUserMessage(UserMessage):
PROMPT_CHAR = "&"
class StreamingMessageBase(Static):
def __init__(self, content: str) -> None:
super().__init__()
@ -247,6 +279,15 @@ class UserCommandMessage(Static):
yield Markdown(self._content)
VSCODE_EXTENSION_URI = "vscode:extension/mistralai.vibe-code"
VSCODE_EXTENSION_LINK_LABEL = "VS Code extension"
VSCODE_EXTENSION_PROMO_STANDALONE = f"We now have a [{VSCODE_EXTENSION_LINK_LABEL}]({VSCODE_EXTENSION_URI}) with a rich UI. Check it out!"
VSCODE_EXTENSION_PROMO_WHATS_NEW_SUFFIX = (
f"\n\n_Btw, we also have a new [{VSCODE_EXTENSION_LINK_LABEL}]"
f"({VSCODE_EXTENSION_URI}). Check it out!_"
)
class WhatsNewMessage(Static):
def __init__(self, content: str) -> None:
super().__init__()
@ -257,6 +298,16 @@ class WhatsNewMessage(Static):
yield Markdown(self._content)
class VscodeExtensionPromoMessage(Static):
def __init__(self, content: str = VSCODE_EXTENSION_PROMO_STANDALONE) -> None:
super().__init__()
self.add_class("vscode-extension-promo-message")
self._content = content
def compose(self) -> ComposeResult:
yield Markdown(self._content)
class InterruptMessage(Static):
def __init__(self) -> None:
super().__init__()

View file

@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage):
if self._error:
return f"Teleport failed: {self._error}"
if self._final_url:
return f"Teleported to Vibe Code: {self._final_url}"
return f"Teleported to Vibe Code Web: {self._final_url}"
return self._status
def set_status(self, status: str) -> None:

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from vibe.cli.vscode_extension_promo._port import (
VscodeExtensionPromoRepository,
VscodeExtensionPromoState,
)
from vibe.cli.vscode_extension_promo.adapters.filesystem_repository import (
FileSystemVscodeExtensionPromoRepository,
)
__all__ = [
"MAX_SHOWN_COUNT",
"PROMO_START",
"FileSystemVscodeExtensionPromoRepository",
"VscodeExtensionPromo",
"VscodeExtensionPromoRepository",
"VscodeExtensionPromoState",
"should_show_promo",
]
MAX_SHOWN_COUNT = 10
PROMO_START = datetime(2026, 5, 28, 16, 0, tzinfo=UTC)
def should_show_promo(
state: VscodeExtensionPromoState | None, now: datetime | None = None
) -> bool:
current = now or datetime.now(UTC)
if current < PROMO_START:
return False
if state is None:
return True
return state.shown_count < MAX_SHOWN_COUNT
@dataclass(frozen=True, slots=True)
class VscodeExtensionPromo:
repository: VscodeExtensionPromoRepository
initial_state: VscodeExtensionPromoState | None

View file

@ -0,0 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True, slots=True)
class VscodeExtensionPromoState:
shown_count: int
class VscodeExtensionPromoRepository(Protocol):
async def get(self) -> VscodeExtensionPromoState | None: ...
async def set(self, state: VscodeExtensionPromoState) -> None: ...

View file

@ -0,0 +1,43 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from vibe.cli.cache import read_cache, write_cache
from vibe.cli.vscode_extension_promo._port import (
VscodeExtensionPromoRepository,
VscodeExtensionPromoState,
)
from vibe.core.paths import VIBE_HOME
_CACHE_SECTION = "vscode_extension_promo"
class FileSystemVscodeExtensionPromoRepository(VscodeExtensionPromoRepository):
def __init__(self, base_path: Path | str | None = None) -> None:
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
self._cache_file = self._base_path / "cache.toml"
async def get(self) -> VscodeExtensionPromoState | None:
data = await asyncio.to_thread(self._read_section)
if data is None:
return None
shown_count = data.get("shown_count")
if not isinstance(shown_count, int):
return None
return VscodeExtensionPromoState(shown_count=shown_count)
async def set(self, state: VscodeExtensionPromoState) -> None:
await asyncio.to_thread(
write_cache,
self._cache_file,
_CACHE_SECTION,
{"shown_count": state.shown_count},
)
def _read_section(self) -> dict | None:
cache = read_cache(self._cache_file)
section = cache.get(_CACHE_SECTION)
if isinstance(section, dict):
return section
return None