Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com>
Co-authored-by: Antoine W <antoine.wronka@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-03-09 19:28:09 +01:00 committed by GitHub
parent 5d2e01a6d7
commit dd372ce494
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 2086 additions and 596 deletions

View file

@ -151,6 +151,7 @@ def run_cli(args: argparse.Namespace) -> None:
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
config.disabled_tools = [*config.disabled_tools, "ask_user_question"]
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
print(

View file

@ -34,13 +34,7 @@ class HttpWhoAmIGateway:
raise WhoAmIGatewayError(f"Unexpected status {response.status_code}")
payload = _safe_json(response) or {}
return WhoAmIResponse(
is_pro_plan=_parse_bool(payload.get("is_pro_plan")),
advertise_pro_plan=_parse_bool(payload.get("advertise_pro_plan")),
prompt_switching_to_pro_plan=_parse_bool(
payload.get("prompt_switching_to_pro_plan")
),
)
return WhoAmIResponse.from_payload(payload)
def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
@ -49,19 +43,3 @@ def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
except ValueError:
return None
return cast(Mapping[str, object], data) if isinstance(data, dict) else None
def _parse_bool(value: object | None) -> bool:
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, str):
match value.strip().lower():
case "true":
return True
case "false":
return False
case _:
raise WhoAmIGatewayError("Invalid boolean string in whoami response")
raise WhoAmIGatewayError("Invalid boolean value in whoami response")

View file

@ -1,6 +1,5 @@
from __future__ import annotations
from enum import StrEnum
import logging
from os import getenv
@ -8,6 +7,7 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGateway,
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIPlanType,
WhoAmIResponse,
)
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
@ -19,51 +19,50 @@ UPGRADE_URL = CONSOLE_CLI_URL
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
class PlanOfferAction(StrEnum):
NONE = "none"
UPGRADE = "upgrade"
SWITCH_TO_PRO_KEY = "switch_to_pro_key"
class PlanInfo:
plan_type: WhoAmIPlanType
plan_name: str
prompt_switching_to_pro_plan: bool
def __init__(
self,
plan_type: WhoAmIPlanType,
plan_name: str = "",
prompt_switching_to_pro_plan: bool = False,
) -> None:
self.plan_type = plan_type
self.plan_name = plan_name
self.prompt_switching_to_pro_plan = prompt_switching_to_pro_plan
@classmethod
def from_response(cls, response: WhoAmIResponse) -> PlanInfo:
return cls(
plan_type=response.plan_type,
plan_name=response.plan_name,
prompt_switching_to_pro_plan=response.prompt_switching_to_pro_plan,
)
def is_paid_api_plan(self) -> bool:
return self.plan_type == WhoAmIPlanType.API and not self.is_free_api_plan()
def is_free_api_plan(self) -> bool:
return self.plan_type == WhoAmIPlanType.API and "FREE" in self.plan_name.upper()
def is_chat_pro_plan(self) -> bool:
return self.plan_type == WhoAmIPlanType.CHAT
ACTION_TO_URL: dict[PlanOfferAction, str] = {
PlanOfferAction.UPGRADE: UPGRADE_URL,
PlanOfferAction.SWITCH_TO_PRO_KEY: SWITCH_TO_PRO_KEY_URL,
}
class PlanType(StrEnum):
FREE = "free"
PRO = "pro"
UNKNOWN = "unknown"
async def decide_plan_offer(
api_key: str | None, gateway: WhoAmIGateway
) -> tuple[PlanOfferAction, PlanType]:
async def decide_plan_offer(api_key: str | None, gateway: WhoAmIGateway) -> PlanInfo:
if not api_key:
return PlanOfferAction.UPGRADE, PlanType.FREE
return PlanInfo(WhoAmIPlanType.UNKNOWN)
try:
response = await gateway.whoami(api_key)
return PlanInfo.from_response(response)
except WhoAmIGatewayUnauthorized:
return PlanOfferAction.UPGRADE, PlanType.FREE
return PlanInfo(WhoAmIPlanType.UNAUTHORIZED)
except WhoAmIGatewayError:
logger.warning("Failed to fetch plan status.", exc_info=True)
return PlanOfferAction.NONE, PlanType.UNKNOWN
return _action_and_plan_from_response(response)
def _action_and_plan_from_response(
response: WhoAmIResponse,
) -> tuple[PlanOfferAction, PlanType]:
match response:
case WhoAmIResponse(is_pro_plan=True):
return PlanOfferAction.NONE, PlanType.PRO
case WhoAmIResponse(prompt_switching_to_pro_plan=True):
return PlanOfferAction.SWITCH_TO_PRO_KEY, PlanType.PRO
case WhoAmIResponse(advertise_pro_plan=True):
return PlanOfferAction.UPGRADE, PlanType.FREE
case _:
return PlanOfferAction.NONE, PlanType.UNKNOWN
return PlanInfo(WhoAmIPlanType.UNKNOWN)
def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
@ -75,13 +74,22 @@ def resolve_api_key_for_plan(provider: ProviderConfig) -> str | None:
return getenv(api_env_key)
def plan_offer_cta(action: PlanOfferAction) -> str | None:
if action is PlanOfferAction.NONE:
def plan_offer_cta(payload: PlanInfo | None) -> str | None:
if not payload:
return
url = ACTION_TO_URL[action]
match action:
case PlanOfferAction.UPGRADE:
text = f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({url})"
case PlanOfferAction.SWITCH_TO_PRO_KEY:
text = f"### Switch to your [Le Chat Pro API key]({url})"
return text
if payload.prompt_switching_to_pro_plan:
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
if payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}:
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
def plan_title(payload: PlanInfo | None) -> str | None:
if not payload:
return None
if payload.is_chat_pro_plan():
return "[Subscription] Pro"
if payload.is_free_api_plan():
return "[API] Experiment plan"
if payload.is_paid_api_plan():
return "[API] Scale plan"
return None

