Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@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: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-05-20 11:39:59 +02:00 committed by GitHub
parent 228f3c65a9
commit f71bfd3b8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 2950 additions and 402 deletions

View file

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

View file

@ -3,12 +3,14 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
from contextlib import aclosing
from dataclasses import dataclass
from datetime import UTC
import logging
import os
from pathlib import Path
import signal
import sys
from typing import Any, cast, override
from typing import Any, Protocol, cast, override
from uuid import uuid4
from acp import (
@ -57,6 +59,7 @@ from acp.schema import (
SetSessionConfigOptionResponse,
SseMcpServer,
TerminalAuthMethod,
TerminalToolCallContent,
TextContentBlock,
TextResourceContents,
ToolCallProgress,
@ -83,7 +86,9 @@ from vibe.acp.exceptions import (
UnauthenticatedError,
)
from vibe.acp.session import AcpSessionLoop
from vibe.acp.title import acp_blocks_to_title_segments
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.events import ToolTerminalOpenedEvent
from vibe.acp.tools.session_update import (
resolve_kind,
tool_call_session_update,
@ -112,6 +117,7 @@ from vibe.core.agents.models import CHAT as CHAT_AGENT, BuiltinAgentName
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import (
MissingAPIKeyError,
ProviderConfig,
SessionLoggingConfig,
VibeConfig,
load_dotenv_values,
@ -129,6 +135,7 @@ from vibe.core.session.saved_sessions import (
update_saved_session_title_at_path,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.session.title_format import format_session_title
from vibe.core.skills.manager import SkillManager
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.send import TelemetryClient
@ -146,6 +153,7 @@ from vibe.core.types import (
RateLimitError as CoreRateLimitError,
ReasoningEvent,
Role,
SessionTitleUpdatedEvent,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
@ -155,6 +163,18 @@ from vibe.core.utils import (
ConversationLimitException,
get_user_cancellation_message,
)
from vibe.setup.auth import (
BrowserSignInAttempt,
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInService,
HttpBrowserSignInGateway,
)
from vibe.setup.auth.api_key_persistence import (
persist_api_key,
resolve_api_key_provider,
)
from vibe.setup.onboarding.context import OnboardingContext
logger = logging.getLogger("vibe")
@ -215,13 +235,123 @@ def _resolved_user_message_id(client_message_id: str | None) -> str:
return str(uuid4())
@dataclass(frozen=True)
class PendingBrowserSignInAttempt:
attempt: BrowserSignInAttempt
provider: ProviderConfig
RETRYABLE_BROWSER_SIGN_IN_COMPLETION_ERRORS = {
BrowserSignInErrorCode.EXCHANGE_FAILED,
BrowserSignInErrorCode.POLL_FAILED,
}
OnboardingContextLoader = Callable[[], OnboardingContext]
ApiKeyPersister = Callable[[ProviderConfig, str], str]
class BrowserSignInServiceAdapter(Protocol):
async def authenticate(self) -> str: ...
async def start_attempt(self) -> BrowserSignInAttempt: ...
async def complete_attempt(self, attempt: BrowserSignInAttempt) -> str: ...
async def aclose(self) -> None: ...
BrowserSignInServiceFactory = Callable[[ProviderConfig], BrowserSignInServiceAdapter]
class VibeAcpAgentLoop(AcpAgent):
client: Client
def __init__(self) -> None:
def __init__(
self,
*,
onboarding_context_loader: OnboardingContextLoader | None = None,
browser_sign_in_service_factory: BrowserSignInServiceFactory | None = None,
api_key_persister: ApiKeyPersister = persist_api_key,
) -> None:
self.sessions: dict[str, AcpSessionLoop] = {}
self.client_capabilities: ClientCapabilities | None = None
self.client_info: Implementation | None = None
self._pending_browser_sign_in_attempts: dict[
str, PendingBrowserSignInAttempt
] = {}
self._load_onboarding_context = (
onboarding_context_loader or OnboardingContext.load
)
self._browser_sign_in_service_factory = (
browser_sign_in_service_factory or self._build_browser_sign_in_service
)
self._persist_api_key = api_key_persister
def _build_browser_auth_method(
self, context: OnboardingContext, method_id: str
) -> AuthMethodAgent | None:
if not context.browser_sign_in_enabled:
return None
return AuthMethodAgent(
id=method_id,
name="Sign in through Mistral AI Studio",
description="Sign into Mistral Vibe through your Mistral AI Studio account.",
)
def _build_terminal_auth_method(
self, command: str, args: list[str]
) -> TerminalAuthMethod:
return TerminalAuthMethod(
type="terminal",
id="vibe-setup",
name="Register your API Key",
description="Register your API Key inside Mistral Vibe",
field_meta={
"terminal-auth": {
"command": command,
"args": args,
"label": "Mistral Vibe Setup",
}
},
)
def _build_browser_sign_in_service(
self, provider: ProviderConfig | None = None
) -> BrowserSignInService:
provider = provider or self._load_onboarding_context().provider
if not provider.supports_browser_sign_in:
raise InvalidRequestError(
"Browser sign-in is not available for the configured provider."
)
browser_base_url = provider.browser_auth_base_url
api_base_url = provider.browser_auth_api_base_url
if browser_base_url is None or api_base_url is None:
raise ConfigurationError("Browser sign-in requires both browser auth URLs.")
return BrowserSignInService(
HttpBrowserSignInGateway(
browser_base_url=browser_base_url, api_base_url=api_base_url
)
)
def _load_enabled_browser_sign_in_context(self) -> OnboardingContext:
context = self._load_onboarding_context()
if not context.browser_sign_in_enabled:
raise InvalidRequestError(
"Browser sign-in is not available for the configured provider."
)
return context
def _supports_delegated_browser_auth(self) -> bool:
return bool(
self.client_capabilities
and self.client_capabilities.field_meta
and self.client_capabilities.field_meta.get("browser-auth-delegated")
is True
)
@override
async def initialize(
@ -256,25 +386,21 @@ class VibeAcpAgentLoop(AcpAgent):
and self.client_capabilities.field_meta.get("terminal-auth") is True
)
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = (
[
TerminalAuthMethod(
type="terminal",
id="vibe-setup",
name="Register your API Key",
description="Register your API Key inside Mistral Vibe",
field_meta={
"terminal-auth": {
"command": command,
"args": args,
"label": "Mistral Vibe Setup",
}
},
)
]
if supports_terminal_auth
else []
)
context = self._load_onboarding_context()
auth_methods: list[EnvVarAuthMethod | TerminalAuthMethod | AuthMethodAgent] = []
if browser_auth_method := self._build_browser_auth_method(
context, "browser-auth"
):
auth_methods.append(browser_auth_method)
if self._supports_delegated_browser_auth():
delegated_browser_auth_method = self._build_browser_auth_method(
context, "browser-auth-delegated"
)
if delegated_browser_auth_method is not None:
auth_methods.append(delegated_browser_auth_method)
if supports_terminal_auth:
auth_methods.append(self._build_terminal_auth_method(command, args))
response = InitializeResponse(
agent_capabilities=AgentCapabilities(
@ -298,11 +424,116 @@ class VibeAcpAgentLoop(AcpAgent):
)
return response
async def _authenticate_browser_auth(self, **kwargs: Any) -> AuthenticateResponse:
action = kwargs.get("action")
if action not in {None, "start"}:
raise InvalidRequestError(f"Unsupported browser auth action: {action}")
provider = self._load_enabled_browser_sign_in_context().provider
browser_sign_in = self._browser_sign_in_service_factory(provider)
try:
api_key = await browser_sign_in.authenticate()
except BrowserSignInError as e:
raise InternalError(str(e)) from e
finally:
await browser_sign_in.aclose()
persist_result = self._persist_api_key(
resolve_api_key_provider(provider), api_key
)
return AuthenticateResponse(
field_meta={
"browser-auth": {"persistResult": persist_result, "status": "completed"}
}
)
async def _start_delegated_browser_auth(self) -> AuthenticateResponse:
provider = self._load_enabled_browser_sign_in_context().provider
browser_sign_in = self._browser_sign_in_service_factory(provider)
try:
attempt = await browser_sign_in.start_attempt()
except BrowserSignInError as e:
raise InternalError(str(e)) from e
finally:
await browser_sign_in.aclose()
self._pending_browser_sign_in_attempts[attempt.process_id] = (
PendingBrowserSignInAttempt(attempt=attempt, provider=provider)
)
return AuthenticateResponse(
field_meta={
"browser-auth-delegated": {
"attemptId": attempt.process_id,
"expiresAt": attempt.expires_at.astimezone(UTC)
.isoformat()
.replace("+00:00", "Z"),
"signInUrl": attempt.sign_in_url,
}
}
)
async def _complete_delegated_browser_auth(
self, **kwargs: Any
) -> AuthenticateResponse:
attempt_id = kwargs.get("attemptId") or kwargs.get("attempt_id")
if not isinstance(attempt_id, str) or not attempt_id:
raise InvalidRequestError("Missing browser sign-in attempt ID.")
pending_attempt = self._pending_browser_sign_in_attempts.get(attempt_id)
if pending_attempt is None:
raise InvalidRequestError(f"Unknown browser sign-in attempt: {attempt_id}")
browser_sign_in = self._browser_sign_in_service_factory(
pending_attempt.provider
)
try:
api_key = await browser_sign_in.complete_attempt(pending_attempt.attempt)
except BrowserSignInError as e:
if e.code not in RETRYABLE_BROWSER_SIGN_IN_COMPLETION_ERRORS:
self._pending_browser_sign_in_attempts.pop(attempt_id, None)
raise InvalidRequestError(str(e)) from e
finally:
await browser_sign_in.aclose()
self._pending_browser_sign_in_attempts.pop(attempt_id, None)
persist_result = self._persist_api_key(
resolve_api_key_provider(pending_attempt.provider), api_key
)
return AuthenticateResponse(
field_meta={
"browser-auth-delegated": {
"attemptId": attempt_id,
"persistResult": persist_result,
"status": "completed",
}
}
)
async def _authenticate_delegated_browser_auth(
self, **kwargs: Any
) -> AuthenticateResponse:
action = kwargs.get("action", "start")
if action not in {"start", "complete"}:
raise InvalidRequestError(
f"Unsupported delegated browser auth action: {action}"
)
if action == "start":
return await self._start_delegated_browser_auth()
return await self._complete_delegated_browser_auth(**kwargs)
@override
async def authenticate(
self, method_id: str, **kwargs: Any
) -> AuthenticateResponse | None:
raise NotImplementedMethodError("authenticate")
if method_id == "browser-auth":
return await self._authenticate_browser_auth(**kwargs)
if method_id == "browser-auth-delegated":
return await self._authenticate_delegated_browser_auth(**kwargs)
raise InvalidRequestError(f"Unsupported auth method: {method_id}")
def _build_entrypoint_metadata(self) -> EntrypointMetadata:
return build_entrypoint_metadata(
@ -834,9 +1065,15 @@ class VibeAcpAgentLoop(AcpAgent):
)
text_prompt = SkillManager.build_skill_prompt(text_prompt, skill)
auto_title: str | None = None
if session.agent_loop.session_logger.needs_initial_auto_title():
auto_title = (
format_session_title(acp_blocks_to_title_segments(prompt)) or None
)
async def agent_loop_task() -> None:
async for update in self._run_agent_loop(
session, text_prompt, resolved_message_id
session, text_prompt, resolved_message_id, auto_title=auto_title
):
await self.client.session_update(session_id=session.id, update=update)
@ -945,15 +1182,29 @@ class VibeAcpAgentLoop(AcpAgent):
return await handler(session, text_prompt, message_id)
async def _run_agent_loop(
self, session: AcpSessionLoop, prompt: str, client_message_id: str | None = None
self,
session: AcpSessionLoop,
prompt: str,
client_message_id: str | None = None,
*,
auto_title: str | None = None,
) -> AsyncGenerator[SessionUpdate | UsageUpdate]:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
async with aclosing(
session.agent_loop.act(rendered_prompt, client_message_id=client_message_id)
session.agent_loop.act(
rendered_prompt,
client_message_id=client_message_id,
auto_title=auto_title,
)
) as events:
async for event in events:
if isinstance(event, AssistantEvent):
if isinstance(event, SessionTitleUpdatedEvent):
await self._emit_session_info_update(
session.id, title=event.title, updated_at=None
)
elif isinstance(event, AssistantEvent):
yield AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=event.content),
@ -973,7 +1224,6 @@ class VibeAcpAgentLoop(AcpAgent):
tool_manager=session.agent_loop.tool_manager,
client=self.client,
session_id=session.id,
tool_call_id=event.tool_call_id,
)
session_update = tool_call_session_update(event)
@ -986,6 +1236,22 @@ class VibeAcpAgentLoop(AcpAgent):
yield session_update
self._send_usage_update(session)
elif isinstance(event, ToolTerminalOpenedEvent):
# bash yielded the terminal id; surface it as an
# in_progress update carrying the terminal block.
yield ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="in_progress",
kind=resolve_kind(event.tool_name),
content=[
TerminalToolCallContent(
type="terminal", terminal_id=event.terminal_id
)
],
field_meta={"tool_name": event.tool_name},
)
elif isinstance(event, ToolStreamEvent):
yield ToolCallProgress(
session_update="tool_call_update",

69
vibe/acp/title.py Normal file
View file

@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
import re
from urllib.parse import SplitResult, urlsplit
from acp.helpers import ContentBlock
from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment
_LINE_FRAGMENT_RE = re.compile(r"^L(\d+)(?:-L(\d+))?$")
def acp_blocks_to_title_segments(blocks: list[ContentBlock]) -> list[TitleSegment]:
segments: list[TitleSegment] = []
for block in blocks:
if block.field_meta and block.field_meta.get("automatic"):
continue
if (segment := _block_to_segment(block)) is not None:
segments.append(segment)
return segments
def _block_to_segment(block: ContentBlock) -> TitleSegment | None:
match block.type:
case "text":
return TextSegment(text=block.text)
case "resource":
base, start, end = _parse_line_range_fragment(block.resource.uri)
name = _basename_from_uri(base)
if not name:
return None
return MentionSegment(name=name, start_line=start, end_line=end)
case "resource_link":
base, start, end = _parse_line_range_fragment(block.uri)
name = block.name or _basename_from_uri(base)
if not name:
return None
return MentionSegment(name=name, start_line=start, end_line=end)
case _:
return None
def _safe_urlsplit(uri: str) -> SplitResult | None:
try:
return urlsplit(uri)
except ValueError:
return None
def _parse_line_range_fragment(uri: str) -> tuple[str, int | None, int | None]:
parts = _safe_urlsplit(uri)
if parts is None or not parts.fragment:
return uri, None, None
match = _LINE_FRAGMENT_RE.match(parts.fragment)
if match is None:
return uri, None, None
start = int(match.group(1))
end = int(match.group(2)) if match.group(2) is not None else None
base_uri = parts._replace(fragment="").geturl()
return base_uri, start, end
def _basename_from_uri(uri: str) -> str:
parts = _safe_urlsplit(uri)
if parts is None:
return ""
path = parts.path if parts.scheme else uri
return Path(path).name

View file

@ -4,12 +4,8 @@ from abc import abstractmethod
from typing import Annotated, cast
from acp import Client
from acp.helpers import ToolCallContentVariant
from acp.schema import ToolCallProgress
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
from vibe.acp.tools.session_update import resolve_kind
from vibe.core.logger import logger
from vibe.core.tools.base import BaseTool, ToolError
from vibe.core.tools.manager import ToolManager
@ -21,9 +17,6 @@ class AcpToolState(BaseModel):
default=None, description="ACP Client"
)
session_id: str | None = Field(default=None, description="Current ACP session ID")
tool_call_id: str | None = Field(
default=None, description="Current ACP tool call ID"
)
class BaseAcpTool[ToolState: AcpToolState](BaseTool):
@ -37,23 +30,17 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
@classmethod
def update_tool_state(
cls,
*,
tool_manager: ToolManager,
client: Client | None,
session_id: str | None,
tool_call_id: str | None,
cls, *, tool_manager: ToolManager, client: Client | None, session_id: str | None
) -> None:
tool_instance = cls.get_tool_instance(cls.get_name(), tool_manager)
tool_instance.state.client = client
tool_instance.state.session_id = session_id
tool_instance.state.tool_call_id = tool_call_id
@classmethod
@abstractmethod
def _get_tool_state_class(cls) -> type[ToolState]: ...
def _load_state(self) -> tuple[Client, str, str | None]:
def _load_state(self) -> tuple[Client, str]:
if self.state.client is None:
raise ToolError(
"Client not available in tool state. This tool can only be used within an ACP session."
@ -63,27 +50,4 @@ class BaseAcpTool[ToolState: AcpToolState](BaseTool):
"Session ID not available in tool state. This tool can only be used within an ACP session."
)
return self.state.client, self.state.session_id, self.state.tool_call_id
async def _send_in_progress_session_update(
self, content: list[ToolCallContentVariant] | None = None
) -> None:
client, session_id, tool_call_id = self._load_state()
if tool_call_id is None:
return
tool_name = self.get_name()
try:
await client.session_update(
session_id=session_id,
update=ToolCallProgress(
session_update="tool_call_update",
tool_call_id=tool_call_id,
status="in_progress",
kind=resolve_kind(tool_name),
content=content,
field_meta={"tool_name": tool_name},
),
)
except Exception as e:
logger.error(f"Failed to update session: {e!r}")
return self.state.client, self.state.session_id

View file

@ -6,7 +6,6 @@ from pathlib import Path
from acp.schema import (
ContentToolCallContent,
TerminalToolCallContent,
TextContentBlock,
ToolCallProgress,
ToolCallStart,
@ -15,6 +14,7 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.acp.tools.events import ToolTerminalOpenedEvent
from vibe.acp.tools.session_update import resolve_kind
from vibe.core.logger import logger
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
@ -37,7 +37,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
async def run(
self, args: BashArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | BashResult, None]:
client, session_id, _ = self._load_state()
client, session_id = self._load_state()
timeout = args.timeout or self.config.default_timeout
max_bytes = self.config.max_output_bytes
@ -54,11 +54,14 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
terminal_id = terminal.terminal_id
await self._send_in_progress_session_update([
TerminalToolCallContent(type="terminal", terminal_id=terminal_id)
])
try:
if ctx is not None:
yield ToolTerminalOpenedEvent(
tool_name=self.get_name(),
tool_call_id=ctx.tool_call_id,
terminal_id=terminal_id,
)
exit_response = await self._wait_for_terminal_exit(
terminal_id=terminal_id, timeout=timeout, command=args.command
)
@ -93,7 +96,7 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
async def _wait_for_terminal_exit(
self, terminal_id: str, timeout: int, command: str
) -> WaitForTerminalExitResponse:
client, session_id, _ = self._load_state()
client, session_id = self._load_state()
try:
return await asyncio.wait_for(

View file

@ -114,13 +114,11 @@ class ReadFile(
)
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
client, session_id, _ = self._load_state()
client, session_id = self._load_state()
line = args.offset + 1 if args.offset > 0 else None
limit = args.limit
await self._send_in_progress_session_update()
try:
response = await client.read_text_file(
session_id=session_id, path=str(file_path), line=line, limit=limit

View file

@ -44,9 +44,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
return AcpSearchReplaceState
async def _read_file(self, file_path: Path) -> ReadSafeResult:
client, session_id, _ = self._load_state()
await self._send_in_progress_session_update()
client, session_id = self._load_state()
try:
response = await client.read_text_file(
@ -74,7 +72,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
async def _write_file(
self, file_path: Path, content: str, encoding: str, newline: str
) -> None:
client, session_id, _ = self._load_state()
client, session_id = self._load_state()
try:
await client.write_text_file(

View file

@ -41,9 +41,7 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
return AcpWriteFileState
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
client, session_id, _ = self._load_state()
await self._send_in_progress_session_update()
client, session_id = self._load_state()
try:
await client.write_text_file(

8
vibe/acp/tools/events.py Normal file
View file

@ -0,0 +1,8 @@
from __future__ import annotations
from vibe.core.types import ToolStreamEvent
class ToolTerminalOpenedEvent(ToolStreamEvent):
message: str = ""
terminal_id: str

View file

@ -123,7 +123,10 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
from vibe.core.autocompletion.path_prompt import (
build_path_prompt_payload,
build_title_segments,
)
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
@ -140,6 +143,7 @@ from vibe.core.session.resume_sessions import (
)
from vibe.core.session.saved_sessions import update_saved_session_title_at_path
from vibe.core.session.session_loader import SessionLoader
from vibe.core.session.title_format import format_session_title
from vibe.core.skills.manager import SkillManager
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
from vibe.core.teleport.types import (
@ -957,7 +961,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(skill.name, "skill")
prompt = SkillManager.build_skill_prompt(user_input, skill)
await self._handle_user_message(prompt)
await self._handle_user_message(prompt, title_source=user_input)
return True
@staticmethod
@ -1146,7 +1150,9 @@ class VibeApp(App): # noqa: PLR0904
return "\n\n".join(sections)
async def _handle_user_message(self, message: str) -> None:
async def _handle_user_message(
self, message: str, *, title_source: str | None = None
) -> None:
if self._remote_manager.is_active:
await self._handle_remote_user_message(message)
return
@ -1165,7 +1171,7 @@ class VibeApp(App): # noqa: PLR0904
await self._remote_manager.stop_stream()
await self._remove_loading_widget()
self._agent_task = asyncio.create_task(
self._handle_agent_loop_turn(message)
self._handle_agent_loop_turn(message, title_source=title_source)
)
async def _handle_remote_user_message(self, message: str) -> None:
@ -1366,7 +1372,9 @@ class VibeApp(App): # noqa: PLR0904
event, loading_widget=self._loading_widget
)
async def _handle_agent_loop_turn(self, prompt: str) -> None:
async def _handle_agent_loop_turn(
self, prompt: str, *, title_source: str | None = None
) -> None:
self._agent_running = True
await self._remove_loading_widget()
@ -1393,10 +1401,22 @@ class VibeApp(App): # noqa: PLR0904
message_id=message_id,
)
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
auto_title: str | None = None
if self.agent_loop.session_logger.needs_initial_auto_title():
auto_title = (
format_session_title(
build_title_segments(
title_source or prompt, base_dir=Path.cwd()
)
)
or None
)
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
async with aclosing(
self.agent_loop.act(rendered_prompt, client_message_id=message_id)
self.agent_loop.act(
rendered_prompt, client_message_id=message_id, auto_title=auto_title
)
) as events:
await self._handle_agent_loop_events(events)
except asyncio.CancelledError:

View file

@ -33,6 +33,7 @@ from vibe.core.types import (
PlanReviewEndedEvent,
PlanReviewRequestedEvent,
ReasoningEvent,
SessionTitleUpdatedEvent,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
@ -116,6 +117,8 @@ class EventHandler:
case AgentProfileChangedEvent():
if self.on_profile_changed:
self.on_profile_changed()
case SessionTitleUpdatedEvent():
pass
case UserMessageEvent():
await self.finalize_streaming()
if self.is_remote:

View file

@ -71,6 +71,7 @@ _LIST_VIEW_HELP_AUTH = (
_DETAIL_VIEW_HELP = (
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
)
_DETAIL_VIEW_HELP_NO_TOOLS = "↑↓ Navigate Backspace Back R Refresh Esc Close"
class MCPApp(Container):
@ -211,7 +212,16 @@ class MCPApp(Container):
return
self._status_message = "Refreshing..."
help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP_TOOLS
if self._viewing_server:
tools_source = (
self._index.connector_tools
if self._viewing_kind == MCPSourceKind.CONNECTOR
else self._index.server_tools
)
all_tools = tools_source.get(self._viewing_server, [])
help = _DETAIL_VIEW_HELP if all_tools else _DETAIL_VIEW_HELP_NO_TOOLS
else:
help = _LIST_VIEW_HELP_TOOLS
self._set_help_text(help)
self._refreshing = True
@ -516,9 +526,11 @@ class MCPApp(Container):
self.query_one("#mcp-title", NoMarkupStatic).update(
f"{title_prefix}: {server_name}"
)
self._set_help_text(_DETAIL_VIEW_HELP)
tools_source = index.connector_tools if is_connector else index.server_tools
all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0])
self._set_help_text(
_DETAIL_VIEW_HELP if all_tools else _DETAIL_VIEW_HELP_NO_TOOLS
)
if not all_tools:
if (
is_connector

View file

@ -113,6 +113,7 @@ from vibe.core.types import (
RateLimitError,
ReasoningEvent,
Role,
SessionTitleUpdatedEvent,
ToolCall,
ToolCallEvent,
ToolResultEvent,
@ -629,7 +630,11 @@ class AgentLoop: # noqa: PLR0904
@requires_init
async def act(
self, msg: str, client_message_id: str | None = None
self,
msg: str,
client_message_id: str | None = None,
*,
auto_title: str | None = None,
) -> AsyncGenerator[BaseEvent, None]:
self._clean_message_history()
self.rewind_manager.create_checkpoint()
@ -639,7 +644,7 @@ class AgentLoop: # noqa: PLR0904
model_name = None
async with agent_span(model=model_name, session_id=self.session_id):
async for event in self._conversation_loop(
msg, client_message_id=client_message_id
msg, client_message_id=client_message_id, auto_title=auto_title
):
yield event
@ -848,7 +853,11 @@ class AgentLoop: # noqa: PLR0904
return headers
async def _conversation_loop( # noqa: PLR0912
self, user_msg: str, client_message_id: str | None = None
self,
user_msg: str,
client_message_id: str | None = None,
*,
auto_title: str | None = None,
) -> AsyncGenerator[BaseEvent]:
user_message = LLMMessage(
role=Role.user, content=user_msg, message_id=client_message_id
@ -862,6 +871,11 @@ class AgentLoop: # noqa: PLR0904
yield UserMessageEvent(content=user_msg, message_id=user_message.message_id)
if auto_title is not None and self.session_logger.set_initial_auto_title(
auto_title
):
yield SessionTitleUpdatedEvent(title=auto_title)
if self._hooks_manager:
self._hooks_manager.reset_retry_count()

View file

@ -4,6 +4,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment
@dataclass(frozen=True, slots=True)
class PathResource:
@ -107,3 +109,35 @@ def _dedupe_resources(resources: list[PathResource]) -> list[PathResource]:
seen.add(resource.path)
unique.append(resource)
return unique
def build_title_segments(
message: str, *, base_dir: Path | None = None
) -> list[TitleSegment]:
if not message:
return []
resolved_base = (base_dir or Path.cwd()).resolve()
segments: list[TitleSegment] = []
text_buf: list[str] = []
pos = 0
def flush_text() -> None:
if text_buf:
segments.append(TextSegment(text="".join(text_buf)))
text_buf.clear()
while pos < len(message):
if _is_path_anchor(message, pos):
candidate, new_pos = _extract_candidate(message, pos + 1)
if candidate and (resource := _to_resource(candidate, resolved_base)):
flush_text()
segments.append(MentionSegment(name=resource.path.name))
pos = new_pos
continue
text_buf.append(message[pos])
pos += 1
flush_text()
return segments

122
vibe/core/config/builder.py Normal file
View file

@ -0,0 +1,122 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from vibe.core.config.layer import (
ConfigLayer,
EmptyLayerError,
RawConfig,
UntrustedLayerError,
)
from vibe.core.config.schema import ConfigFragment, ConfigSchema, MergeFieldMetadata
@dataclass(frozen=True, slots=True)
class _LayerData:
name: str
data: dict[str, Any]
class ConfigBuilder[S: ConfigSchema]:
"""Collects layers and merges them into an immutable Config[S]."""
def __init__(self, schema: type[S]) -> None:
self._schema = schema
self._layers: list[ConfigLayer[RawConfig]] = []
self._lock = asyncio.Lock()
def add_layer(self, layer: ConfigLayer[RawConfig]) -> None:
self._layers.append(layer)
def add_layers(self, layers: list[ConfigLayer[RawConfig]]) -> None:
self._layers.extend(layers)
@property
def layers(self) -> list[ConfigLayer[RawConfig]]:
return self._layers
async def build(self, force_load: bool = False) -> S:
"""Merge all layers and return a validated schema.
Untrusted and empty layers are skipped.
Pass ``force_load=True`` to bypass caching.
"""
async with self._lock:
internal_layers = self._layers.copy()
layer_dicts: list[_LayerData] = []
for layer in internal_layers:
try:
data = await layer.load(force=force_load)
raw = data.model_dump()
if raw:
layer_dicts.append(_LayerData(name=layer.name, data=raw))
except (UntrustedLayerError, EmptyLayerError):
continue
merged, origins = self._merge_fields(self._schema, layer_dicts)
return self._schema(origins=origins, **merged)
def _merge_fields(
self, schema: type[S], layer_dicts: list[_LayerData]
) -> tuple[dict[str, Any], dict[str, Any]]:
accumulated: dict[str, Any] = defaultdict(dict)
origins: dict[str, Any] = {}
for ld in layer_dicts:
for key, value in ld.data.items():
if key not in schema.model_fields:
continue
field_info = schema.model_fields[key]
annotation = field_info.annotation
if annotation is None:
continue
is_fragment = isinstance(annotation, type) and issubclass(
annotation, ConfigFragment
)
if is_fragment:
if not isinstance(value, dict):
continue
for fragment_key, fragment_value in value.items():
if fragment_key not in annotation.model_fields:
continue
fragment_field = annotation.model_fields[fragment_key]
fragment_meta = MergeFieldMetadata.from_field(fragment_field)
if fragment_meta is None:
continue
accumulated[key][fragment_key] = (
fragment_meta.merge_strategy.apply(
accumulated[key].get(fragment_key),
fragment_value,
key_fn=self._make_key_fn(fragment_meta),
)
)
continue
meta = MergeFieldMetadata.from_field(field_info)
if meta is None:
continue
accumulated[key] = meta.merge_strategy.apply(
accumulated.get(key), value, key_fn=self._make_key_fn(meta)
)
return accumulated, origins
def _make_key_fn(
self, merge_field_meta: MergeFieldMetadata
) -> Callable[[Any], str] | None:
merge_key = merge_field_meta.merge_key
if merge_key is None:
return None
return lambda item: item[merge_key]

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from vibe.core.config.layers.user import UserConfigLayer
__all__ = ["UserConfigLayer"]

View file

@ -0,0 +1,32 @@
from __future__ import annotations
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
class UserConfigLayer(ConfigLayer[RawConfig]):
"""Reads the user-level TOML config file. Always trusted.
Defaults to ``~/.vibe/config.toml`` (via VIBE_HOME).
Pass an explicit ``path`` for testing.
"""
def __init__(self, *, path: Path | None = None, name: str = "user-toml") -> None:
super().__init__(name=name)
self._path = path or (VIBE_HOME.path / "config.toml")
async def _check_trust(self) -> bool:
return True
async def _read_config(self) -> dict[str, Any]:
if not self._path.exists():
return {}
with self._path.open("rb") as f:
return tomllib.load(f)
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
raise NotImplementedError("UserConfigLayer.apply() is not implemented (M2)")

View file

@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Any
from vibe.core.config.builder import ConfigBuilder
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.schema import ConfigSchema
class ConfigOrchestrator[S: ConfigSchema]:
"""Single entry point for config management."""
def __init__(self, builder: ConfigBuilder[S], config: S) -> None:
self._builder = builder
self._config = config
@classmethod
async def create(
cls, *, schema: type[S], layers: list[ConfigLayer[RawConfig]]
) -> ConfigOrchestrator[S]:
"""Build an orchestrator from a schema and an ordered list of layers."""
builder = ConfigBuilder[S](schema)
builder.add_layers(layers)
config = await builder.build()
instance = cls(builder, config)
return instance
@property
def config(self) -> S:
return self._config
def get_layer(self, name: str) -> ConfigLayer[RawConfig]:
for layer in self._builder.layers:
if layer.name == name:
return layer
raise KeyError(f"No layer named {name!r}")
async def reload(self) -> None:
"""Force-reload all layers and atomically replace the config snapshot."""
self._config = await self._builder.build(force_load=True)
async def apply_patch(self, patch: Any) -> None:
raise NotImplementedError("apply_patch() is not implemented (M2)")
async def subscribe(self, callback: Any) -> None:
raise NotImplementedError("subscribe() is not implemented (M3)")

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, PrivateAttr
from pydantic.fields import FieldInfo
from vibe.core.utils.merge import MergeStrategy
@ -19,11 +19,25 @@ class ConfigDefinitionError(TypeError):
class ConfigSchema(BaseModel):
"""Base for composite config schemas composed of fragments and merge-aware fields."""
model_config = ConfigDict(frozen=True)
_origins: dict[str, str] = PrivateAttr(default_factory=dict)
def __init__(
self, *, origins: dict[str, str] | None = None, **data: object
) -> None:
super().__init__(**data)
if origins:
self._origins = origins
def origin_of(self, key: str) -> str | None:
return self._origins.get(key)
@classmethod
def __pydantic_on_complete__(cls) -> None:
super().__pydantic_on_complete__()
if cls.__name__ == "ConfigSchema" and cls.__module__ == __name__:
if not cls.model_fields:
return
for field_name, field_info in cls.model_fields.items():
@ -50,6 +64,8 @@ class ConfigSchema(BaseModel):
class ConfigFragment(BaseModel):
"""Base for domain config groups with merge-aware top-level fields."""
model_config = ConfigDict(frozen=True)
@classmethod
def __pydantic_on_complete__(cls) -> None:
super().__pydantic_on_complete__()

View file

@ -0,0 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ConfigOrigin:
layer_name: str

View file

@ -18,6 +18,7 @@ from vibe.core.session.session_loader import (
METADATA_FILENAME,
SessionLoader,
)
from vibe.core.session.title_format import MAX_TITLE_LENGTH
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
from vibe.core.utils import is_windows, utc_now
from vibe.core.utils.io import read_safe_async
@ -145,7 +146,7 @@ class SessionLogger:
title_source="auto",
)
def _get_title(self, messages: Sequence[LLMMessage]) -> str:
def _fallback_title_from_messages(self, messages: Sequence[LLMMessage]) -> str:
first_user_message = None
for message in messages:
if message.role == Role.user:
@ -153,14 +154,12 @@ class SessionLogger:
break
if first_user_message is None:
title = "Untitled session"
else:
MAX_TITLE_LENGTH = 50
text = str(first_user_message.content)
title = text[:MAX_TITLE_LENGTH]
if len(text) > MAX_TITLE_LENGTH:
title += ""
return "Untitled session"
text = str(first_user_message.content)
title = text[:MAX_TITLE_LENGTH]
if len(text) > MAX_TITLE_LENGTH:
title += ""
return title
def _set_title_state(
@ -183,14 +182,28 @@ class SessionLogger:
self._set_title_state(normalized_title, source="manual")
def needs_initial_auto_title(self) -> bool:
return self.session_metadata is not None and self.session_metadata.title is None
def set_initial_auto_title(self, title: str) -> bool:
if not self.needs_initial_auto_title():
return False
normalized_title = title.strip()
if not normalized_title:
return False
self._set_title_state(normalized_title, source="auto")
return True
def _resolve_title(self, messages: Sequence[LLMMessage]) -> str | None:
if self.session_metadata is None:
return self._get_title(messages)
return self._fallback_title_from_messages(messages)
if self.session_metadata.title_source == "manual":
if self.session_metadata.title is not None:
return self.session_metadata.title
title = self._get_title(messages)
title = self._fallback_title_from_messages(messages)
self._set_title_state(title, source="auto")
return title

View file

@ -0,0 +1,51 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import re
MAX_TITLE_LENGTH = 50
ELLIPSIS = ""
_WHITESPACE_RE = re.compile(r"\s+")
@dataclass(frozen=True, slots=True)
class TextSegment:
text: str
@dataclass(frozen=True, slots=True)
class MentionSegment:
name: str
start_line: int | None = None
end_line: int | None = None
TitleSegment = TextSegment | MentionSegment
def format_session_title(segments: Sequence[TitleSegment]) -> str:
parts = [_render_segment(s) for s in segments]
joined = "".join(parts)
collapsed = _WHITESPACE_RE.sub(" ", joined).strip()
if not collapsed:
return ""
if len(collapsed) > MAX_TITLE_LENGTH:
return collapsed[:MAX_TITLE_LENGTH] + ELLIPSIS
return collapsed
def _render_segment(segment: TitleSegment) -> str:
match segment:
case TextSegment(text=text):
return text
case MentionSegment(name=name, start_line=start, end_line=end):
if start is not None and end is not None:
return f"@{name}:{start}-{end}"
if start is not None:
return f"@{name}:{start}"
return f"@{name}"

View file

@ -456,6 +456,10 @@ class AgentProfileChangedEvent(BaseEvent):
agent_name: str
class SessionTitleUpdatedEvent(BaseEvent):
title: str
class OutputFormat(StrEnum):
TEXT = auto()
JSON = auto()

View file

@ -1,6 +1,10 @@
from __future__ import annotations
from vibe.setup.auth.browser_sign_in import BrowserSignInService, BrowserSignInStatus
from vibe.setup.auth.browser_sign_in import (
BrowserSignInAttempt,
BrowserSignInService,
BrowserSignInStatus,
)
from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError,
BrowserSignInErrorCode,
@ -11,6 +15,7 @@ from vibe.setup.auth.browser_sign_in_gateway import (
from vibe.setup.auth.http_browser_sign_in_gateway import HttpBrowserSignInGateway
__all__ = [
"BrowserSignInAttempt",
"BrowserSignInError",
"BrowserSignInErrorCode",
"BrowserSignInGateway",

View file

@ -0,0 +1,64 @@
from __future__ import annotations
import os
from dotenv import set_key
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.types import Backend
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
def _get_mistral_provider() -> ProviderConfig:
return next(
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
)
def _load_onboarding_provider() -> ProviderConfig:
from vibe.setup.onboarding.context import OnboardingContext
return OnboardingContext.load().provider
def resolve_api_key_provider(provider: ProviderConfig | None = None) -> ProviderConfig:
resolved_provider = provider or _load_onboarding_provider()
if resolved_provider.api_key_env_var:
return resolved_provider
return _get_mistral_provider()
def persist_api_key(
provider: ProviderConfig,
api_key: str,
*,
entrypoint_metadata: EntrypointMetadata | None = None,
) -> str:
env_key = provider.api_key_env_var
if not env_key:
return "env_var_error:<empty>"
try:
os.environ[env_key] = api_key
except ValueError:
return f"env_var_error:{env_key}"
try:
_save_api_key_to_env_file(env_key, api_key)
except (OSError, ValueError) as err:
return f"save_error:{err}"
if provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(
config_getter=VibeConfig,
entrypoint_metadata_getter=lambda: entrypoint_metadata,
)
telemetry.send_onboarding_api_key_added()
except Exception:
pass
return "completed"

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import base64
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
import hashlib
@ -13,7 +14,6 @@ from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInGateway,
BrowserSignInProcess,
)
@ -30,6 +30,15 @@ SleepFn = Callable[[float], Awaitable[None]]
NowFn = Callable[[], datetime]
@dataclass(frozen=True)
class BrowserSignInAttempt:
process_id: str
sign_in_url: str
poll_url: str
expires_at: datetime
code_verifier: str
class BrowserSignInService:
_max_consecutive_poll_failures = 3
@ -51,38 +60,55 @@ class BrowserSignInService:
async def aclose(self) -> None:
await self._gateway.aclose()
async def authenticate(self, status_callback: StatusCallback | None = None) -> str:
async def start_attempt(self) -> BrowserSignInAttempt:
verifier, challenge = _generate_pkce_pair()
process = await self._gateway.create_process(challenge)
self._emit(status_callback, BrowserSignInStatus.OPENING_BROWSER)
self._open_browser_or_raise(process.sign_in_url)
return BrowserSignInAttempt(
process_id=process.process_id,
sign_in_url=process.sign_in_url,
poll_url=process.poll_url,
expires_at=process.expires_at,
code_verifier=verifier,
)
async def complete_attempt(
self,
attempt: BrowserSignInAttempt,
status_callback: StatusCallback | None = None,
) -> str:
self._emit(status_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN)
exchange_token = await self._wait_for_completion(process)
exchange_token = await self._wait_for_completion(attempt)
self._emit(status_callback, BrowserSignInStatus.EXCHANGING)
api_key = await self._gateway.exchange(
process.process_id, exchange_token, verifier
attempt.process_id, exchange_token, attempt.code_verifier
)
self._emit(status_callback, BrowserSignInStatus.COMPLETED)
return api_key
async def _wait_for_completion(self, process: BrowserSignInProcess) -> str:
async def authenticate(self, status_callback: StatusCallback | None = None) -> str:
attempt = await self.start_attempt()
self._emit(status_callback, BrowserSignInStatus.OPENING_BROWSER)
self._open_browser_or_raise(attempt.sign_in_url)
return await self.complete_attempt(attempt, status_callback=status_callback)
async def _wait_for_completion(self, attempt: BrowserSignInAttempt) -> str:
consecutive_poll_failures = 0
while self._now() < process.expires_at:
while self._now() < attempt.expires_at:
try:
payload = await self._gateway.poll(process.poll_url)
payload = await self._gateway.poll(attempt.poll_url)
except BrowserSignInError as err:
if err.code is not BrowserSignInErrorCode.POLL_FAILED:
raise
consecutive_poll_failures += 1
if consecutive_poll_failures >= self._max_consecutive_poll_failures:
raise
await self._sleep_until_next_poll_or_timeout(process.expires_at)
await self._sleep_until_next_poll_or_timeout(attempt.expires_at)
continue
consecutive_poll_failures = 0
match payload.status:
case "pending":
await self._sleep_until_next_poll_or_timeout(process.expires_at)
await self._sleep_until_next_poll_or_timeout(attempt.expires_at)
case "completed":
if payload.exchange_token:
return payload.exchange_token

View file

@ -188,9 +188,21 @@ class HttpBrowserSignInGateway(BrowserSignInGateway):
json={"exchange_token": exchange_token, "code_verifier": code_verifier},
)
except httpx.HTTPError as err:
logger.warning(
"Browser sign-in exchange request failed for api_base_url=%s: %s",
self._api_base_url,
err,
exc_info=True,
)
raise BrowserSignInError(message, code=code) from err
if not response.is_success:
logger.warning(
"Browser sign-in exchange request returned status_code=%s for api_base_url=%s response_detail=%s",
response.status_code,
self._api_base_url,
_build_safe_response_error_detail(response),
)
raise BrowserSignInError(message, code=code)
raw_payload = _response_json_or_raise(response, message=message, code=code)
@ -298,6 +310,29 @@ def _response_json_or_raise(
return dict(payload)
def _build_safe_response_error_detail(response: httpx.Response) -> str:
try:
payload = response.json()
except ValueError:
return "unavailable"
if not isinstance(payload, Mapping):
return "unavailable"
for key in ("detail", "message", "error"):
value = payload.get(key)
if isinstance(value, str):
return _truncate_log_value(value)
return "unavailable"
def _truncate_log_value(value: str, *, max_length: int = 200) -> str:
if len(value) <= max_length:
return value
return f"{value[: max_length - 3]}..."
def _build_safe_url_log_details(value: object) -> str:
if not isinstance(value, str):
return "unavailable"

View file

@ -1,9 +1,7 @@
from __future__ import annotations
import os
from typing import ClassVar
from dotenv import set_key
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, Horizontal, Vertical
@ -13,13 +11,13 @@ from textual.widgets import Input, Link, Static
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import DEFAULT_PROVIDERS, ProviderConfig, VibeConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.config import ProviderConfig
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.types import Backend
from vibe.setup.auth.api_key_persistence import (
persist_api_key,
resolve_api_key_provider,
)
from vibe.setup.onboarding.base import OnboardingScreen
from vibe.setup.onboarding.context import OnboardingContext
PROVIDER_HELP = {
"mistral": ("https://console.mistral.ai/codestral/cli", "Mistral AI Studio")
@ -29,55 +27,6 @@ CONFIG_DOCS_URL = (
)
def _save_api_key_to_env_file(env_key: str, api_key: str) -> None:
GLOBAL_ENV_FILE.path.parent.mkdir(parents=True, exist_ok=True)
set_key(GLOBAL_ENV_FILE.path, env_key, api_key)
def persist_api_key(
provider: ProviderConfig,
api_key: str,
*,
entrypoint_metadata: EntrypointMetadata | None = None,
) -> str:
env_key = provider.api_key_env_var
if not env_key:
return "env_var_error:<empty>"
try:
os.environ[env_key] = api_key
except ValueError:
return f"env_var_error:{env_key}"
try:
_save_api_key_to_env_file(env_key, api_key)
except (OSError, ValueError) as err:
return f"save_error:{err}"
if provider.backend == Backend.MISTRAL:
try:
telemetry = TelemetryClient(
config_getter=VibeConfig,
entrypoint_metadata_getter=lambda: entrypoint_metadata,
)
telemetry.send_onboarding_api_key_added()
except Exception:
pass
return "completed"
def _get_mistral_provider() -> ProviderConfig:
return next(
provider for provider in DEFAULT_PROVIDERS if provider.name == "mistral"
)
def _resolve_onboarding_provider(
provider: ProviderConfig | None = None,
) -> ProviderConfig:
resolved_provider = provider or OnboardingContext.load().provider
if resolved_provider.api_key_env_var:
return resolved_provider
return _get_mistral_provider()
class ApiKeyScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "cancel", "Cancel", show=False),
@ -93,7 +42,7 @@ class ApiKeyScreen(OnboardingScreen):
entrypoint_metadata: EntrypointMetadata | None = None,
) -> None:
super().__init__()
self.provider = _resolve_onboarding_provider(provider)
self.provider = resolve_api_key_provider(provider)
self._entrypoint_metadata = entrypoint_metadata
def _compose_provider_link(self, provider_name: str) -> ComposeResult:

View file

@ -22,11 +22,11 @@ from vibe.setup.auth import (
BrowserSignInService,
BrowserSignInStatus,
)
from vibe.setup.onboarding.base import OnboardingScreen
from vibe.setup.onboarding.screens.api_key import (
_resolve_onboarding_provider,
from vibe.setup.auth.api_key_persistence import (
persist_api_key,
resolve_api_key_provider,
)
from vibe.setup.onboarding.base import OnboardingScreen
PENDING_HINT = "Press M to enter API key manually · Esc to cancel"
ERROR_HINT = "Press R to retry · Press M to enter API key manually · Esc to cancel"
@ -215,7 +215,7 @@ class BrowserSignInScreen(OnboardingScreen):
self._worker = None
self.app.exit(
persist_api_key(
_resolve_onboarding_provider(self.provider),
resolve_api_key_provider(self.provider),
api_key,
entrypoint_metadata=self._entrypoint_metadata,
)