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

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.11.0"
__version__ = "2.11.1"

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncGenerator, Callable, Mapping
from contextlib import aclosing
from dataclasses import dataclass
from datetime import UTC
@ -125,6 +125,7 @@ from vibe.core.config import (
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.feedback import record_feedback_asked, should_show_feedback
from vibe.core.hooks.config import load_hooks_from_fs
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.proxy_setup import (
ProxySetupError,
parse_proxy_command,
@ -165,14 +166,18 @@ from vibe.core.utils import (
get_user_cancellation_message,
)
from vibe.setup.auth import (
AuthState,
AuthStateKind,
BrowserSignInAttempt,
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInService,
HttpBrowserSignInGateway,
assess_auth_state,
)
from vibe.setup.auth.api_key_persistence import (
persist_api_key,
remove_api_key,
resolve_api_key_provider,
)
from vibe.setup.onboarding.context import OnboardingContext
@ -205,6 +210,22 @@ class TelemetrySendNotification(BaseModel):
session_id: str = Field(validation_alias=AliasChoices("session_id", "sessionId"))
class AuthStatusResponse(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
authenticated: bool
auth_state: AuthStateKind = Field(alias="authState")
sign_out_available: bool = Field(alias="signOutAvailable")
def _auth_status_response_from_auth_state(auth_state: AuthState) -> AuthStatusResponse:
return AuthStatusResponse(
authenticated=auth_state.can_use_active_provider,
authState=auth_state.kind,
signOutAvailable=auth_state.sign_out_available,
)
def _dispatch_at_mention_inserted(
client: TelemetryClient, properties: dict[str, Any]
) -> None:
@ -250,6 +271,7 @@ RETRYABLE_BROWSER_SIGN_IN_COMPLETION_ERRORS = {
OnboardingContextLoader = Callable[[], OnboardingContext]
ApiKeyPersister = Callable[[ProviderConfig, str], str]
ApiKeyRemover = Callable[[ProviderConfig], None]
class BrowserSignInServiceAdapter(Protocol):
@ -274,10 +296,17 @@ class VibeAcpAgentLoop(AcpAgent):
onboarding_context_loader: OnboardingContextLoader | None = None,
browser_sign_in_service_factory: BrowserSignInServiceFactory | None = None,
api_key_persister: ApiKeyPersister = persist_api_key,
api_key_remover: ApiKeyRemover = remove_api_key,
environ_before_dotenv_load: Mapping[str, str] | None = None,
) -> None:
self.sessions: dict[str, AcpSessionLoop] = {}
self.client_capabilities: ClientCapabilities | None = None
self.client_info: Implementation | None = None
self._environ_before_dotenv_load = dict(
environ_before_dotenv_load
if environ_before_dotenv_load is not None
else os.environ
)
self._pending_browser_sign_in_attempts: dict[
str, PendingBrowserSignInAttempt
] = {}
@ -288,11 +317,12 @@ class VibeAcpAgentLoop(AcpAgent):
browser_sign_in_service_factory or self._build_browser_sign_in_service
)
self._persist_api_key = api_key_persister
self._remove_api_key = api_key_remover
def _build_browser_auth_method(
self, context: OnboardingContext, method_id: str
) -> AuthMethodAgent | None:
if not context.browser_sign_in_enabled:
if not context.supports_browser_sign_in:
return None
return AuthMethodAgent(
@ -340,7 +370,7 @@ class VibeAcpAgentLoop(AcpAgent):
def _load_enabled_browser_sign_in_context(self) -> OnboardingContext:
context = self._load_onboarding_context()
if not context.browser_sign_in_enabled:
if not context.supports_browser_sign_in:
raise InvalidRequestError(
"Browser sign-in is not available for the configured provider."
)
@ -1474,8 +1504,46 @@ class VibeAcpAgentLoop(AcpAgent):
)
return {}
def _assess_current_auth_state(self) -> tuple[ProviderConfig, AuthState]:
load_dotenv_values(env_path=GLOBAL_ENV_FILE.path)
provider = self._load_onboarding_context().provider
auth_state = assess_auth_state(
provider,
process_env_had_value_before_dotenv_load=bool(
provider.api_key_env_var
and self._environ_before_dotenv_load.get(provider.api_key_env_var)
),
)
return provider, auth_state
def _handle_auth_status(self) -> dict[str, Any]:
_, auth_state = self._assess_current_auth_state()
return _auth_status_response_from_auth_state(auth_state).model_dump(
mode="json", by_alias=True
)
def _handle_auth_sign_out(self) -> dict[str, Any]:
provider, auth_state = self._assess_current_auth_state()
if not auth_state.sign_out_available:
raise InvalidRequestError(
f"Sign out is not available for auth state: {auth_state.kind.value}"
)
try:
self._remove_api_key(provider)
except (OSError, ValueError) as exc:
raise InternalError(f"Failed to sign out: {exc}") from exc
return {}
@override
async def ext_method(self, method: str, params: dict) -> dict:
if method == "auth/status":
return self._handle_auth_status()
if method == "auth/signOut":
return self._handle_auth_sign_out()
if method == "session/set_title":
return await self._handle_session_set_title(params)
@ -1744,8 +1812,10 @@ class VibeAcpAgentLoop(AcpAgent):
SESSION_CLOSED_FLUSH_TIMEOUT_SECONDS = 1.0
def run_acp_server() -> None:
agent = VibeAcpAgentLoop()
def run_acp_server(
*, environ_before_dotenv_load: Mapping[str, str] | None = None
) -> None:
agent = VibeAcpAgentLoop(environ_before_dotenv_load=environ_before_dotenv_load)
install_sigterm_flush = TelemetryClient(config_getter=VibeConfig.load).is_active()
received_sigterm = False
previous_sigterm_handler = signal.getsignal(signal.SIGTERM)

View file

@ -84,6 +84,7 @@ def main() -> None:
from vibe.core.tracing import setup_tracing
from vibe.setup.onboarding import run_onboarding
environ_before_dotenv_load = os.environ.copy()
load_dotenv_values()
bootstrap_config_files()
args = parse_arguments()
@ -104,7 +105,7 @@ def main() -> None:
except Exception:
pass # tracing disabled
run_acp_server()
run_acp_server(environ_before_dotenv_load=environ_before_dotenv_load)
if __name__ == "__main__":

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

View file

@ -22,6 +22,7 @@ from pydantic import BaseModel
from vibe.cli.terminal_detect import detect_terminal
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.compaction import collect_prior_user_messages
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.experiments import ExperimentManager
from vibe.core.experiments.client import RemoteEvalClient
@ -53,6 +54,7 @@ from vibe.core.middleware import (
PriceLimitMiddleware,
ReadOnlyAgentMiddleware,
ResetReason,
TokenLimitMiddleware,
TurnLimitMiddleware,
make_plan_agent_reminder,
)
@ -237,6 +239,7 @@ class AgentLoop: # noqa: PLR0904
message_observer: Callable[[LLMMessage], None] | None = None,
max_turns: int | None = None,
max_price: float | None = None,
max_session_tokens: int | None = None,
backend: BackendLike | None = None,
enable_streaming: bool = False,
entrypoint_metadata: EntrypointMetadata | None = None,
@ -245,6 +248,7 @@ class AgentLoop: # noqa: PLR0904
headless: bool = False,
hook_config_result: HookConfigResult | None = None,
permission_store: PermissionStore | None = None,
mcp_registry: MCPRegistry | None = None,
) -> None:
self._base_config = config
self._headless = headless
@ -260,7 +264,7 @@ class AgentLoop: # noqa: PLR0904
self._permission_store = permission_store or PermissionStore()
self.mcp_registry = MCPRegistry()
self.mcp_registry = mcp_registry or MCPRegistry()
self.connector_registry = self._create_connector_registry()
self.agent_manager = AgentManager(
lambda: self._base_config,
@ -278,6 +282,7 @@ class AgentLoop: # noqa: PLR0904
self.message_observer = message_observer
self._max_turns = max_turns
self._max_price = max_price
self._max_session_tokens = max_session_tokens
self._plan_session = PlanSession()
self.format_handler = APIToolFormatHandler()
@ -738,6 +743,9 @@ class AgentLoop: # noqa: PLR0904
if self._max_price is not None:
self.middleware_pipeline.add(PriceLimitMiddleware(self._max_price))
if self._max_session_tokens is not None:
self.middleware_pipeline.add(TokenLimitMiddleware(self._max_session_tokens))
self.middleware_pipeline.add(AutoCompactMiddleware())
if self.config.context_warnings:
self.middleware_pipeline.add(ContextWarningMiddleware(0.5))
@ -1698,7 +1706,12 @@ class AgentLoop: # noqa: PLR0904
self.agent_profile,
)
summary_request = UtilityPrompt.COMPACT.read()
summary_prefix = UtilityPrompt.COMPACT_SUMMARY_PREFIX.read()
prior_user_messages = collect_prior_user_messages(
list(self.messages), summary_prefix
)
summary_request = self.config.compaction_prompt
if extra_instructions:
summary_request += (
f"\n\n## Additional Instructions\n{extra_instructions}"
@ -1717,11 +1730,14 @@ class AgentLoop: # noqa: PLR0904
raise AgentLoopLLMResponseError(
"Usage data missing in compaction summary response"
)
summary_content = summary_result.message.content or ""
summary_content = (summary_result.message.content or "").strip()
if not summary_content:
summary_content = "(no summary available)"
system_message = self.messages[0]
summary_message = LLMMessage(role=Role.user, content=summary_content)
self.messages.reset([system_message, summary_message])
wrapped_summary = f"{summary_prefix}\n{summary_content}"
summary_message = LLMMessage(role=Role.user, content=wrapped_summary)
self.messages.reset([system_message, *prior_user_messages, summary_message])
active_model = self.config.get_active_model()
await self._reset_session()
@ -1745,7 +1761,7 @@ class AgentLoop: # noqa: PLR0904
self.middleware_pipeline.reset(reset_reason=ResetReason.COMPACT)
return summary_content or ""
return summary_content
except Exception:
await self.session_logger.save_interaction(

45
vibe/core/compaction.py Normal file
View file

@ -0,0 +1,45 @@
from __future__ import annotations
from vibe.core.types import LLMMessage, Role
from vibe.core.utils.tokens import approx_token_count, truncate_middle_to_tokens
COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000
def collect_prior_user_messages(
messages: list[LLMMessage],
summary_prefix: str,
max_tokens: int = COMPACT_USER_MESSAGE_MAX_TOKENS,
) -> list[LLMMessage]:
"""Pick user messages to preserve through compaction.
Walks newest-first within a token budget, dropping system-internal
injections and prior compaction summaries, middle-truncating the message
that spills over. Returns kept messages in chronological order.
"""
candidates = [
m
for m in messages
if m.role == Role.user
and not m.injected
and m.content
and not m.content.startswith(summary_prefix)
]
selected: list[LLMMessage] = []
remaining = max_tokens
for m in reversed(candidates):
if remaining <= 0:
break
content = m.content or ""
cost = approx_token_count(content)
if cost <= remaining:
selected.append(LLMMessage(role=Role.user, content=content))
remaining -= cost
else:
truncated = truncate_middle_to_tokens(content, remaining)
selected.append(LLMMessage(role=Role.user, content=truncated))
remaining = 0
selected.reverse()
return selected

View file

@ -40,6 +40,7 @@ from vibe.core.config.layer import (
EmptyLayerError,
LayerImplementationError,
RawConfig,
TrustNotResolvedError,
TrustResolutionError,
UntrustedLayerError,
)
@ -113,6 +114,7 @@ __all__ = [
"TranscribeClient",
"TranscribeModelConfig",
"TranscribeProviderConfig",
"TrustNotResolvedError",
"TrustResolutionError",
"UntrustedLayerError",
"VibeConfig",

View file

@ -30,7 +30,7 @@ from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.paths import GLOBAL_ENV_FILE, SESSION_LOG_DIR
from vibe.core.prompts import load_system_prompt
from vibe.core.prompts import UtilityPrompt, load_prompt, load_system_prompt
from vibe.core.types import Backend
from vibe.core.utils import get_server_url_from_api_base
@ -512,6 +512,7 @@ class VibeConfig(BaseSettings):
enable_telemetry: bool = True
experiment_overrides: dict[str, str] = Field(default_factory=dict)
system_prompt_id: str = "cli"
compaction_prompt_id: str = "compact"
include_commit_signature: bool = True
include_model_info: bool = True
include_project_context: bool = True
@ -537,7 +538,6 @@ class VibeConfig(BaseSettings):
console_base_url: str = Field(default=DEFAULT_CONSOLE_BASE_URL, exclude=True)
enable_experimental_hooks: bool = Field(default=False, exclude=True)
enable_experimental_browser_sign_in: bool = Field(default=False, exclude=True)
providers: list[ProviderConfig] = Field(
default_factory=lambda: list(DEFAULT_PROVIDERS)
@ -634,10 +634,9 @@ class VibeConfig(BaseSettings):
default_agent: str = Field(
default=BuiltinAgentName.DEFAULT,
description=(
"Agent profile to use when no --agent flag is passed in interactive "
"mode. Builtin: default, plan, accept-edits, auto-approve. "
"Ignored in programmatic mode (-p/--prompt), which falls back to "
"auto-approve when --agent is not provided."
"Agent profile to use when no --agent flag is passed. "
"Builtin: default, plan, accept-edits, auto-approve. "
"Applies in both interactive and programmatic (-p/--prompt) mode."
),
)
skill_paths: list[Path] = Field(
@ -717,6 +716,14 @@ class VibeConfig(BaseSettings):
def system_prompt(self) -> str:
return load_system_prompt(self.system_prompt_id)
@property
def compaction_prompt(self) -> str:
return load_prompt(
self.compaction_prompt_id,
setting_name="compaction_prompt_id",
builtins={"compact": UtilityPrompt.COMPACT.path},
)
def get_active_model(self) -> ModelConfig:
for model in self.models:
if model.alias == self.active_model:
@ -933,6 +940,11 @@ class VibeConfig(BaseSettings):
_ = self.system_prompt
return self
@model_validator(mode="after")
def _check_compaction_prompt(self) -> VibeConfig:
_ = self.compaction_prompt
return self
def set_thinking(self, level: ThinkingLevel) -> None:
model = self.get_active_model()

View file

@ -36,6 +36,15 @@ class EmptyLayerError(ConfigLayerError):
super().__init__(layer_name, f"Layer '{layer_name}' has no data after load")
class TrustNotResolvedError(ConfigLayerError):
"""Raised when grant_trust/revoke_trust is called before trust has been resolved."""
def __init__(self, layer_name: str) -> None:
super().__init__(
layer_name, f"Layer '{layer_name}': trust has not been resolved yet"
)
class TrustResolutionError(ConfigLayerError):
"""Raised when trust status is not resolvable."""
@ -171,6 +180,9 @@ class ConfigLayer[S: BaseModel](ABC):
return new_state
async def _handle_grant_trust(self, state: _LayerState[S]) -> _LayerState[S]:
if state.is_trusted is None:
raise TrustNotResolvedError(self.name)
if state.is_trusted is True:
return state
@ -179,6 +191,9 @@ class ConfigLayer[S: BaseModel](ABC):
return _LayerState(is_trusted=True, data=state.data)
async def _handle_revoke_trust(self, state: _LayerState[S]) -> _LayerState[S]:
if state.is_trusted is None:
raise TrustNotResolvedError(self.name)
if state.is_trusted is False:
return state

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from vibe.core.config.layers.overrides import OverridesLayer
from vibe.core.config.layers.project import ProjectConfigLayer
from vibe.core.config.layers.user import UserConfigLayer
__all__ = ["UserConfigLayer"]
__all__ = ["OverridesLayer", "ProjectConfigLayer", "UserConfigLayer"]

View file

@ -0,0 +1,27 @@
from __future__ import annotations
import copy
from typing import Any
from vibe.core.config.layer import ConfigLayer, RawConfig
class OverridesLayer(ConfigLayer[RawConfig]):
"""Highest-priority layer wrapping an arbitrary dict passed at construction.
Always trusted and read-only.
Used by CLI and ACP entry points to inject runtime overrides.
"""
def __init__(self, *, data: dict[str, Any], name: str = "overrides") -> None:
super().__init__(name=name)
self._data = data
async def _check_trust(self) -> bool:
return True
async def _read_config(self) -> dict[str, Any]:
return copy.deepcopy(self._data)
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
raise NotImplementedError("OverridesLayer.apply() is not implemented (M2)")

View file

@ -0,0 +1,84 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import tomllib
from typing import Any
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.paths._vibe_home import VIBE_HOME
from vibe.core.trusted_folders import trusted_folders_manager
class ProjectConfigLayer(ConfigLayer[RawConfig]):
"""Reads a project-level TOML config file.
If no file is found in the current working directory, walks up parent directories
until a trusted .vibe/config.toml is found.
"""
def __init__(self, *, path: Path | None = None, name: str = "project-toml") -> None:
super().__init__(name=name)
self._root = path or Path.cwd()
self._config_file_path: Path | None = None
self._is_set = False
self._find_lock = asyncio.Lock()
@property
def config_file_path(self) -> Path | None:
return self._config_file_path
async def _check_trust(self) -> bool:
await self._find_config_file()
if self._config_file_path is None:
return True
return bool(trusted_folders_manager.is_trusted(self._config_file_path.parent))
async def _read_config(self) -> dict[str, Any]:
if self._config_file_path is None:
return {}
with self._config_file_path.open("rb") as f:
return tomllib.load(f)
async def _on_trust_changed(self, old: bool | None, new: bool | None) -> None:
if new is None or self._config_file_path is None:
return
if new:
trusted_folders_manager.add_trusted(self._config_file_path.parent)
else:
trusted_folders_manager.add_untrusted(self._config_file_path.parent)
async def grant_trust(self) -> None:
await self._find_config_file()
if self._config_file_path is None:
return
await super().grant_trust()
async def revoke_trust(self) -> None:
await self._find_config_file()
if self._config_file_path is None:
return
await super().revoke_trust()
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
raise NotImplementedError("ProjectConfigLayer.apply() is not implemented (M2)")
async def _find_config_file(self) -> None:
async with self._find_lock:
if self._is_set:
return
for directory in [self._root, *self._root.parents]:
if directory == VIBE_HOME.path.parent:
break
candidate = directory / ".vibe" / "config.toml"
if candidate.is_file():
self._config_file_path = candidate
break
self._is_set = True

View file

@ -78,6 +78,25 @@ class PriceLimitMiddleware:
pass
class TokenLimitMiddleware:
def __init__(self, max_tokens: int) -> None:
self.max_tokens = max_tokens
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
if context.stats.session_total_llm_tokens > self.max_tokens:
return MiddlewareResult(
action=MiddlewareAction.STOP,
reason=(
"Token limit exceeded: "
f"{context.stats.session_total_llm_tokens:,} > {self.max_tokens:,}"
),
)
return MiddlewareResult()
def reset(self, reset_reason: ResetReason = ResetReason.STOP) -> None:
pass
class AutoCompactMiddleware:
async def before_turn(self, context: ConversationContext) -> MiddlewareResult:
threshold = context.config.get_active_model().auto_compact_threshold

View file

@ -29,9 +29,10 @@ def run_programmatic( # noqa: PLR0913, PLR0917
prompt: str,
max_turns: int | None = None,
max_price: float | None = None,
max_session_tokens: int | None = None,
output_format: OutputFormat = OutputFormat.TEXT,
previous_messages: list[LLMMessage] | None = None,
agent_name: str = BuiltinAgentName.AUTO_APPROVE,
agent_name: str = BuiltinAgentName.DEFAULT,
client_metadata: ClientMetadata = _DEFAULT_CLIENT_METADATA,
teleport: bool = False,
headless: bool = False,
@ -45,6 +46,7 @@ def run_programmatic( # noqa: PLR0913, PLR0917
message_observer=formatter.on_message_added,
max_turns=max_turns,
max_price=max_price,
max_session_tokens=max_session_tokens,
enable_streaming=False,
headless=headless,
entrypoint_metadata=build_entrypoint_metadata(

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from enum import StrEnum, auto
from pathlib import Path
@ -30,48 +31,82 @@ class SystemPrompt(Prompt):
class UtilityPrompt(Prompt):
AGENTS_DOC = auto()
COMPACT = auto()
COMPACT_SUMMARY_PREFIX = auto()
DANGEROUS_DIRECTORY = auto()
PROJECT_CONTEXT = auto()
TURN_SUMMARY = auto()
class MissingPromptFileError(RuntimeError):
def __init__(self, system_prompt_id: str, *prompt_dirs: str) -> None:
dirs_str = " or ".join(prompt_dirs) if prompt_dirs else "<no prompt dirs>"
class MissingPromptFileError(ValueError):
def __init__(
self,
setting_name: str,
prompt_id: str,
builtin_ids: Iterable[str],
custom_dirs: Iterable[Path],
custom_ids: Iterable[str],
) -> None:
builtin_hint = ", ".join('"' + i + '"' for i in builtin_ids)
dirs_hint = " or ".join(str(d) for d in custom_dirs) or "<no prompt dirs>"
custom_hint = ", ".join('"' + i + '"' for i in custom_ids) or "<none>"
super().__init__(
f"Invalid system_prompt_id value: '{system_prompt_id}'. "
f"Must be one of the available prompts ({', '.join(p.name.lower() for p in SystemPrompt)}), "
f"or correspond to a .md file in {dirs_str}"
f"Invalid {setting_name} value: '{prompt_id}'. "
f"Must be one of the available prompts ({builtin_hint}), "
f"or correspond to a .md file in {dirs_hint} (available: {custom_hint})"
)
self.system_prompt_id = system_prompt_id
self.setting_name = setting_name
self.prompt_id = prompt_id
def _validate_prompt_id(prompt_id: str, setting_name: str) -> None:
if (
not prompt_id
or prompt_id in {".", ".."}
or "/" in prompt_id
or "\\" in prompt_id
):
raise ValueError(
f"Invalid {setting_name} value: '{prompt_id}' must be a bare filename "
"without path separators"
)
def load_prompt(
prompt_id: str, *, setting_name: str, builtins: Mapping[str, Path]
) -> str:
_validate_prompt_id(prompt_id, setting_name)
mgr = get_harness_files_manager()
custom_dirs = mgr.project_prompts_dirs + mgr.user_prompts_dirs
for d in custom_dirs:
path = (d / prompt_id).with_suffix(".md")
if path.is_file():
return read_safe(path).text.strip()
builtin_path = builtins.get(prompt_id.lower())
if builtin_path is not None and builtin_path.is_file():
return read_safe(builtin_path).text.strip()
custom_ids = sorted({p.stem for d in custom_dirs for p in d.glob("*.md")})
raise MissingPromptFileError(
setting_name, prompt_id, tuple(builtins), custom_dirs, custom_ids
)
def load_system_prompt(prompt_id: str) -> str:
mgr = get_harness_files_manager()
prompt_dirs = mgr.project_prompts_dirs + mgr.user_prompts_dirs
for current_prompt_dir in prompt_dirs:
custom_sp_path = (current_prompt_dir / prompt_id).with_suffix(".md")
if custom_sp_path.is_file():
return read_safe(custom_sp_path).text
try:
return SystemPrompt[prompt_id.upper()].read()
except KeyError:
pass
builtin_path = (PROMPTS_DIR / prompt_id).with_suffix(".md")
if builtin_path.is_file():
return read_safe(builtin_path).text.strip()
raise MissingPromptFileError(
prompt_id, *(str(d) for d in [*prompt_dirs, PROMPTS_DIR])
)
builtins: dict[str, Path] = {p.name.lower(): p.path for p in SystemPrompt}
# Experiment variants may reference bundled .md files not in the enum.
fallback = (PROMPTS_DIR / prompt_id).with_suffix(".md")
if fallback.is_file():
builtins.setdefault(prompt_id.lower(), fallback)
return load_prompt(prompt_id, setting_name="system_prompt_id", builtins=builtins)
__all__ = [
"PROMPTS_DIR",
"MissingPromptFileError",
"Prompt",
"SystemPrompt",
"UtilityPrompt",
"load_prompt",
"load_system_prompt",
]

View file

@ -1,48 +1,12 @@
Create a comprehensive summary of our entire conversation that will serve as complete context for continuing this work. Structure your summary to capture both the narrative flow and technical details necessary for seamless continuation.
You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume this task.
Your summary must include these sections in order:
Include:
- The user's current goal and any explicit constraints or preferences
- Key decisions made and their rationale
- Files touched and the current state of in-progress work (paths + one-line status)
- What remains to be done — the concrete next step
- Any data, identifiers, or references the next LLM needs to continue
## 1. User's Primary Goals and Intent
Capture ALL explicit requests and objectives stated by the user throughout the conversation, preserving their exact priorities and constraints.
Be concise and structured. One line per modified file unless a snippet is load-bearing. Do not repeat information already captured. Do not include a "Final Answer" section — the entire response IS the handoff.
## 2. Conversation Timeline and Progress
Chronologically document the key phases of our work:
- Initial requests and how they were addressed
- Major decisions made and their rationale
- Problems encountered and solutions applied
- Current state of the work
## 3. Technical Context and Decisions
- Technologies, frameworks, and tools being used
- Architectural patterns and design decisions made
- Key technical constraints or requirements identified
- Important code patterns or conventions established
## 4. Files and Code Changes
For each file created, modified, or examined:
- Full file path/name
- Purpose and importance of the file
- Specific changes made (with key code snippets where critical)
- Current state of the file
## 5. Active Work and Last Actions
CRITICAL: Detail EXACTLY what was being worked on in the most recent exchanges:
- The specific task or problem being addressed
- Last completed action
- Any partial work or mid-implementation state
- Include relevant code snippets from the most recent work
## 6. Unresolved Issues and Pending Tasks
- Any errors or issues still requiring attention
- Tasks explicitly requested but not yet started
- Decisions waiting for user input
## 7. Immediate Next Step
State the SPECIFIC next action to take based on:
- The user's most recent request
- The current state of implementation
- Any ongoing work that was interrupted
Important: Be precise with technical details, file names, and code. The next agent reading this should be able to continue exactly where we left off without asking clarifying questions. Include enough detail that no context is lost, but remain focused on actionable information.
Respond with ONLY the summary text following this structure - no additional commentary or meta-discussion.
Respond with the summary text only — no preamble, no meta-discussion, no tool calls.

View file

@ -0,0 +1 @@
Another language model started to solve this problem and produced a summary of its work so far. Use this summary to continue the task without redoing work already done. Summary follows:

View file

@ -28,7 +28,7 @@ agents, prompts, logs, and session data live here.
vibehistory # Command history
trusted_folders.toml # Trust database for project folders
agents/ # Custom agent profiles (*.toml)
prompts/ # Custom system prompts (*.md)
prompts/ # Custom prompts (*.md)
skills/ # User-level skills (each skill is a subdirectory with SKILL.md)
tools/ # Custom tool definitions
logs/
@ -56,6 +56,9 @@ When in a trusted folder, Vibe also looks for project-local configuration:
The configuration file uses TOML format. Settings can also be overridden via
environment variables with the `VIBE_` prefix (e.g., `VIBE_ACTIVE_MODEL=local`).
Custom prompt IDs are resolved from project-local `.vibe/prompts/` first, then
from `~/.vibe/prompts/`, and finally from the built-in bundled prompts.
### Key Settings
```toml
@ -71,6 +74,7 @@ file_watcher_for_autocomplete = false
# Behavior
bypass_tool_permissions = false # Skip tool approval prompts
system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename
compaction_prompt_id = "compact" # Compaction prompt: built-in "compact" or custom .md filename
enable_telemetry = true
enable_update_checks = true
enable_auto_update = true
@ -251,18 +255,9 @@ save_dir = "" # Defaults to ~/.vibe/logs/session
session_prefix = "session"
```
### Browser Sign-In (Experimental)
### Browser Sign-In
Browser sign-in lets users authenticate through the browser during onboarding.
The feature is **experimental** and must be enabled first:
```toml
# In config.toml
enable_experimental_browser_sign_in = true
```
Or via the environment variable `VIBE_ENABLE_EXPERIMENTAL_BROWSER_SIGN_IN=true`.
Mistral providers use default browser sign-in URLs. Custom or renamed providers
must configure both URLs:
@ -382,6 +377,7 @@ vibe -v / --version # Show version
vibe --setup # Run onboarding/setup
vibe --max-turns N # Max assistant turns (programmatic mode)
vibe --max-price DOLLARS # Max cost limit (programmatic mode)
vibe --max-tokens N # Max total session tokens (programmatic mode)
vibe --enabled-tools TOOL # Enable specific tools (repeatable)
vibe --output text|json|streaming # Output format (programmatic mode)
```
@ -441,7 +437,7 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
- `/leanstall` - Install the Lean 4 agent (leanstral)
- `/unleanstall` - Uninstall the Lean 4 agent
- `/data-retention` - Show data retention information
- `/teleport` - Teleport session to Vibe Code (only available when Vibe Code is enabled)
- `/teleport` - Teleport session to Vibe Code Web (only available when Vibe Code is enabled)
- `/exit` - Exit the application
## Skills System

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import types
from typing import Literal
import httpx
from pydantic import BaseModel, ConfigDict, Field, ValidationError
@ -23,11 +24,22 @@ class ExperimentalNuageMessage(BaseModel):
parts: list[ExperimentalNuageTextPart]
class ExperimentalNuageDiff(BaseModel):
model_config = ConfigDict(extra="forbid")
format: Literal["git-diff"] = "git-diff"
encoding: Literal["base64"] = "base64"
compression: Literal["zstd"] = "zstd"
content: str
class ExperimentalNuageRepository(BaseModel):
model_config = ConfigDict(extra="forbid")
repo_url: str = Field(serialization_alias="repoUrl")
branch: str | None = None
commit_sha: str | None = Field(default=None, serialization_alias="commitSha")
diff: ExperimentalNuageDiff | None = None
class ExperimentalNuageContext(BaseModel):

View file

@ -16,6 +16,7 @@ from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.experimental_nuage import (
ExperimentalNuageClient,
ExperimentalNuageContext,
ExperimentalNuageDiff,
ExperimentalNuageMessage,
ExperimentalNuageRepository,
ExperimentalNuageRequest,
@ -282,6 +283,13 @@ class TeleportService:
"Experimental Nuage teleport requires a checked-out branch."
)
compressed = self._compress_diff(git_info.diff)
diff = (
ExperimentalNuageDiff(content=compressed.decode("ascii"))
if compressed is not None
else None
)
return ExperimentalNuageRequest(
idempotency_key=str(uuid4()),
message=ExperimentalNuageMessage(
@ -290,7 +298,10 @@ class TeleportService:
context=ExperimentalNuageContext(
repositories=[
ExperimentalNuageRepository(
repo_url=git_info.remote_url, branch=git_info.branch
repo_url=git_info.remote_url,
branch=git_info.branch,
commit_sha=git_info.commit,
diff=diff,
)
]
),

29
vibe/core/utils/tokens.py Normal file
View file

@ -0,0 +1,29 @@
from __future__ import annotations
import math
_APPROX_BYTES_PER_TOKEN = 4
_TRUNCATION_MARKER = "\n\n[... truncated ...]\n\n"
def approx_token_count(text: str) -> int:
return math.ceil(len(text) / _APPROX_BYTES_PER_TOKEN)
def truncate_middle_to_tokens(text: str, max_tokens: int) -> str:
"""Shrink ``text`` to fit in ``max_tokens`` by dropping the middle.
Keeps head + tail (intent and constraints usually live at the ends of user
messages) and inserts a marker where the middle was removed.
"""
if max_tokens <= 0:
return ""
max_chars = max_tokens * _APPROX_BYTES_PER_TOKEN
if len(text) <= max_chars:
return text
available = max_chars - len(_TRUNCATION_MARKER)
if available <= 0:
return text[:max_chars]
head = available // 2
tail = available - head
return text[:head] + _TRUNCATION_MARKER + text[-tail:]

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from vibe.setup.auth.auth_state import AuthState, AuthStateKind, assess_auth_state
from vibe.setup.auth.browser_sign_in import (
BrowserSignInAttempt,
BrowserSignInService,
@ -15,6 +16,8 @@ from vibe.setup.auth.browser_sign_in_gateway import (
from vibe.setup.auth.http_browser_sign_in_gateway import HttpBrowserSignInGateway
__all__ = [
"AuthState",
"AuthStateKind",
"BrowserSignInAttempt",
"BrowserSignInError",
"BrowserSignInErrorCode",
@ -24,4 +27,5 @@ __all__ = [
"BrowserSignInService",
"BrowserSignInStatus",
"HttpBrowserSignInGateway",
"assess_auth_state",
]

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import os
from dotenv import set_key
from dotenv import set_key, unset_key
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
from vibe.core.paths import GLOBAL_ENV_FILE
@ -16,6 +16,12 @@ def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
def _remove_api_key_from_env_file(env_key: str) -> None:
if not GLOBAL_ENV_FILE.path.exists():
return
unset_key(GLOBAL_ENV_FILE.path, env_key)
def _get_mistral_provider() -> ProviderConfig:
return next(
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
@ -62,3 +68,11 @@ def persist_api_key(
except Exception:
pass
return "completed"
def remove_api_key(provider: ProviderConfig) -> None:
env_key = provider.api_key_env_var
if not env_key:
raise ValueError("Cannot remove API key without an environment variable name")
_remove_api_key_from_env_file(env_key)
os.environ.pop(env_key, None)

View file

@ -0,0 +1,150 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum, auto
import os
from pathlib import Path
from dotenv import dotenv_values
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
from vibe.core.paths import GLOBAL_ENV_FILE
class AuthStateKind(StrEnum):
SIGNED_OUT = auto()
AUTH_NOT_REQUIRED = auto()
VIBE_HOME_ENV_FILE = auto()
PROCESS_ENV = auto()
VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV = auto()
UNSUPPORTED_PROVIDER = auto()
@dataclass(frozen=True, slots=True)
class _AuthEnvSnapshot:
env_key: str
current_process_has_value: bool
dotenv_has_value: bool
process_env_had_value_before_dotenv_load: bool
@dataclass(frozen=True, slots=True)
class AuthState:
kind: AuthStateKind
can_use_active_provider: bool
sign_out_available: bool
env_key: str | None
def _has_value(value: str | None) -> bool:
return bool(value)
def _dotenv_has_value(env_path: Path, env_key: str) -> bool:
if not env_path.is_file() and not env_path.is_fifo():
return False
value = dotenv_values(env_path).get(env_key)
if not isinstance(value, str):
return False
return _has_value(value)
def _supports_vibe_owned_sign_out(provider: ProviderConfig) -> bool:
return provider.api_key_env_var == DEFAULT_MISTRAL_API_ENV_KEY
def _auth_state(
kind: AuthStateKind,
*,
can_use_active_provider: bool,
sign_out_available: bool = False,
env_key: str | None = None,
) -> AuthState:
return AuthState(
kind=kind,
can_use_active_provider=can_use_active_provider,
sign_out_available=sign_out_available,
env_key=env_key,
)
def _capture_auth_env_snapshot(
env_key: str,
*,
env_path: Path | None = None,
environ: Mapping[str, str] | None = None,
process_env_had_value_before_dotenv_load: bool = False,
) -> _AuthEnvSnapshot:
resolved_env_path = env_path if env_path is not None else GLOBAL_ENV_FILE.path
resolved_environ = environ if environ is not None else os.environ
return _AuthEnvSnapshot(
env_key=env_key,
current_process_has_value=_has_value(resolved_environ.get(env_key)),
dotenv_has_value=_dotenv_has_value(resolved_env_path, env_key),
process_env_had_value_before_dotenv_load=process_env_had_value_before_dotenv_load,
)
def assess_auth_state(
provider: ProviderConfig,
*,
env_path: Path | None = None,
environ: Mapping[str, str] | None = None,
process_env_had_value_before_dotenv_load: bool = False,
) -> AuthState:
env_key = provider.api_key_env_var
if not env_key:
return _auth_state(
AuthStateKind.AUTH_NOT_REQUIRED, can_use_active_provider=True
)
auth_snapshot = _capture_auth_env_snapshot(
env_key,
env_path=env_path,
environ=environ,
process_env_had_value_before_dotenv_load=process_env_had_value_before_dotenv_load,
)
if (
not auth_snapshot.current_process_has_value
and not auth_snapshot.dotenv_has_value
):
return _auth_state(
AuthStateKind.SIGNED_OUT, can_use_active_provider=False, env_key=env_key
)
if not _supports_vibe_owned_sign_out(provider):
return _auth_state(
AuthStateKind.UNSUPPORTED_PROVIDER,
can_use_active_provider=True,
env_key=env_key,
)
if (
auth_snapshot.dotenv_has_value
and auth_snapshot.process_env_had_value_before_dotenv_load
):
return _auth_state(
AuthStateKind.VIBE_HOME_ENV_FILE_OVERRIDES_PROCESS_ENV,
can_use_active_provider=True,
env_key=env_key,
)
if auth_snapshot.dotenv_has_value:
return _auth_state(
AuthStateKind.VIBE_HOME_ENV_FILE,
can_use_active_provider=True,
sign_out_available=True,
env_key=env_key,
)
if auth_snapshot.current_process_has_value:
return _auth_state(
AuthStateKind.PROCESS_ENV, can_use_active_provider=True, env_key=env_key
)
raise AssertionError("assess_auth_state reached unreachable state")
__all__ = ["AuthState", "AuthStateKind", "assess_auth_state"]

View file

@ -96,7 +96,7 @@ class OnboardingApp(App[str | None]):
def _resolve_browser_sign_in_factory(
self, browser_sign_in_service_factory: Callable[[], BrowserSignInService] | None
) -> Callable[[], BrowserSignInService] | None:
if not self._config.browser_sign_in_enabled:
if not self._config.supports_browser_sign_in:
return None
return (

View file

@ -29,7 +29,6 @@ def _default_model_payloads() -> list[dict[str, Any]]:
class _OnboardingSnapshot(BaseModel):
active_model: str = DEFAULT_ACTIVE_MODEL
enable_experimental_browser_sign_in: bool = False
providers: list[Any] = Field(default_factory=_default_provider_payloads)
models: list[Any] = Field(default_factory=_default_model_payloads)
@ -98,19 +97,6 @@ def _load_onboarding_env_payload_for_fields(
and (models := _find_env_value("VIBE_MODELS")) is not None
):
payload["models"] = _ONBOARDING_LIST_ADAPTER.validate_json(models)
# Onboarding uses this lightweight snapshot before full VibeConfig loading,
# so env-backed config fields must be mirrored here when onboarding needs them.
if (
"enable_experimental_browser_sign_in" in field_names
and (
enable_browser_sign_in := _find_env_value(
"VIBE_ENABLE_EXPERIMENTAL_BROWSER_SIGN_IN"
)
)
is not None
):
payload["enable_experimental_browser_sign_in"] = enable_browser_sign_in
return payload
@ -194,27 +180,14 @@ def _resolve_provider(
@dataclass(frozen=True)
class OnboardingContext:
provider: ProviderConfig
enable_experimental_browser_sign_in: bool = False
@property
def supports_browser_sign_in(self) -> bool:
return self.provider.supports_browser_sign_in
@property
def browser_sign_in_enabled(self) -> bool:
return (
self.enable_experimental_browser_sign_in
and self.provider.supports_browser_sign_in
)
@classmethod
def from_config(cls, config: VibeConfig) -> OnboardingContext:
return cls(
provider=config.get_active_provider(),
enable_experimental_browser_sign_in=(
config.enable_experimental_browser_sign_in
),
)
return cls(provider=config.get_active_provider())
@classmethod
def load(cls, **overrides: Any) -> OnboardingContext:
@ -225,10 +198,7 @@ class OnboardingContext:
return cls(
provider=_resolve_provider(
active_model=snapshot.active_model, snapshot=snapshot
),
enable_experimental_browser_sign_in=(
snapshot.enable_experimental_browser_sign_in
),
)
)
except (RuntimeError, ValidationError, ValueError):
logger.warning(

View file

@ -1,5 +1,4 @@
# What's new in v2.11.0
# What's new in v2.11.1
- **Shared skills**: Vibe now loads skills from `~/.agents/skills` so they can be reused across agents
- **Connector onboarding from the CLI**: `/mcp` lists unauthenticated connectors and lets you start the OAuth flow without leaving Vibe
- **Theme picker is back**: Pick a theme during onboarding (`vibe --setup`) or switch any time with `/theme`
- **Custom compaction prompts**: Override the default `/compact` prompt by setting `compaction_prompt_id` and dropping a markdown file in `~/.vibe/prompts/` or `.vibe/prompts/`.
- **Safer programmatic mode**: `-p` no longer auto-approves tool calls by default — pass `--auto-approve` to restore the previous behavior.