View file

@ -1,15 +1,58 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
from pydantic import TypeAdapter, ValidationError
class WhoAmIPlanType(StrEnum):
API = "API"
CHAT = "CHAT"
UNKNOWN = "UNKNOWN"
UNAUTHORIZED = "UNAUTHORIZED"
@classmethod
def from_string(cls, value: str) -> WhoAmIPlanType:
try:
return cls(value.strip().upper())
except ValueError:
return cls.UNKNOWN
@dataclass(frozen=True, slots=True)
class WhoAmIResponse:
is_pro_plan: bool
advertise_pro_plan: bool
plan_type: WhoAmIPlanType
plan_name: str
prompt_switching_to_pro_plan: bool
@classmethod
def from_payload(cls, payload: Mapping[str, object]) -> WhoAmIResponse:
plan_type = payload.get("plan_type")
plan_name = payload.get("plan_name")
if not isinstance(plan_type, str) or not isinstance(plan_name, str):
raise WhoAmIGatewayError(f"Invalid whoami response: {payload}")
return cls(
plan_type=WhoAmIPlanType.from_string(plan_type),
plan_name=plan_name.strip(),
prompt_switching_to_pro_plan=_parse_bool(
payload.get("prompt_switching_to_pro_plan")
),
)
def _parse_bool(value: object | None) -> bool:
if value is None:
return False
try:
return TypeAdapter(bool).validate_python(value)
except ValidationError as e:
raise WhoAmIGatewayError(
f"Invalid boolean value in whoami response: {value}"
) from e
class WhoAmIGatewayUnauthorized(Exception):
pass

View file

@ -25,12 +25,13 @@ from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import (
PlanType,
PlanInfo,
decide_plan_offer,
plan_offer_cta,
plan_title,
resolve_api_key_for_plan,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway, WhoAmIPlanType
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.notifications import (
@ -89,7 +90,7 @@ from vibe.cli.update_notifier.update import do_update
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.config import Backend, VibeConfig
from vibe.core.logger import logger
from vibe.core.paths.config_paths import HISTORY_FILE
from vibe.core.session.session_loader import SessionLoader
@ -270,6 +271,7 @@ class VibeApp(App): # noqa: PLR0904
self._cached_chat: ChatScroll | None = None
self._cached_loading_area: Widget | None = None
self._switch_agent_generation = 0
self._plan_info: PlanInfo | None = None
@property
def config(self) -> VibeConfig:
@ -319,7 +321,7 @@ class VibeApp(App): # noqa: PLR0904
def update_context_progress(stats: AgentStats) -> None:
context_progress.tokens = TokenState(
max_tokens=self.config.auto_compact_threshold,
max_tokens=self.config.get_active_model().auto_compact_threshold,
current_tokens=stats.context_tokens,
)
@ -332,6 +334,7 @@ class VibeApp(App): # noqa: PLR0904
chat_input_container = self.query_one(ChatInputContainer)
chat_input_container.focus_input()
await self._resolve_plan()
await self._show_dangerous_directory_warning()
await self._resume_history_from_messages()
await self._check_and_show_whats_new()
@ -728,10 +731,7 @@ class VibeApp(App): # noqa: PLR0904
message = str(e)
if isinstance(e, RateLimitError):
if self.plan_type == PlanType.FREE:
message = "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
else:
message = "Rate limits exceeded. Please wait a moment before trying again."
message = self._rate_limit_message()
await self._mount_and_scroll(
ErrorMessage(message, collapsed=self._tools_collapsed)
@ -748,6 +748,15 @@ class VibeApp(App): # noqa: PLR0904
await self._refresh_windowing_from_history()
self._terminal_notifier.notify(NotificationContext.COMPLETE)
def _rate_limit_message(self) -> str:
upgrade_to_pro = self._plan_info and self._plan_info.plan_type in {
WhoAmIPlanType.API,
WhoAmIPlanType.UNAUTHORIZED,
}
if upgrade_to_pro:
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
return "Rate limits exceeded. Please wait a moment before trying again."
async def _teleport_command(self) -> None:
await self._handle_teleport_command(show_message=False)
@ -1003,9 +1012,14 @@ class VibeApp(App): # noqa: PLR0904
base_config = VibeConfig.load()
await self.agent_loop.reload_with_initial_messages(base_config=base_config)
await self._resolve_plan()
if self._banner:
self._banner.set_state(base_config, self.agent_loop.skill_manager)
self._banner.set_state(
base_config,
self.agent_loop.skill_manager,
plan_title(self._plan_info),
)
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
except Exception as e:
await self._mount_and_scroll(
@ -1198,6 +1212,7 @@ class VibeApp(App): # noqa: PLR0904
self._chat_input_container.disabled = False
self._chat_input_container.display = True
self._current_bottom_app = BottomApp.Input
self._refresh_profile_widgets()
self.call_after_refresh(self._chat_input_container.focus_input)
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if chat.is_at_bottom:
@ -1371,7 +1386,9 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_banner(self) -> None:
if self._banner:
self._banner.set_state(self.config, self.agent_loop.skill_manager)
self._banner.set_state(
self.config, self.agent_loop.skill_manager, plan_title(self._plan_info)
)
def _update_profile_widgets(self, profile: AgentProfile) -> None:
if self._chat_input_container:
@ -1460,7 +1477,7 @@ class VibeApp(App): # noqa: PLR0904
content = load_whats_new_content()
if content is not None:
whats_new_message = WhatsNewMessage(content)
plan_offer = await self._plan_offer_cta()
plan_offer = plan_offer_cta(self._plan_info)
if plan_offer is not None:
whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}")
if self._history_widget_indices:
@ -1474,23 +1491,21 @@ class VibeApp(App): # noqa: PLR0904
chat.anchor()
await mark_version_as_seen(self._current_version, self._update_cache_repository)
async def _plan_offer_cta(self) -> str | None:
self.plan_type = PlanType.UNKNOWN
async def _resolve_plan(self) -> None:
if self._plan_offer_gateway is None:
self._plan_info = None
return
try:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
api_key = resolve_api_key_for_plan(provider)
action, plan_type = await decide_plan_offer(
api_key, self._plan_offer_gateway
)
if provider.backend != Backend.MISTRAL:
self._plan_info = None
return
self.plan_type = plan_type
return plan_offer_cta(action)
api_key = resolve_api_key_for_plan(provider)
self._plan_info = await decide_plan_offer(api_key, self._plan_offer_gateway)
except Exception as exc:
logger.warning(
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True

View file

@ -840,6 +840,10 @@ StatusMessage {
height: auto;
}
#question-content.question-content-docked {
dock: bottom;
}
.question-tabs {
height: auto;
color: ansi_blue;
@ -900,6 +904,28 @@ StatusMessage {
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;
& > *:last-child {
margin-bottom: 0;
}
}
ExpandingBorder {
width: auto;
height: 100%;
@ -959,6 +985,10 @@ ContextProgress {
width: auto;
}
#banner-user-plan {
width: auto;
}
#banner-meta-counts {
width: auto;
}

View file

@ -21,6 +21,7 @@ class BannerState:
models_count: int = 0
mcp_servers_count: int = 0
skills_count: int = 0
plan_description: str | None = None
class Banner(Static):
@ -36,6 +37,7 @@ class Banner(Static):
models_count=len(config.models),
mcp_servers_count=len(config.mcp_servers),
skills_count=len(skill_manager.available_skills),
plan_description=None,
)
self._animated = not config.disable_welcome_banner_animation
@ -49,6 +51,7 @@ class Banner(Static):
yield NoMarkupStatic(" ", classes="banner-spacer")
yield NoMarkupStatic(f"v{__version__} · ", classes="banner-meta")
yield NoMarkupStatic("", id="banner-model")
yield NoMarkupStatic("", id="banner-user-plan")
with Horizontal(classes="banner-line"):
yield NoMarkupStatic("", id="banner-meta-counts")
with Horizontal(classes="banner-line"):
@ -64,17 +67,24 @@ class Banner(Static):
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
self._format_meta_counts()
)
self.query_one("#banner-user-plan", NoMarkupStatic).update(self._format_plan())
def freeze_animation(self) -> None:
if self._animated:
self.query_one(PetitChat).freeze_animation()
def set_state(self, config: VibeConfig, skill_manager: SkillManager) -> None:
def set_state(
self,
config: VibeConfig,
skill_manager: SkillManager,
plan_description: str | None = None,
) -> None:
self.state = BannerState(
active_model=config.active_model,
models_count=len(config.models),
mcp_servers_count=len(config.mcp_servers),
skills_count=len(skill_manager.available_skills),
plan_description=plan_description,
)
def _format_meta_counts(self) -> str:
@ -83,3 +93,10 @@ class Banner(Static):
f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}"
f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}"
)
def _format_plan(self) -> str:
return (
""
if self.state.plan_description is None
else f" · {self.state.plan_description}"
)

View file

@ -113,9 +113,10 @@ class ChatTextArea(TextArea):
self.get_full_text(), self._get_full_cursor_offset()
)
def _reset_prefix(self) -> None:
def _reset_prefix(self, *, clear_last_used: bool = True) -> None:
self._history_prefix = None
self._last_used_prefix = None
if clear_last_used:
self._last_used_prefix = None
def _mark_cursor_moved_if_needed(self) -> None:
if (
@ -124,7 +125,7 @@ class ChatTextArea(TextArea):
and self.cursor_location != self._cursor_pos_after_load
):
self._cursor_moved_since_load = True
self._reset_prefix()
self._reset_prefix(clear_last_used=False)
def _get_prefix_up_to_cursor(self) -> str:
cursor_row, cursor_col = self.cursor_location
@ -137,8 +138,17 @@ class ChatTextArea(TextArea):
return ""
def _handle_history_up(self) -> bool:
cursor_row, cursor_col = self.cursor_location
if cursor_row == 0:
_, cursor_col = self.cursor_location
history_loaded_and_cursor_unmoved = (
self._cursor_pos_after_load is not None
and not self._cursor_moved_since_load
)
should_intercept = (
self.navigator.is_first_wrapped_line(self.cursor_location)
or history_loaded_and_cursor_unmoved
)
if should_intercept:
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
self._reset_prefix()
self._last_cursor_col = 0
@ -151,26 +161,45 @@ class ChatTextArea(TextArea):
return True
return False
def _handle_history_down(self) -> bool:
cursor_row, cursor_col = self.cursor_location
total_lines = self.text.count("\n") + 1
def _is_on_loaded_history_entry(self) -> bool:
return self._cursor_pos_after_load is not None
on_first_line_unmoved = cursor_row == 0 and not self._cursor_moved_since_load
on_last_line = cursor_row == total_lines - 1
def _has_history_prefix(self) -> bool:
return self._history_prefix is not None
should_intercept = (
on_first_line_unmoved and self._history_prefix is not None
) or on_last_line
def _has_history_navigation_context(self) -> bool:
return self._is_on_loaded_history_entry() or self._has_history_prefix()
if not should_intercept:
def _should_intercept_history_down(self) -> bool:
history_loaded_and_cursor_unmoved = (
self._is_on_loaded_history_entry() and not self._cursor_moved_since_load
)
if history_loaded_and_cursor_unmoved and self._has_history_prefix():
return True
if not self.navigator.is_last_wrapped_line(self.cursor_location):
return False
return self._has_history_navigation_context()
def _handle_history_down(self) -> bool:
_, cursor_col = self.cursor_location
if not self._should_intercept_history_down():
return False
navigating_loaded_history = self._is_on_loaded_history_entry()
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
self._reset_prefix()
if not navigating_loaded_history:
self._reset_prefix()
self._last_cursor_col = 0
if self._history_prefix is None:
self._history_prefix = self._get_prefix_up_to_cursor()
if navigating_loaded_history and self._last_used_prefix is not None:
self._history_prefix = self._last_used_prefix
else:
self._history_prefix = self._get_prefix_up_to_cursor()
self._navigating_history = True
self.post_message(self.HistoryNext(self._history_prefix))
@ -238,6 +267,11 @@ class ChatTextArea(TextArea):
event.stop()
return
# Work around VS Code 1.110+ sending space as CSI u (\x1b[32u),
# which Textual parses as Key("space", character=None, is_printable=False).
if event.key == "space" and event.character is None:
event.character = " "
await super()._on_key(event)
self._mark_cursor_moved_if_needed()

View file

@ -6,11 +6,12 @@ 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
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
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
if TYPE_CHECKING:
@ -108,7 +109,16 @@ class QuestionApp(Container):
)
def compose(self) -> ComposeResult:
with Vertical(id="question-content"):
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):
if len(self.questions) > 1:
self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
yield self.tabs_widget