Co-authored-by: Alexis Tacnet <alexis@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
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: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com>
Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com>
Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-04 18:26:35 +02:00 committed by GitHub
parent ad0d5c9520
commit 3f8487f761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
197 changed files with 10819 additions and 2830 deletions

View file

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

View file

@ -133,6 +133,7 @@ from vibe.core.proxy_setup import (
unset_proxy_var,
)
from vibe.core.session.saved_sessions import (
delete_saved_session,
update_saved_session_title,
update_saved_session_title_at_path,
)
@ -187,6 +188,12 @@ logger = logging.getLogger("vibe")
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None:
for tool in NON_INTERACTIVE_DISABLED_TOOLS:
if tool not in config.disabled_tools:
config.disabled_tools.append(tool)
class ForkSessionParams(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@ -202,6 +209,14 @@ class SessionSetTitleRequest(BaseModel):
title: str = Field(min_length=1)
class SessionDeleteRequest(BaseModel):
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
session_id: str = Field(
validation_alias=AliasChoices("session_id", "sessionId"), min_length=1
)
class TelemetrySendNotification(BaseModel):
model_config = ConfigDict(extra="ignore")
@ -496,7 +511,8 @@ class VibeAcpAgentLoop(AcpAgent):
"browser-auth-delegated": {
"attemptId": attempt.process_id,
"expiresAt": (
attempt.expires_at.astimezone(UTC)
attempt.expires_at
.astimezone(UTC)
.isoformat()
.replace("+00:00", "Z")
),
@ -578,7 +594,8 @@ class VibeAcpAgentLoop(AcpAgent):
def _load_config(self) -> VibeConfig:
try:
config = VibeConfig.load(disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS)
config = VibeConfig.load()
_merge_non_interactive_disabled_tools(config)
config.tool_paths.extend(self._get_acp_tool_overrides())
return config
except MissingAPIKeyError as e:
@ -648,6 +665,7 @@ class VibeAcpAgentLoop(AcpAgent):
async def new_session(
self,
cwd: str,
additional_directories: list[str] | None = None,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> NewSessionResponse:
@ -689,9 +707,9 @@ class VibeAcpAgentLoop(AcpAgent):
if self.client_capabilities.fs:
fs = self.client_capabilities.fs
if fs.read_text_file:
overrides.append("read_file")
overrides.append("read")
if fs.write_text_file:
overrides.extend(["write_file", "search_replace"])
overrides.extend(["write_file", "edit"])
return [
VIBE_ROOT / "acp" / "tools" / "builtins" / f"{override}.py"
@ -782,6 +800,13 @@ class VibeAcpAgentLoop(AcpAgent):
return None
def _find_live_session_by_requested_session_id(
self, session_id: str
) -> AcpSessionLoop | None:
return self.sessions.get(
session_id
) or self._find_acp_session_by_vibe_session_id(session_id)
def _load_session_logging_config(self) -> SessionLoggingConfig:
try:
return VibeConfig.load().session_logging
@ -904,6 +929,7 @@ class VibeAcpAgentLoop(AcpAgent):
self,
cwd: str,
session_id: str,
additional_directories: list[str] | None = None,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> LoadSessionResponse | None:
@ -969,10 +995,8 @@ class VibeAcpAgentLoop(AcpAgent):
return True
async def _reload_config(self, session: AcpSessionLoop) -> None:
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS,
)
new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths)
_merge_non_interactive_disabled_tools(new_config)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
async def _apply_model_change(self, session: AcpSessionLoop, model_id: str) -> bool:
@ -1040,7 +1064,11 @@ class VibeAcpAgentLoop(AcpAgent):
@override
async def list_sessions(
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
self,
additional_directories: list[str] | None = None,
cursor: str | None = None,
cwd: str | None = None,
**kwargs: Any,
) -> ListSessionsResponse:
try:
config = VibeConfig.load()
@ -1190,7 +1218,7 @@ class VibeAcpAgentLoop(AcpAgent):
text_prompt = f"{text_prompt}{separator}{block_prompt}"
case "resource_link":
# NOTE: we currently keep more information than just the URI
# making it more detailed than the output of the read_file tool.
# making it more detailed than the output of the read tool.
# This is OK, but might be worth testing how it affect performance.
fields = {
"uri": block.uri,
@ -1370,6 +1398,7 @@ class VibeAcpAgentLoop(AcpAgent):
self,
cwd: str,
session_id: str,
additional_directories: list[str] | None = None,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ForkSessionResponse:
@ -1414,6 +1443,7 @@ class VibeAcpAgentLoop(AcpAgent):
self,
cwd: str,
session_id: str,
additional_directories: list[str] | None = None,
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
**kwargs: Any,
) -> ResumeSessionResponse:
@ -1433,6 +1463,20 @@ class VibeAcpAgentLoop(AcpAgent):
session_id=session_id, update=SessionInfoUpdate(**update_kwargs)
)
async def _delete_saved_session(self, session_id: str) -> None:
try:
await delete_saved_session(session_id, self._load_session_logging_config())
except ValueError as exc:
raise SessionNotFoundError(session_id) from exc
def _live_session_has_saved_history(self, session: AcpSessionLoop) -> bool:
logger = session.agent_loop.session_logger
return (
logger.enabled
and logger.session_dir is not None
and logger.metadata_filepath.exists()
)
async def _persist_live_session_title(
self, session: AcpSessionLoop, title: str
) -> dict[str, Any] | None:
@ -1457,6 +1501,40 @@ class VibeAcpAgentLoop(AcpAgent):
f"Invalid ACP session title request: {exc}"
) from exc
async def _handle_session_delete(self, params: dict[str, Any]) -> dict[str, Any]:
try:
request = SessionDeleteRequest.model_validate(params)
except ValidationError as exc:
raise InvalidRequestError(
f"Invalid ACP session delete request: {exc}"
) from exc
live_session = self._find_live_session_by_requested_session_id(
request.session_id
)
if live_session is None:
await self._delete_saved_session(request.session_id)
return {}
saved_session_id = live_session.agent_loop.session_id
has_saved_history = self._live_session_has_saved_history(live_session)
await self.close_session(live_session.id)
if not has_saved_history:
return {}
try:
await delete_saved_session(
saved_session_id, self._load_session_logging_config()
)
except ValueError as exc:
raise InternalError(
f"Failed to delete saved session {saved_session_id}: {exc}"
) from exc
return {}
async def _handle_session_set_title(self, params: dict[str, Any]) -> dict[str, Any]:
try:
request = SessionSetTitleRequest.model_validate(params)
@ -1465,9 +1543,9 @@ class VibeAcpAgentLoop(AcpAgent):
f"Invalid ACP session title request: {exc}"
) from exc
live_session = self.sessions.get(
live_session = self._find_live_session_by_requested_session_id(
request.session_id
) or self._find_acp_session_by_vibe_session_id(request.session_id)
)
if live_session is None:
try:
metadata = await update_saved_session_title(
@ -1563,6 +1641,9 @@ class VibeAcpAgentLoop(AcpAgent):
if method == "session/set_title":
return await self._handle_session_set_title(params)
if method == "session/delete":
return await self._handle_session_delete(params)
raise NotImplementedMethodError(method)
@override
@ -1681,10 +1762,8 @@ class VibeAcpAgentLoop(AcpAgent):
async def _reload_session_config(self, session: AcpSessionLoop) -> None:
"""Reload config from disk and reinitialize the agent loop."""
new_config = VibeConfig.load(
tool_paths=session.agent_loop.config.tool_paths,
disabled_tools=NON_INTERACTIVE_DISABLED_TOOLS,
)
new_config = VibeConfig.load(tool_paths=session.agent_loop.config.tool_paths)
_merge_non_interactive_disabled_tools(new_config)
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
async def _handle_reload(

View file

@ -18,28 +18,22 @@ from vibe.acp.tools.session_update import (
resolve_kind,
)
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.search_replace import (
SearchReplace as CoreSearchReplaceTool,
SearchReplaceArgs,
SearchReplaceResult,
)
from vibe.core.tools.builtins.edit import Edit as CoreEditTool, EditArgs, EditResult
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils.io import ReadSafeResult, normalize_newlines
class AcpSearchReplaceState(BaseToolState, AcpToolState):
file_backup_content: str | None = None
class AcpEditState(BaseToolState, AcpToolState):
pass
class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
state: AcpSearchReplaceState
prompt_path = (
VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "search_replace.md"
)
class Edit(CoreEditTool, BaseAcpTool[AcpEditState]):
state: AcpEditState
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "edit.md"
@classmethod
def _get_tool_state_class(cls) -> type[AcpSearchReplaceState]:
return AcpSearchReplaceState
def _get_tool_state_class(cls) -> type[AcpEditState]:
return AcpEditState
async def _read_file(self, file_path: Path) -> ReadSafeResult:
client, session_id = self._load_state()
@ -49,58 +43,44 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
session_id=session_id, path=str(file_path)
)
except Exception as e:
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
raise ToolError(f"Error reading {file_path}: {e}") from e
self.state.file_backup_content = response.content
text, newline = normalize_newlines(response.content)
return ReadSafeResult(text, "utf-8", newline)
async def _backup_file(self, file_path: Path) -> None:
if self.state.file_backup_content is None:
return
await self._write_via_client(
file_path.with_suffix(file_path.suffix + ".bak"),
self.state.file_backup_content,
)
async def _write_file(
self, file_path: Path, content: str, encoding: str, newline: str
) -> None:
await self._write_via_client(file_path, content.replace("\n", newline))
async def _write_via_client(self, file_path: Path, content: str) -> None:
client, session_id = self._load_state()
try:
await client.write_text_file(
session_id=session_id, path=str(file_path), content=content
session_id=session_id,
path=str(file_path),
content=content.replace("\n", newline),
)
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
if not isinstance(event.args, SearchReplaceArgs):
return fallback_tool_call(event, "search_replace")
if not isinstance(event.args, EditArgs):
return fallback_tool_call(event, "edit")
args = event.args
blocks = cls._parse_search_replace_blocks(args.content)
return ToolCallStart(
session_update="tool_call",
title=cls.get_call_display(event).summary,
title=cls.format_call_display(args).summary,
tool_call_id=event.tool_call_id,
kind=resolve_kind(event.tool_name),
content=[
FileEditToolCallContent(
type="diff",
path=args.file_path,
old_text=block.search,
new_text=block.replace,
old_text=args.old_string,
new_text=args.new_string,
)
for block in blocks
],
locations=[ToolCallLocation(path=str(Path(args.file_path).resolve()))],
raw_input=args.model_dump_json(),
@ -109,13 +89,11 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
if failure := failed_tool_result(event, SearchReplaceResult):
if failure := failed_tool_result(event, EditResult):
return failure
result = event.result
assert isinstance(result, SearchReplaceResult)
blocks = cls._parse_search_replace_blocks(result.content)
assert isinstance(result, EditResult)
return ToolCallProgress(
session_update="tool_call_update",
@ -126,10 +104,9 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
FileEditToolCallContent(
type="diff",
path=result.file,
old_text=block.search,
new_text=block.replace,
old_text=result.old_string,
new_text=result.new_string,
)
for block in blocks
],
locations=[ToolCallLocation(path=str(Path(result.file).resolve()))],
raw_output=result.model_dump_json(),

View file

@ -21,41 +21,59 @@ from vibe.acp.tools.session_update import (
resolve_kind,
)
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
ReadFile as CoreReadFileTool,
ReadFileArgs,
ReadFileResult,
ReadFileState,
_ReadResult,
from vibe.core.tools.builtins.read import (
Read as CoreReadTool,
ReadArgs,
ReadResult,
ReadState,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
ReadFileResult = ReadFileResult
class AcpReadFileState(ReadFileState, AcpToolState):
class AcpReadState(ReadState, AcpToolState):
pass
class ReadFile(
CoreReadFileTool,
BaseAcpTool[AcpReadFileState],
class Read(
CoreReadTool,
BaseAcpTool[AcpReadState],
ToolCallSessionUpdateProtocol,
ToolResultSessionUpdateProtocol,
):
state: AcpReadFileState
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read_file.md"
state: AcpReadState
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read.md"
@classmethod
def _get_tool_state_class(cls) -> type[AcpReadFileState]:
return AcpReadFileState
def _get_tool_state_class(cls) -> type[AcpReadState]:
return AcpReadState
async def _read_file(
self, args: ReadArgs, file_path: Path
) -> tuple[list[str], int | None, bool]:
client, session_id = self._load_state()
line = args.offset
limit = args.limit
try:
response = await client.read_text_file(
session_id=session_id, path=str(file_path), line=line, limit=limit + 1
)
except Exception as e:
raise ToolError(f"Error reading {file_path}: {e}") from e
lines = response.content.splitlines()
total_lines = 0 if not response.content else None
was_truncated = len(lines) > limit
lines = lines[:limit]
return lines, total_lines, was_truncated
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
if not isinstance(event.args, ReadFileArgs):
return fallback_tool_call(event, "read_file")
if not isinstance(event.args, ReadArgs):
return fallback_tool_call(event, "read")
resolved = str(Path(event.args.path).resolve())
resolved = str(Path(event.args.file_path).resolve())
return ToolCallStart(
session_update="tool_call",
@ -78,19 +96,19 @@ class ReadFile(
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
if failure := failed_tool_result(event, ReadFileResult):
if failure := failed_tool_result(event, ReadResult):
return failure
result = event.result
assert isinstance(result, ReadFileResult)
resolved = str(Path(result.path).resolve())
assert isinstance(result, ReadResult)
resolved = str(Path(result.file_path).resolve())
locations = [
ToolCallLocation(
path=resolved,
field_meta={
"type": "file_range",
"offset": result.offset,
"limit": result.lines_read,
"offset": result.start_line,
"limit": result.num_lines,
},
)
]
@ -112,26 +130,3 @@ class ReadFile(
locations=locations,
field_meta={"tool_name": event.tool_name},
)
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
client, session_id = self._load_state()
line = args.offset + 1 if args.offset > 0 else None
limit = args.limit
try:
response = await client.read_text_file(
session_id=session_id, path=str(file_path), line=line, limit=limit
)
except Exception as e:
raise ToolError(f"Error reading {file_path}: {e}") from e
content_lines = response.content.splitlines(keepends=True)
lines_read = len(content_lines)
bytes_read = sum(len(line.encode("utf-8")) for line in content_lines)
was_truncated = args.limit is not None and lines_read >= args.limit
return _ReadResult(
lines=content_lines, bytes_read=bytes_read, was_truncated=was_truncated
)

View file

@ -1,12 +1,11 @@
from __future__ import annotations
from typing import cast
from acp.helpers import SessionUpdate
from acp.schema import AgentPlanUpdate, PlanEntry, PlanEntryPriority, PlanEntryStatus
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
from vibe.acp.tools.session_update import failed_tool_result
from vibe.core.tools.builtins.todo import (
Todo as CoreTodoTool,
TodoArgs,
@ -38,7 +37,11 @@ class Todo(CoreTodoTool, BaseAcpTool[AcpTodoState]):
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
result = cast(TodoResult, event.result)
if failure := failed_tool_result(event, TodoResult):
return failure
result = event.result
assert isinstance(result, TodoResult)
todos = [todo for todo in result.todos if todo.status != TodoStatus.CANCELLED]
matched_status: dict[TodoStatus, PlanEntryStatus] = {
TodoStatus.PENDING: "pending",

View file

@ -40,12 +40,12 @@ def _cancellation_raw_output(event: ToolResultEvent) -> str | None:
TOOL_KIND_MAP: dict[str, ToolKind] = {
"read_file": "read",
"read": "read",
"grep": "search",
"web_search": "search",
"web_fetch": "fetch",
"write_file": "edit",
"search_replace": "edit",
"edit": "edit",
"bash": "execute",
"skill": "read",
}

View file

@ -154,16 +154,27 @@ def copy_text_to_clipboard(
if not text:
return None
try:
_copy_to_clipboard(text)
if try_copy_text_to_clipboard(text):
if show_toast:
app.notify(success_message, severity="information", timeout=2, markup=False)
return text
app.notify(
"Failed to copy - clipboard not available", severity="warning", timeout=3
)
return None
def try_copy_text_to_clipboard(text: str) -> bool:
if not text:
return False
try:
_copy_to_clipboard(text)
except Exception:
app.notify(
"Failed to copy - clipboard not available", severity="warning", timeout=3
)
return None
return False
return True
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:

View file

@ -9,8 +9,14 @@ from rich import print as rprint
from vibe import __version__
from vibe.core.config.harness_files import init_harness_files_manager
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
from vibe.core.trusted_folders import (
find_git_repo_ancestor,
find_repo_trustable_files_for_cwd,
find_trustable_files,
trusted_folders_manager,
)
from vibe.setup.trusted_folders.trust_folder_dialog import (
TrustDecision,
TrustDialogQuitException,
ask_trust_folder,
)
@ -148,28 +154,49 @@ def check_and_resolve_trusted_folder(cwd: Path) -> None:
if cwd.resolve() == Path.home().resolve():
return
if trusted_folders_manager.is_trusted(cwd) is True:
return
if trusted_folders_manager.is_explicitly_untrusted(cwd):
return
repo_root = find_git_repo_ancestor(cwd)
detected_files = find_trustable_files(cwd)
if not detected_files:
repo_detected_files = find_repo_trustable_files_for_cwd(cwd, repo_root)
if not detected_files and not repo_detected_files:
return
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
if is_folder_trusted is not None:
return
offer_repo_trust = (
repo_root is not None
and trusted_folders_manager.is_trusted(repo_root) is not True
and not trusted_folders_manager.is_explicitly_untrusted(repo_root)
)
repo_explicitly_untrusted = (
repo_root is not None
and trusted_folders_manager.is_explicitly_untrusted(repo_root)
)
try:
is_folder_trusted = ask_trust_folder(cwd, detected_files)
decision = ask_trust_folder(
cwd,
repo_root,
detected_files,
repo_detected_files=repo_detected_files,
offer_repo_trust=offer_repo_trust,
repo_explicitly_untrusted=repo_explicitly_untrusted,
)
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
sys.exit(0)
except Exception as e:
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
return
if is_folder_trusted is True:
trusted_folders_manager.add_trusted(cwd)
elif is_folder_trusted is False:
trusted_folders_manager.add_untrusted(cwd)
match decision:
case TrustDecision.TRUST_REPO if repo_root is not None:
trusted_folders_manager.add_trusted(repo_root)
case TrustDecision.TRUST_CWD:
trusted_folders_manager.add_trusted(cwd)
case TrustDecision.DECLINE:
trusted_folders_manager.add_untrusted(cwd)
def main() -> None:

View file

@ -117,7 +117,7 @@ def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
if payload.is_chat_pro_plan():
return "[Subscription] Pro"
if payload.is_free_api_plan():
return "[API] Experiment plan"
return "Free"
if payload.is_paid_api_plan():
return "[API] Scale plan"
if payload.is_free_mistral_code_plan():

View file

@ -136,10 +136,15 @@ 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 (
PathPromptPayload,
PathResource,
build_path_prompt_payload,
build_title_segments,
)
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.autocompletion.path_prompt_adapter import (
extract_image_resources,
render_path_prompt_from_payload,
)
from vibe.core.config import DEFAULT_THEME, VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
from vibe.core.hooks.models import HookStartEvent
@ -147,6 +152,7 @@ from vibe.core.log_reader import LogReader
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.rewind import RewindError
from vibe.core.session.image_snapshot import ImageSnapshotError, snapshot_image
from vibe.core.session.resume_sessions import (
ResumeSessionInfo,
list_local_resume_sessions,
@ -172,15 +178,18 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.connectors import compute_connector_counts
from vibe.core.tools.mcp_settings import persist_mcp_toggle
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
from vibe.core.types import (
MAX_IMAGE_BYTES,
MAX_IMAGES_PER_MESSAGE,
AgentStats,
ApprovalResponse,
BaseEvent,
ContextTooLongError,
ImageAttachment,
LLMMessage,
RateLimitError,
Role,
@ -199,20 +208,6 @@ def _is_vscode_family_terminal() -> bool:
return detect_terminal() in _VSCODE_FAMILY_TERMINALS
def _compute_connector_counts(
config: VibeConfig, connector_registry: ConnectorRegistry | None
) -> tuple[int, int]:
total = connector_registry.connector_count if connector_registry else 0
if total == 0:
return (0, 0)
disabled_names = {c.name for c in config.connectors if c.disabled}
known_names = set(
connector_registry.get_connector_names() if connector_registry else []
)
enabled = total - len(disabled_names & known_names)
return (enabled, total)
class BottomApp(StrEnum):
"""Bottom panel app types.
@ -335,6 +330,12 @@ class StartupOptions:
is_resuming_session: bool = False
@dataclass(frozen=True, slots=True)
class _ImageAttachmentRejection:
message: str
no_vision: bool = False
class VibeApp(App): # noqa: PLR0904
ENABLE_COMMAND_PALETTE = False
CSS_PATH = "app.tcss"
@ -487,13 +488,13 @@ class VibeApp(App): # noqa: PLR0904
def compose(self) -> ComposeResult:
with ChatScroll(id="chat"):
connectors_enabled, connectors_total = _compute_connector_counts(
connectors_connected, connectors_total = compute_connector_counts(
self.config, self.agent_loop.connector_registry
)
self._banner = Banner(
config=self.config,
skill_manager=self.agent_loop.skill_manager,
connectors_enabled=connectors_enabled,
connectors_connected=connectors_connected,
connectors_total=connectors_total,
)
yield self._banner
@ -569,7 +570,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.start_initialize_experiments()
self.call_after_refresh(self._refresh_banner)
self._show_hook_config_issues_once()
self._show_config_issues()
self.run_worker(self._watch_init_completion(), exclusive=False)
@ -581,8 +582,11 @@ class VibeApp(App): # noqa: PLR0904
gc.collect()
gc.freeze()
def _show_hook_config_issues_once(self) -> None:
for issue in self.agent_loop.hook_config_issues:
def _show_config_issues(self) -> None:
for issue in (
*self.agent_loop.hook_config_issues,
*self.agent_loop.skill_manager.config_issues,
):
self.notify(
f"{issue.file}\n{issue.message}",
severity="warning",
@ -759,6 +763,83 @@ class VibeApp(App): # noqa: PLR0904
await self._loading_widget.remove()
self._loading_widget = None
async def _resolve_turn_images(
self, payload: PathPromptPayload, prebuilt: list[ImageAttachment] | None
) -> list[ImageAttachment] | None:
if prebuilt is not None:
return prebuilt
return await self._prepare_images_or_abort(payload)
async def _prepare_images_or_abort(
self, payload: PathPromptPayload
) -> list[ImageAttachment] | None:
result = await self._build_image_attachments(payload)
if isinstance(result, _ImageAttachmentRejection):
await self._remove_loading_widget()
if result.no_vision:
await self._mount_and_scroll(
ErrorMessage(result.message, show_border=False)
)
else:
await self._mount_and_scroll(
ErrorMessage(result.message, collapsed=self._tools_collapsed)
)
return None
return result
async def _build_image_attachments(
self, payload: PathPromptPayload
) -> list[ImageAttachment] | _ImageAttachmentRejection:
image_resources = extract_image_resources(payload)
if not image_resources:
return []
if len(image_resources) > MAX_IMAGES_PER_MESSAGE:
return _ImageAttachmentRejection(
f"Too many image attachments (got {len(image_resources)}, "
f"max {MAX_IMAGES_PER_MESSAGE})."
)
try:
active_model = self.agent_loop.config.get_active_model()
except ValueError:
active_model = None
if active_model is not None and not active_model.supports_images:
return _ImageAttachmentRejection(
f"Model `{active_model.alias}` does not support images. "
f"Switch with /model, remove the attachment, or ask me to enable the support for this model.",
no_vision=True,
)
attachments: list[ImageAttachment] = []
session_dir = self.agent_loop.session_logger.session_dir
for resource in image_resources:
result = self._snapshot_single_image(resource, session_dir)
if isinstance(result, str):
return _ImageAttachmentRejection(result)
attachments.append(result)
return attachments
def _snapshot_single_image(
self, resource: PathResource, session_dir: Path | None
) -> ImageAttachment | str:
try:
size = resource.path.stat().st_size
except OSError as e:
return f"Cannot read image {resource.alias}: {e}"
if size > MAX_IMAGE_BYTES:
return (
f"Image `{resource.alias}` is "
f"{size / (1024 * 1024):.1f} MB; max is "
f"{MAX_IMAGE_BYTES // (1024 * 1024)} MB."
)
try:
return snapshot_image(
resource.path, alias=resource.alias, session_dir=session_dir
)
except ImageSnapshotError as e:
return f"Failed to attach image {resource.alias}: {e}"
async def on_config_app_open_model_picker(
self, _message: ConfigApp.OpenModelPicker
) -> None:
@ -1203,10 +1284,20 @@ class VibeApp(App): # noqa: PLR0904
await self._handle_remote_user_message(message)
return
prompt_payload = build_path_prompt_payload(message, base_dir=Path.cwd())
images = await self._prepare_images_or_abort(prompt_payload)
if images is None:
input_widget = self.query_one(ChatInputContainer)
if not input_widget.value:
input_widget.value = message
return
# message_index is where the user message will land in agent_loop.messages
# (checkpoint is created in agent_loop.act())
message_index = len(self.agent_loop.messages)
user_message = UserMessage(message, message_index=message_index)
user_message = UserMessage(
message, message_index=message_index, images=images or None
)
await self._mount_and_scroll(user_message)
if self._feedback_bar_manager.should_show(self.agent_loop):
@ -1217,7 +1308,12 @@ 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, title_source=title_source)
self._handle_agent_loop_turn(
message,
title_source=title_source,
prebuilt_images=images,
prebuilt_payload=prompt_payload,
)
)
async def _handle_remote_user_message(self, message: str) -> None:
@ -1419,7 +1515,12 @@ class VibeApp(App): # noqa: PLR0904
)
async def _handle_agent_loop_turn(
self, prompt: str, *, title_source: str | None = None
self,
prompt: str,
*,
title_source: str | None = None,
prebuilt_images: list[ImageAttachment] | None = None,
prebuilt_payload: PathPromptPayload | None = None,
) -> None:
self._agent_running = True
@ -1429,7 +1530,9 @@ class VibeApp(App): # noqa: PLR0904
await self._handle_agent_loop_init()
await self._ensure_loading_widget()
message_id = str(uuid4())
prompt_payload = build_path_prompt_payload(prompt, base_dir=Path.cwd())
prompt_payload = prebuilt_payload or build_path_prompt_payload(
prompt, base_dir=Path.cwd()
)
if prompt_payload.all_resources:
context_types: dict[str, int] = {}
for r in prompt_payload.all_resources:
@ -1446,7 +1549,12 @@ class VibeApp(App): # noqa: PLR0904
file_extensions=file_ext_counts or None,
message_id=message_id,
)
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
images = await self._resolve_turn_images(prompt_payload, prebuilt_images)
if images is None:
return
rendered_prompt = render_path_prompt_from_payload(
prompt_payload, skip_images=True
)
auto_title: str | None = None
if self.agent_loop.session_logger.needs_initial_auto_title():
auto_title = (
@ -1461,7 +1569,10 @@ class VibeApp(App): # noqa: PLR0904
self._narrator_manager.on_turn_start(rendered_prompt)
async with aclosing(
self.agent_loop.act(
rendered_prompt, client_message_id=message_id, auto_title=auto_title
rendered_prompt,
client_message_id=message_id,
auto_title=auto_title,
images=images or None,
)
) as events:
await self._handle_agent_loop_events(events)
@ -1752,7 +1863,7 @@ class VibeApp(App): # noqa: PLR0904
tool_manager=self.agent_loop.tool_manager,
initial_server=name,
connector_registry=connector_registry,
get_connector_configs=lambda: self.agent_loop.config.connectors,
get_vibe_config=lambda: self.agent_loop.config,
refresh_callback=self._refresh_mcp_browser,
)
)
@ -2059,21 +2170,37 @@ class VibeApp(App): # noqa: PLR0904
self._narrator_manager.sync()
if self._banner:
ce, ct = _compute_connector_counts(
cc, ct = compute_connector_counts(
base_config, self.agent_loop.connector_registry
)
self._banner.set_state(
base_config,
self.agent_loop.skill_manager,
connectors_enabled=ce,
connectors_connected=cc,
connectors_total=ct,
plan_description=plan_title(self._plan_info),
)
self._show_config_issues()
await self._mount_and_scroll(
UserCommandMessage(
"Configuration reloaded (includes agent instructions and skills)."
)
)
stripped_count = (
self.agent_loop.count_history_images_unsupported_by_active_model()
)
if stripped_count > 0:
try:
model_alias = self.agent_loop.config.get_active_model().alias
except ValueError:
model_alias = "the active model"
noun = "image" if stripped_count == 1 else "images"
await self._mount_and_scroll(
WarningMessage(
f"{stripped_count} {noun} from earlier turns will be omitted "
f"when sending to {model_alias} (no vision support)."
)
)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
@ -2849,13 +2976,13 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_banner(self) -> None:
if self._banner:
ce, ct = _compute_connector_counts(
cc, ct = compute_connector_counts(
self.config, self.agent_loop.connector_registry
)
self._banner.set_state(
self.config,
self.agent_loop.skill_manager,
connectors_enabled=ce,
connectors_connected=cc,
connectors_total=ct,
plan_description=plan_title(self._plan_info),
)

View file

@ -292,6 +292,22 @@ Markdown {
height: auto;
}
.user-message-row {
width: 100%;
height: auto;
}
.user-message-attachments {
width: 100%;
height: auto;
padding-left: 2;
color: $text-muted;
&:ansi {
text-style: dim;
}
}
.user-message-prompt {
width: auto;
height: auto;
@ -934,7 +950,7 @@ StatusMessage {
#approval-app {
width: 100%;
height: auto;
/* height set by ApprovalApp._recompute_height; max-height below caps it */
max-height: 70vh;
background: transparent;
border: solid $foreground-muted;
@ -945,18 +961,16 @@ StatusMessage {
#approval-options {
width: 100%;
height: auto;
dock: bottom;
}
#approval-content {
width: 100%;
height: auto;
height: 1fr;
}
.approval-tool-info-scroll {
width: 100%;
height: auto;
max-height: 50vh;
height: 1fr;
}
.approval-title {

View file

@ -9,6 +9,7 @@ from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical, VerticalScroll
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
@ -87,13 +88,24 @@ class ApprovalApp(Container):
self.required_permissions = required_permissions or []
self.selected_option = 0
self.content_container: Vertical | None = None
self.title_widget: Static | None = None
self.title_widget = NoMarkupStatic(
self._build_title(), classes="approval-title"
)
self.tool_info_container: Vertical | None = None
self.option_widgets: list[Static] = []
self.help_widget: Static | None = None
self._mount_time: float = 0.0
def compose(self) -> ComposeResult:
with Vertical(id="approval-content"):
yield self.title_widget
with VerticalScroll(classes="approval-tool-info-scroll"):
self.tool_info_container = Vertical(
classes="approval-tool-info-container"
)
yield self.tool_info_container
with Vertical(id="approval-options"):
yield NoMarkupStatic("")
for _ in range(self.NUM_OPTIONS):
@ -105,17 +117,6 @@ class ApprovalApp(Container):
)
yield self.help_widget
with Vertical(id="approval-content"):
title = self._build_title()
self.title_widget = NoMarkupStatic(title, classes="approval-title")
yield self.title_widget
with VerticalScroll(classes="approval-tool-info-scroll"):
self.tool_info_container = Vertical(
classes="approval-tool-info-container"
)
yield self.tool_info_container
def _build_title(self) -> str:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
@ -127,6 +128,33 @@ class ApprovalApp(Container):
await self._update_tool_info()
self._update_options()
self.focus()
self._recompute_height()
self.screen.screen_layout_refresh_signal.subscribe(
self, lambda _screen: self._recompute_height()
)
def _recompute_height(self) -> None:
"""Manual sizing: the scroll uses `1fr`, so `height: auto` cannot shrink to fit."""
options = self.query_one("#approval-options", Vertical)
scroll = self.query_one(".approval-tool-info-scroll", VerticalScroll)
natural_height = (
options.outer_size.height
+ self.title_widget.outer_size.height
+ scroll.virtual_size.height
+ self.gutter.height
)
# Cap the natural height if greater than max_height
if max_height := self.styles.max_height:
viewport = self.app.size
parent_size = (
self.parent.size if isinstance(self.parent, Widget) else viewport
)
resolved_max_height = int(max_height.resolve(parent_size, viewport))
natural_height = min(natural_height, resolved_max_height)
self.styles.height = natural_height
def is_within_grace_period(self) -> bool:
return (time.monotonic() - self._mount_time) < _INPUT_GRACE_PERIOD_S

View file

@ -25,7 +25,7 @@ class BannerState:
models_count: int = 0
mcp_servers_enabled: int = 0
mcp_servers_total: int = 0
connectors_enabled: int = 0
connectors_connected: int = 0
connectors_total: int = 0
skills_count: int = 0
plan_description: str | None = None
@ -38,7 +38,7 @@ class Banner(Static):
self,
config: VibeConfig,
skill_manager: SkillManager,
connectors_enabled: int = 0,
connectors_connected: int = 0,
connectors_total: int = 0,
**kwargs: Any,
) -> None:
@ -47,7 +47,7 @@ class Banner(Static):
self._initial_state = self._build_state(
config=config,
skill_manager=skill_manager,
connectors_enabled=connectors_enabled,
connectors_connected=connectors_connected,
connectors_total=connectors_total,
plan_description=None,
)
@ -91,14 +91,14 @@ class Banner(Static):
self,
config: VibeConfig,
skill_manager: SkillManager,
connectors_enabled: int = 0,
connectors_connected: int = 0,
connectors_total: int = 0,
plan_description: str | None = None,
) -> None:
self.state = self._build_state(
config,
skill_manager,
connectors_enabled,
connectors_connected,
connectors_total,
plan_description,
)
@ -107,7 +107,7 @@ class Banner(Static):
def _build_state(
config: VibeConfig,
skill_manager: SkillManager,
connectors_enabled: int = 0,
connectors_connected: int = 0,
connectors_total: int = 0,
plan_description: str | None = None,
) -> BannerState:
@ -120,7 +120,7 @@ class Banner(Static):
models_count=len(config.models),
mcp_servers_enabled=len(enabled_servers),
mcp_servers_total=len(all_servers),
connectors_enabled=connectors_enabled,
connectors_connected=connectors_connected,
connectors_total=connectors_total,
skills_count=skill_manager.custom_skills_count,
plan_description=plan_description,
@ -130,13 +130,13 @@ class Banner(Static):
parts = [_pluralize(self.state.models_count, "model")]
# Format as x/y for MCP servers and connectors (only when enabled != total)
if self.state.connectors_total > 0:
if self.state.connectors_enabled != self.state.connectors_total:
if self.state.connectors_connected != self.state.connectors_total:
connector_str = (
f"{self.state.connectors_enabled}/{self.state.connectors_total} connector"
f"{self.state.connectors_connected}/{self.state.connectors_total} connector"
+ ("s" if self.state.connectors_total != 1 else "")
)
else:
connector_str = _pluralize(self.state.connectors_enabled, "connector")
connector_str = _pluralize(self.state.connectors_connected, "connector")
parts.append(connector_str)
# Always show MCP servers count (even if 0/0)
if self.state.mcp_servers_enabled != self.state.mcp_servers_total:

View file

@ -0,0 +1,117 @@
from __future__ import annotations
from pathlib import Path
from vibe.core.types import IMAGE_EXTENSIONS
_QUOTES: frozenset[str] = frozenset({"'", '"'})
_PATH_ROOTS: frozenset[str] = frozenset({"/", "~"})
_TOKEN_BOUNDARY_CHARS: frozenset[str] = frozenset("(<[")
_MIN_QUOTED_LEN = 2
def maybe_prepend_at_for_image_path(pasted: str) -> str:
text = pasted.strip()
if not text or "\n" in text or "\r" in text:
return pasted
candidate = _unescape_spaces(_strip_matched_quotes(text))
if not candidate or candidate[0] not in _PATH_ROOTS:
return pasted
if not _is_image_file(candidate):
return pasted
return f"@{_quote_if_needed(candidate)}"
def rewrite_bare_image_paths_in_text(text: str) -> str:
"""Scan `text` for bare absolute image paths (raw, backslash-escaped, or
quoted) and prepend `@` to each. Idempotent: tokens already preceded by
`@` are not touched. Used as a text-changed hook to recover the UX of
drag-and-drop in terminals that do not emit bracketed-paste sequences.
"""
# Per-keystroke fast path: a bare path token must start with `/`, `~`, or
# a quote, so skip the per-token stat() walk if none are present.
if not any(ch in text for ch in "/~'\""):
return text
out: list[str] = []
pos = 0
while pos < len(text):
if _at_token_boundary(text, pos):
token, end = _extract_path_token(text, pos)
if token is not None and _is_image_file(token):
out.append(f"@{_quote_if_needed(token)}")
pos = end
continue
out.append(text[pos])
pos += 1
return "".join(out)
def _is_image_file(candidate: str) -> bool:
try:
resolved = Path(candidate).expanduser()
except RuntimeError:
# `~unknownuser/...` raises when the user cannot be resolved.
return False
return (
resolved.is_absolute()
and resolved.suffix.lower() in IMAGE_EXTENSIONS
and resolved.is_file()
)
def _quote_if_needed(path: str) -> str:
return f"'{path}'" if " " in path else path
def _unescape_spaces(text: str) -> str:
return text.replace("\\ ", " ")
def _strip_matched_quotes(text: str) -> str:
if len(text) >= _MIN_QUOTED_LEN and text[0] in _QUOTES and text[-1] == text[0]:
return text[1:-1]
return text
def _at_token_boundary(text: str, pos: int) -> bool:
if pos == 0:
return True
prev = text[pos - 1]
if prev == "@":
return False
return prev.isspace() or prev in _TOKEN_BOUNDARY_CHARS
def _extract_path_token(text: str, pos: int) -> tuple[str | None, int]:
head = text[pos]
if head in _QUOTES:
return _extract_quoted(text, pos, quote=head)
if head in _PATH_ROOTS:
return _extract_bare(text, pos)
return None, pos
def _extract_quoted(text: str, start: int, *, quote: str) -> tuple[str | None, int]:
end = text.find(quote, start + 1)
if end == -1:
return None, start
return text[start + 1 : end], end + 1
def _extract_bare(text: str, start: int) -> tuple[str | None, int]:
out: list[str] = []
end = start
n = len(text)
while end < n:
ch = text[end]
if ch == "\\" and end + 1 < n and text[end + 1] == " ":
out.append(" ")
end += 2
continue
if ch.isspace():
break
out.append(ch)
end += 1
if end == start:
return None, start
return "".join(out), end

View file

@ -14,6 +14,10 @@ from vibe.cli.textual_ui.external_editor import ExternalEditor
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
from vibe.cli.textual_ui.widgets.chat_input.paste_path import (
maybe_prepend_at_for_image_path,
rewrite_bare_image_paths_in_text,
)
from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space
from vibe.cli.voice_manager.voice_manager_port import (
RecordingStartError,
@ -33,6 +37,8 @@ class ChatTextArea(TextArea):
show=False,
priority=True,
),
Binding("shift+backspace", "delete_left", "Delete character left", show=False),
Binding("shift+delete", "delete_right", "Delete character right", show=False),
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
]
@ -91,6 +97,17 @@ class ChatTextArea(TextArea):
def on_click(self, event: events.Click) -> None:
self._mark_cursor_moved_if_needed()
async def _on_paste(self, event: events.Paste) -> None:
# Best-effort: terminals that emit bracketed paste sequences will
# land here, and we can rewrite event.text directly. event.stop()
# prevents App.on_event from auto-forwarding the Paste back to the
# focused widget (which would otherwise dispatch this handler a
# second time and double-insert). TextArea._on_paste in the same
# MRO still runs inside this dispatch cycle and performs the
# single insertion using the mutated text.
event.text = maybe_prepend_at_for_image_path(event.text)
event.stop()
def action_insert_newline(self) -> None:
self.insert("\n")
@ -106,6 +123,19 @@ class ChatTextArea(TextArea):
self.insert(result)
def on_text_area_changed(self, event: TextArea.Changed) -> None:
# Fallback for terminals that deliver drag-n-drop as a bulk text
# edit rather than a Paste event (so _on_paste never fires).
# Scan the full text for any bare absolute image path token and
# rewrite it to @<path>. Idempotent: tokens already preceded by
# `@` are skipped, so this is safe to run on every change.
current = self.text
rewritten = rewrite_bare_image_paths_in_text(current)
if rewritten != current:
self.text = rewritten
last_line = rewritten.rsplit("\n", 1)[-1]
self.move_cursor((rewritten.count("\n"), len(last_line)))
return
if not self._navigating_history and self.text != self._last_text:
self._original_text = ""
self._cursor_pos_after_load = None
@ -268,7 +298,10 @@ class ChatTextArea(TextArea):
event.stop()
return
if event.key == "backspace" and self._should_reset_mode_on_backspace():
if (
event.key in {"backspace", "shift+backspace"}
and self._should_reset_mode_on_backspace()
):
self._set_mode(self.DEFAULT_MODE)
event.prevent_default()
event.stop()

View file

@ -15,7 +15,7 @@ from textual.widgets.option_list import Option, OptionDoesNotExist
from textual.worker import Worker
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import ConnectorConfig
from vibe.core.config import ConnectorConfig, VibeConfig
from vibe.core.tools.connectors import ConnectorAuthAction, ConnectorRegistry
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.tools.mcp_settings import updated_tool_list
@ -123,19 +123,19 @@ class MCPApp(Container):
tool_manager: ToolManager,
initial_server: str = "",
connector_registry: ConnectorRegistry | None = None,
get_connector_configs: Callable[[], list[ConnectorConfig]] | None = None,
get_vibe_config: Callable[[], VibeConfig] | None = None,
refresh_callback: Callable[[], Awaitable[str]] | None = None,
) -> None:
super().__init__(id="mcp-app")
self._mcp_servers = mcp_servers
self._connector_registry = connector_registry
self._get_connector_configs = get_connector_configs or (lambda: [])
self._get_vibe_config = get_vibe_config
connector_names = (
connector_registry.get_connector_names() if connector_registry else []
)
self._connector_names = connector_names
self._sorted_connector_names = _sort_connector_names_for_menu(
connector_names, connector_registry
connector_names, connector_registry, self._current_disabled_names()
)
self._tool_manager = tool_manager
self._index = collect_mcp_tool_index(mcp_servers, tool_manager, connector_names)
@ -163,7 +163,9 @@ class MCPApp(Container):
if self._connector_registry:
self._connector_names = self._connector_registry.get_connector_names()
self._sorted_connector_names = _sort_connector_names_for_menu(
self._connector_names, self._connector_registry
self._connector_names,
self._connector_registry,
self._current_disabled_names(),
)
self._rebuild_preserving_scroll()
@ -259,9 +261,22 @@ class MCPApp(Container):
text = f"{self._status_message} {text}"
self.query_one("#mcp-help", NoMarkupStatic).update(text)
def _connector_configs(self) -> list[ConnectorConfig]:
return self._get_vibe_config().connectors if self._get_vibe_config else []
def _find_connector_config(self, name: str) -> ConnectorConfig | None:
"""Look up a connector config by name from the live VibeConfig source."""
return next((c for c in self._get_connector_configs() if c.name == name), None)
return next((c for c in self._connector_configs() if c.name == name), None)
def _current_disabled_names(self) -> set[str]:
config = self._get_vibe_config() if self._get_vibe_config else None
if config is None:
return set(self._connector_names)
by_name = config.connectors_by_name()
return {
n
for n in self._connector_names
if (cfg := by_name.get(n)) is None or cfg.disabled
}
def action_disable(self) -> None:
self._set_highlighted_disabled(disabled=True)
@ -290,7 +305,7 @@ class MCPApp(Container):
cfg = self._find_connector_config(name)
if cfg is None:
cfg = ConnectorConfig(name=name, disabled=disabled)
self._get_connector_configs().append(cfg)
self._connector_configs().append(cfg)
else:
cfg.disabled = disabled
@ -342,7 +357,7 @@ class MCPApp(Container):
cfg = ConnectorConfig(
name=server_name, disabled_tools=[remote_name] if disabled else []
)
self._get_connector_configs().append(cfg)
self._connector_configs().append(cfg)
else:
cfg.disabled_tools = updated_tool_list(
cfg.disabled_tools, remote_name, disabled
@ -366,6 +381,11 @@ class MCPApp(Container):
self._index = collect_mcp_tool_index(
self._mcp_servers, self._tool_manager, self._connector_names
)
self._sorted_connector_names = _sort_connector_names_for_menu(
self._connector_names,
self._connector_registry,
self._current_disabled_names(),
)
self._refresh_view(self._viewing_server, kind=self._viewing_kind)
if saved_option_id is not None:
@ -612,12 +632,15 @@ def _tool_count_text(enabled: int, total: int | None = None) -> str:
def _sort_connector_names_for_menu(
connector_names: Sequence[str], connector_registry: ConnectorRegistry | None
connector_names: Sequence[str],
connector_registry: ConnectorRegistry | None,
disabled_names: set[str],
) -> list[str]:
return sorted(
connector_names,
key=lambda name: (
not connector_registry.is_connected(name) if connector_registry else True,
name.lower(),
),
)
def key(name: str) -> tuple[bool, bool, str]:
is_disabled = name in disabled_names
is_connected = (
connector_registry.is_connected(name) if connector_registry else False
)
return (is_disabled, not is_connected, name.lower())
return sorted(connector_names, key=key)

View file

@ -4,8 +4,11 @@ import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, cast
from rich.markup import escape
from vibe.core.hooks.models import HookMessageSeverity
from vibe.core.logger import logger
from vibe.core.types import ImageAttachment
from vibe.core.utils.io import read_safe_async
if TYPE_CHECKING:
@ -62,12 +65,17 @@ class UserMessage(Static):
SHOW_SEPARATOR: ClassVar[bool] = True
def __init__(
self, content: str, pending: bool = False, message_index: int | None = None
self,
content: str,
pending: bool = False,
message_index: int | None = None,
images: list[ImageAttachment] | None = None,
) -> None:
super().__init__()
self.add_class("user-message")
self._content = content
self._pending = pending
self._images = images or []
self.message_index: int | None = message_index
def get_content(self) -> str:
@ -80,10 +88,28 @@ class UserMessage(Static):
f"{self.PROMPT_CHAR} ", classes="user-message-prompt"
)
yield NoMarkupStatic(self._content, classes="user-message-content")
if self._images:
yield Static(
self._format_attachments_footer(self._images),
classes="user-message-attachments",
markup=True,
)
if self.SHOW_SEPARATOR:
yield ExpandingSeparator(classes="user-message-separator")
if self._pending:
self.add_class("pending")
if self._pending:
self.add_class("pending")
@staticmethod
def _format_attachments_footer(images: list[ImageAttachment]) -> str:
label = "attached image" if len(images) == 1 else "attached images"
# Use Textual [link="..."] markup with the URL quoted: Textual's
# markup parser stops at `:` inside an unquoted tag value, so a raw
# `file://...` URL would raise MarkupError. Textual auto-wires the
# click to webbrowser.open(url), opening the OS default viewer.
links = ", ".join(
f'[link="{att.path.as_uri()}"]{escape(att.alias)}[/link]' for att in images
)
return f"{label}: {links}"
async def set_pending(self, pending: bool) -> None:
if pending == self._pending:
@ -429,19 +455,22 @@ class BashOutputMessage(SpinnerMixin, Static):
class ErrorMessage(Static):
def __init__(self, error: str, collapsed: bool = False) -> None:
def __init__(
self, error: str, collapsed: bool = False, show_border: bool = True
) -> None:
super().__init__()
self.add_class("error-message")
self._error = error
self.collapsed = collapsed
self._show_border = show_border
self._content_widget: Static | None = None
def compose(self) -> ComposeResult:
with Horizontal(classes="error-container"):
yield ExpandingBorder(classes="error-border")
self._content_widget = NoMarkupStatic(
f"Error: {self._error}", classes="error-content"
)
if self._show_border:
yield ExpandingBorder(classes="error-border")
text = f"Error: {self._error}" if self._show_border else self._error
self._content_widget = NoMarkupStatic(text, classes="error-content")
yield self._content_widget
def set_collapsed(self, collapsed: bool) -> None:

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import difflib
from pathlib import Path
import re
from pydantic import BaseModel
from textual.app import ComposeResult
@ -11,16 +12,19 @@ from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
from vibe.core.tools.builtins.bash import BashArgs, BashResult
from vibe.core.tools.builtins.edit import EditArgs, EditResult
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
from vibe.core.tools.builtins.search_replace import (
SEARCH_REPLACE_BLOCK_RE,
SearchReplaceArgs,
SearchReplaceResult,
)
from vibe.core.tools.builtins.read import ReadArgs, ReadResult
from vibe.core.tools.builtins.todo import TodoArgs, TodoResult
from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
_LINE_NUMBER_PREFIX = re.compile(r"^ *\d+→")
def _strip_line_numbers(content: str) -> str:
"""Remove the model-facing `` 12→`` line-number prefixes for CLI display."""
return "\n".join(_LINE_NUMBER_PREFIX.sub("", line) for line in content.split("\n"))
def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]:
"""Truncate content to max_lines, returning (content, truncation_info)."""
@ -31,24 +35,6 @@ def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]:
return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)"
def parse_search_replace_to_diff(content: str) -> list[str]:
"""Parse SEARCH/REPLACE blocks and generate unified diff lines."""
all_diff_lines: list[str] = []
matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
if not matches:
return [content[:500]] if content else []
for i, (search_text, replace_text) in enumerate(matches):
if i > 0:
all_diff_lines.append("") # Separator between blocks
search_lines = search_text.strip("\n").split("\n")
replace_lines = replace_text.strip("\n").split("\n")
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
all_diff_lines.extend(list(diff)[2:]) # Skip file headers
return all_diff_lines
def render_diff_line(line: str) -> Static:
"""Render a single diff line with appropriate styling."""
if line.startswith("---") or line.startswith("+++"):
@ -193,28 +179,35 @@ class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
yield from self._footer()
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
class EditApprovalWidget(ToolApprovalWidget[EditArgs]):
def compose(self) -> ComposeResult:
yield NoMarkupStatic(
f"File: {self.args.file_path}", classes="approval-description"
)
yield NoMarkupStatic("")
diff_lines = parse_search_replace_to_diff(self.args.content)
for line in diff_lines:
old_lines = self.args.old_string.split("\n")
new_lines = self.args.new_string.split("\n")
diff = list(difflib.unified_diff(old_lines, new_lines, lineterm="", n=2))[2:]
for line in diff:
yield render_diff_line(line)
if self.args.replace_all:
yield NoMarkupStatic("(replace_all)", classes="approval-description")
class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]):
class EditResultWidget(ToolResultWidget[EditResult]):
def compose(self) -> ComposeResult:
if not self.result:
yield from self._footer()
return
for warning in self.warnings:
yield NoMarkupStatic(f"{warning}", classes="tool-result-warning")
if self.result.content:
for line in parse_search_replace_to_diff(self.result.content):
yield render_diff_line(line)
old_lines = self.result.old_string.split("\n")
new_lines = self.result.new_string.split("\n")
diff = list(difflib.unified_diff(old_lines, new_lines, lineterm="", n=2))[2:]
for line in diff:
yield render_diff_line(line)
yield from self._footer()
@ -260,10 +253,12 @@ class TodoResultWidget(ToolResultWidget[TodoResult]):
return icons.get(status, "")
class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
class ReadApprovalWidget(ToolApprovalWidget[ReadArgs]):
def compose(self) -> ComposeResult:
yield NoMarkupStatic(f"path: {self.args.path}", classes="approval-description")
if self.args.offset > 0:
yield NoMarkupStatic(
f"file_path: {self.args.file_path}", classes="approval-description"
)
if self.args.offset is not None:
yield NoMarkupStatic(
f"offset: {self.args.offset}", classes="approval-description"
)
@ -273,23 +268,24 @@ class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
)
class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
class ReadResultWidget(ToolResultWidget[ReadResult]):
def compose(self) -> ComposeResult:
if self.collapsed:
yield from self._footer()
return
if self.result:
yield NoMarkupStatic(
f"Path: {self.result.path}", classes="tool-result-detail"
f"Path: {self.result.file_path}", classes="tool-result-detail"
)
for warning in self.warnings:
yield NoMarkupStatic(f"{warning}", classes="tool-result-warning")
truncation_info = None
if self.result and self.result.content:
yield NoMarkupStatic("")
ext = Path(self.result.path).suffix.lstrip(".") or "text"
content, truncation_info = _truncate_lines(self.result.content, 10)
yield Markdown(f"```{ext}\n{content}\n```")
content, truncation_info = _truncate_lines(
_strip_line_numbers(self.result.content), 10
)
yield NoMarkupStatic(content, classes="tool-result-detail")
yield from self._footer(truncation_info)
@ -337,18 +333,18 @@ class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
"bash": BashApprovalWidget,
"read_file": ReadFileApprovalWidget,
"read": ReadApprovalWidget,
"write_file": WriteFileApprovalWidget,
"search_replace": SearchReplaceApprovalWidget,
"edit": EditApprovalWidget,
"grep": GrepApprovalWidget,
"todo": TodoApprovalWidget,
}
RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = {
"bash": BashResultWidget,
"read_file": ReadFileResultWidget,
"read": ReadResultWidget,
"write_file": WriteFileResultWidget,
"search_replace": SearchReplaceResultWidget,
"edit": EditResultWidget,
"grep": GrepResultWidget,
"todo": TodoResultWidget,
"ask_user_question": AskUserQuestionResultWidget,

View file

@ -46,10 +46,14 @@ def build_history_widgets(
continue
match msg.role:
case Role.user:
if msg.content:
if msg.content or msg.images:
# history_index is 0-based in non-system messages;
# agent_loop.messages index = history_index + 1 (system msg at 0)
widget = UserMessage(msg.content, message_index=history_index + 1)
widget = UserMessage(
msg.content or "",
message_index=history_index + 1,
images=msg.images,
)
widgets.append(widget)
history_widget_indices[widget] = history_index

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable, Generator
from collections.abc import AsyncGenerator, Callable, Generator, Sequence
import contextlib
import copy
from enum import StrEnum, auto
@ -106,6 +106,7 @@ from vibe.core.types import (
CompactEndEvent,
CompactStartEvent,
ContextTooLongError,
ImageAttachment,
LLMChunk,
LLMMessage,
LLMUsage,
@ -171,6 +172,10 @@ class AgentLoopLLMResponseError(AgentLoopError):
"""Raised when LLM response is malformed or missing expected data."""
class ImagesNotSupportedError(AgentLoopError):
"""Raised when the active model does not support image attachments."""
class TeleportError(AgentLoopError):
"""Raised when teleport to Vibe Code fails."""
@ -649,16 +654,24 @@ class AgentLoop: # noqa: PLR0904
client_message_id: str | None = None,
*,
auto_title: str | None = None,
images: list[ImageAttachment] | None = None,
) -> AsyncGenerator[BaseEvent, None]:
try:
active_model = self.config.get_active_model()
model_name = active_model.name
except ValueError:
active_model = None
model_name = None
if images and active_model is not None and not active_model.supports_images:
raise ImagesNotSupportedError(active_model.alias)
self._clean_message_history()
self.rewind_manager.create_checkpoint()
try:
model_name = self.config.get_active_model().name
except ValueError:
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, auto_title=auto_title
msg,
client_message_id=client_message_id,
auto_title=auto_title,
images=images,
):
yield event
@ -872,9 +885,13 @@ class AgentLoop: # noqa: PLR0904
client_message_id: str | None = None,
*,
auto_title: str | None = None,
images: list[ImageAttachment] | None = None,
) -> AsyncGenerator[BaseEvent]:
user_message = LLMMessage(
role=Role.user, content=user_msg, message_id=client_message_id
role=Role.user,
content=user_msg,
message_id=client_message_id,
images=images or None,
)
self.messages.append(user_message)
self.stats.steps += 1
@ -1317,6 +1334,25 @@ class AgentLoop: # noqa: PLR0904
tool_call_id=tool_call.call_id,
)
def _messages_for_backend(self, active_model: ModelConfig) -> Sequence[LLMMessage]:
if active_model.supports_images:
return self.messages
if not any(m.images for m in self.messages):
return self.messages
return [
m.model_copy(update={"images": None}) if m.images else m
for m in self.messages
]
def count_history_images_unsupported_by_active_model(self) -> int:
try:
active_model = self.config.get_active_model()
except ValueError:
return 0
if active_model.supports_images:
return 0
return sum(1 for m in self.messages if m.images)
async def _chat(
self, max_tokens: int | None = None, model_override: ModelConfig | None = None
) -> LLMChunk:
@ -1343,7 +1379,7 @@ class AgentLoop: # noqa: PLR0904
start_time = time.perf_counter()
result = await self.backend.complete(
model=active_model,
messages=self.messages,
messages=self._messages_for_backend(active_model),
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,
@ -1408,7 +1444,7 @@ class AgentLoop: # noqa: PLR0904
chunk_agg: LLMChunk | None = None
async for chunk in self.backend.complete_streaming(
model=active_model,
messages=self.messages,
messages=self._messages_for_backend(active_model),
temperature=active_model.temperature,
tools=available_tools,
tool_choice=tool_choice,

View file

@ -94,7 +94,7 @@ class AgentProfile:
)
CHAT_AGENT_TOOLS = ["grep", "read_file", "ask_user_question", "task"]
CHAT_AGENT_TOOLS = ["grep", "read", "ask_user_question", "task"]
def _plan_overrides() -> dict[str, Any]:
@ -102,7 +102,7 @@ def _plan_overrides() -> dict[str, Any]:
return {
"tools": {
"write_file": {"permission": "never", "allowlist": [plans_pattern]},
"search_replace": {"permission": "never", "allowlist": [plans_pattern]},
"edit": {"permission": "never", "allowlist": [plans_pattern]},
}
}
@ -137,7 +137,7 @@ ACCEPT_EDITS = AgentProfile(
"base_disabled": ["exit_plan_mode"],
"tools": {
"write_file": {"permission": "always"},
"search_replace": {"permission": "always"},
"edit": {"permission": "always"},
},
},
)
@ -155,7 +155,7 @@ EXPLORE = AgentProfile(
description="Read-only subagent for codebase exploration",
safety=AgentSafety.SAFE,
agent_type=AgentType.SUBAGENT,
overrides={"enabled_tools": ["grep", "read_file"], "system_prompt_id": "explore"},
overrides={"enabled_tools": ["grep", "read"], "system_prompt_id": "explore"},
)
LEAN = AgentProfile(

View file

@ -5,13 +5,14 @@ from pathlib import Path
from typing import Literal
from vibe.core.session.title_format import MentionSegment, TextSegment, TitleSegment
from vibe.core.types import IMAGE_EXTENSIONS
@dataclass(frozen=True, slots=True)
class PathResource:
path: Path
alias: str
kind: Literal["file", "folder"]
kind: Literal["file", "folder", "image"]
@dataclass(frozen=True, slots=True)
@ -80,14 +81,17 @@ def _extract_candidate(message: str, start: int) -> tuple[str | None, int]:
def _is_path_char(char: str) -> bool:
return char.isalnum() or char in "._/\\-()[]{}"
return char.isalnum() or char in "._/\\-()[]{}~"
def _to_resource(candidate: str, base_dir: Path) -> PathResource | None:
if not candidate:
return None
candidate_path = Path(candidate)
try:
candidate_path = Path(candidate).expanduser()
except RuntimeError:
return None
resolved = (
candidate_path if candidate_path.is_absolute() else base_dir / candidate_path
)
@ -96,7 +100,13 @@ def _to_resource(candidate: str, base_dir: Path) -> PathResource | None:
if not resolved.exists():
return None
kind = "folder" if resolved.is_dir() else "file"
kind: Literal["file", "folder", "image"]
if resolved.is_dir():
kind = "folder"
elif resolved.suffix.lower() in IMAGE_EXTENSIONS:
kind = "image"
else:
kind = "file"
return PathResource(path=resolved, alias=candidate, kind=kind)

View file

@ -16,19 +16,41 @@ DEFAULT_MAX_EMBED_BYTES = 256 * 1024
ResourceBlock = dict[str, str | None]
def extract_image_resources(payload: PathPromptPayload) -> list[PathResource]:
return [r for r in payload.resources if r.kind == "image"]
def render_path_prompt_from_payload(
payload: PathPromptPayload,
*,
max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES,
skip_images: bool = False,
) -> str:
blocks = _path_prompt_to_content_blocks(
payload, max_embed_bytes=max_embed_bytes, skip_images=skip_images
)
return _content_blocks_to_prompt_text(blocks)
def render_path_prompt(
message: str,
*,
base_dir: Path,
max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES,
skip_images: bool = False,
) -> str:
payload = build_path_prompt_payload(message, base_dir=base_dir)
blocks = _path_prompt_to_content_blocks(payload, max_embed_bytes=max_embed_bytes)
return _content_blocks_to_prompt_text(blocks)
return render_path_prompt_from_payload(
build_path_prompt_payload(message, base_dir=base_dir),
max_embed_bytes=max_embed_bytes,
skip_images=skip_images,
)
def _path_prompt_to_content_blocks(
payload: PathPromptPayload, *, max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES
payload: PathPromptPayload,
*,
max_embed_bytes: int | None = DEFAULT_MAX_EMBED_BYTES,
skip_images: bool = False,
) -> list[ResourceBlock]:
blocks: list[ResourceBlock] = [{"type": "text", "text": payload.prompt_text}]
@ -50,6 +72,19 @@ def _path_prompt_to_content_blocks(
"uri": resource.path.as_uri(),
"name": resource.alias,
})
case "image":
# Callers that carry images as `LLMMessage.images` sidecar
# attachments (e.g. the Textual TUI) pass skip_images=True to
# avoid duplicating them as text. Other callers (e.g. ACP,
# which has no sidecar plumbing yet) fall back to a
# resource_link so the model still sees the file reference.
if skip_images:
continue
blocks.append({
"type": "resource_link",
"uri": resource.path.as_uri(),
"name": resource.alias,
})
return blocks

View file

@ -65,6 +65,7 @@ from vibe.core.config.schema import (
WithShallowMerge,
WithUnionMerge,
)
from vibe.core.config.vibe_schema import VibeConfigSchema
from vibe.core.prompts import MissingPromptFileError
__all__ = [
@ -120,6 +121,7 @@ __all__ = [
"TrustResolutionError",
"UntrustedLayerError",
"VibeConfig",
"VibeConfigSchema",
"WithConcatMerge",
"WithConflictMerge",
"WithReplaceMerge",

View file

@ -7,7 +7,7 @@ from pathlib import Path
import re
import shlex
import tomllib
from typing import Annotated, Any, Literal, get_args
from typing import Annotated, Any, ClassVar, Literal, get_args
from urllib.parse import urljoin
from dotenv import dotenv_values
@ -30,7 +30,12 @@ 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 UtilityPrompt, load_prompt, load_system_prompt
from vibe.core.prompts import (
SystemPrompt,
UtilityPrompt,
load_prompt,
load_system_prompt,
)
from vibe.core.types import Backend
from vibe.core.utils import configure_ssl_context, get_server_url_from_api_base
@ -362,6 +367,9 @@ def _default_alias_to_name(data: Any) -> Any:
ThinkingLevel = Literal["off", "low", "medium", "high", "max"]
THINKING_LEVELS: list[str] = list(get_args(ThinkingLevel))
DEFAULT_AUTO_COMPACT_THRESHOLD = 200_000
DEFAULT_API_TIMEOUT = 720.0
class ModelConfig(BaseModel):
name: str
@ -371,8 +379,8 @@ class ModelConfig(BaseModel):
input_price: float = 0.0 # Price per million input tokens
output_price: float = 0.0 # Price per million output tokens
thinking: ThinkingLevel = "off"
auto_compact_threshold: int = 200_000
supports_images: bool = False
auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD
_default_alias_to_name = model_validator(mode="before")(_default_alias_to_name)
@ -417,12 +425,15 @@ class OtelSpanExporterConfig(BaseModel):
MISTRAL_OTEL_PATH = "/telemetry"
_DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
DEFAULT_MISTRAL_SERVER_URL = "https://api.mistral.ai"
DEFAULT_VIBE_CODE_WORKFLOW_ID = "__shared-nuage-workflow"
DEFAULT_VIBE_CODE_TASK_QUEUE = "shared-vibe-nuage"
DEFAULT_PROVIDERS = [
ProviderConfig(
name="mistral",
api_base=f"{_DEFAULT_MISTRAL_SERVER_URL}/v1",
api_base=f"{DEFAULT_MISTRAL_SERVER_URL}/v1",
api_key_env_var=DEFAULT_MISTRAL_API_ENV_KEY,
browser_auth_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_BASE_URL,
browser_auth_api_base_url=DEFAULT_MISTRAL_BROWSER_AUTH_API_BASE_URL,
@ -435,16 +446,19 @@ DEFAULT_PROVIDERS = [
),
]
DEFAULT_ACTIVE_MODEL_CONFIG = ModelConfig(
name="mistral-vibe-cli-latest",
provider="mistral",
alias="mistral-medium-3.5",
temperature=1.0,
input_price=1.5,
output_price=7.5,
thinking="high",
supports_images=True,
)
DEFAULT_MODELS = [
ModelConfig(
name="mistral-vibe-cli-latest",
provider="mistral",
alias="mistral-medium-3.5",
temperature=1.0,
input_price=1.5,
output_price=7.5,
thinking="high",
),
DEFAULT_ACTIVE_MODEL_CONFIG,
ModelConfig(
name="devstral-small-latest",
provider="mistral",
@ -461,8 +475,6 @@ DEFAULT_MODELS = [
),
]
DEFAULT_ACTIVE_MODEL = DEFAULT_MODELS[0].alias
DEFAULT_TRANSCRIBE_PROVIDERS = [
TranscribeProviderConfig(
name="mistral",
@ -471,13 +483,13 @@ DEFAULT_TRANSCRIBE_PROVIDERS = [
)
]
DEFAULT_TRANSCRIBE_MODELS = [
TranscribeModelConfig(
name="voxtral-mini-transcribe-realtime-2602",
provider="mistral",
alias="voxtral-realtime",
)
]
DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG = TranscribeModelConfig(
name="voxtral-mini-transcribe-realtime-2602",
provider="mistral",
alias="voxtral-realtime",
)
DEFAULT_TRANSCRIBE_MODELS = [DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG]
DEFAULT_TTS_PROVIDERS = [
TTSProviderConfig(
@ -487,17 +499,17 @@ DEFAULT_TTS_PROVIDERS = [
)
]
DEFAULT_TTS_MODELS = [
TTSModelConfig(
name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts"
)
]
DEFAULT_ACTIVE_TTS_MODEL_CONFIG = TTSModelConfig(
name="voxtral-mini-tts-latest", provider="mistral", alias="voxtral-tts"
)
DEFAULT_TTS_MODELS = [DEFAULT_ACTIVE_TTS_MODEL_CONFIG]
DEFAULT_THEME = "ansi-dark"
class VibeConfig(BaseSettings):
active_model: str = DEFAULT_ACTIVE_MODEL
active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias
vim_keybindings: bool = False
theme: str = DEFAULT_THEME
disable_welcome_banner_animation: bool = False
@ -507,13 +519,13 @@ class VibeConfig(BaseSettings):
context_warnings: bool = False
voice_mode_enabled: bool = False
narrator_enabled: bool = False
active_transcribe_model: str = "voxtral-realtime"
active_tts_model: str = "voxtral-tts"
active_transcribe_model: str = DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG.alias
active_tts_model: str = DEFAULT_ACTIVE_TTS_MODEL_CONFIG.alias
bypass_tool_permissions: bool = False
enable_telemetry: bool = True
experiment_overrides: dict[str, str] = Field(default_factory=dict)
system_prompt_id: str = "cli"
compaction_prompt_id: str = "compact"
system_prompt_id: str = SystemPrompt.CLI
compaction_prompt_id: str = UtilityPrompt.COMPACT
include_commit_signature: bool = True
include_model_info: bool = True
include_project_context: bool = True
@ -522,16 +534,20 @@ class VibeConfig(BaseSettings):
enable_auto_update: bool = True
enable_notifications: bool = True
enable_system_trust_store: bool = False
api_timeout: float = 720.0
auto_compact_threshold: int = 200_000
api_timeout: float = DEFAULT_API_TIMEOUT
auto_compact_threshold: int = DEFAULT_AUTO_COMPACT_THRESHOLD
vibe_code_enabled: bool = Field(default=True, exclude=True)
vibe_code_base_url: str = Field(default="https://api.mistral.ai", exclude=True)
vibe_code_base_url: str = Field(default=DEFAULT_MISTRAL_SERVER_URL, exclude=True)
vibe_code_sessions_base_url: str = Field(
default="https://chat.mistral.ai", exclude=True
)
vibe_code_workflow_id: str = Field(default="__shared-nuage-workflow", exclude=True)
vibe_code_api_key_env_var: str = Field(default="MISTRAL_API_KEY", exclude=True)
vibe_code_workflow_id: str = Field(
default=DEFAULT_VIBE_CODE_WORKFLOW_ID, exclude=True
)
vibe_code_api_key_env_var: str = Field(
default=DEFAULT_MISTRAL_API_ENV_KEY, exclude=True
)
vibe_code_project_name: str | None = Field(default=None, exclude=True)
# TODO(otel): remove exclude=True once the feature is publicly available
@ -702,7 +718,7 @@ class VibeConfig(BaseSettings):
api_key_env = DEFAULT_MISTRAL_API_ENV_KEY
endpoint = urljoin(
f"{urljoin(server_url or _DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/",
f"{urljoin(server_url or DEFAULT_MISTRAL_SERVER_URL, MISTRAL_OTEL_PATH).rstrip('/')}/",
traces_export_path,
)
@ -741,6 +757,9 @@ class VibeConfig(BaseSettings):
return self.compaction_model
return self.get_active_model()
def connectors_by_name(self) -> dict[str, ConnectorConfig]:
return {c.name: c for c in self.connectors}
def get_mistral_provider(self) -> ProviderConfig | None:
try:
active_provider = self.get_active_provider()
@ -964,14 +983,15 @@ class VibeConfig(BaseSettings):
entry["thinking"] = level
break
else:
# Model comes from defaults; materialize the full list so we
# Model comes from defaults; materialize the identities so we
# don't lose the other models.
models = [
{
"alias": m.alias,
"name": m.name,
"provider": m.provider,
"alias": m.alias,
"thinking": level if m.alias == model.alias else m.thinking,
**({"supports_images": True} if m.supports_images else {}),
}
for m in self.models
]
@ -1057,13 +1077,64 @@ class VibeConfig(BaseSettings):
model["thinking"] = "high"
changed = True
if (
model.get("name") == "mistral-vibe-cli-latest"
and model.get("alias") == "mistral-medium-3.5"
and "supports_images" not in model
):
model["supports_images"] = True
changed = True
if data.get("active_model") == "devstral-2":
data["active_model"] = "mistral-medium-3.5"
changed = True
if cls._migrate_renamed_tools(data):
changed = True
if changed:
cls.dump_config(data)
# Old tool name -> new tool name. The new tools replaced these in-place, so
# existing user configs keyed by the old names need their settings moved over.
_RENAMED_TOOLS: ClassVar[dict[str, str]] = {
"read_file": "read",
"search_replace": "edit",
}
# Options on the old tool that have no equivalent on the new one; dropped on migrate.
_DROPPED_TOOL_OPTIONS: ClassVar[dict[str, tuple[str, ...]]] = {
"edit": ("max_content_size", "create_backup")
}
@classmethod
def _migrate_renamed_tools(cls, data: dict[str, Any]) -> bool:
changed = False
tools = data.get("tools")
if isinstance(tools, dict):
for old, new in cls._RENAMED_TOOLS.items():
if old not in tools:
continue
old_config = tools.pop(old)
changed = True
# Prefer an already-present new key; don't clobber it.
if new not in tools:
if isinstance(old_config, dict):
for dropped in cls._DROPPED_TOOL_OPTIONS.get(new, ()):
old_config.pop(dropped, None)
tools[new] = old_config
for list_key in ("enabled_tools", "disabled_tools"):
names = data.get(list_key)
if not isinstance(names, list):
continue
renamed = [cls._RENAMED_TOOLS.get(name, name) for name in names]
if renamed != names:
data[list_key] = renamed
changed = True
return changed
@classmethod
def load(cls, **overrides: Any) -> VibeConfig:
cls._migrate()

View file

@ -14,9 +14,9 @@ from vibe.core.config.harness_files._paths import (
from vibe.core.paths import (
AGENTS_MD_FILENAME,
VIBE_HOME,
ConfigWalkResult,
LocalConfigDirs,
dedup_paths,
walk_local_config_dirs,
find_local_config_dirs,
)
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.core.utils.io import read_safe
@ -60,19 +60,18 @@ class HarnessFilesManager:
"""Open project directories: trusted cwd (if any) plus ``--add-dir``
paths.
``--add-dir`` entries are coalesced (resolved + nested-collapse: adding
``/x`` and ``/x/y`` is the same as just ``/x``). Add-dirs equal to or
nested inside the cwd are dropped cwd already covers them. The cwd
itself, if trusted, is always kept as a starting point: when an add-dir
contains it, both survive (the add-dir contributes its own root-level
discovery; cwd preserves its walk-up semantics for AGENTS.md).
``--add-dir`` entries are resolved and deduplicated; nested paths are
preserved because project config discovery is root-level only.
Add-dirs equal to the cwd are dropped (redundant). When an add-dir
contains the cwd, both survive (the add-dir contributes its own
root-level discovery; cwd preserves walk-up semantics for AGENTS.md).
"""
add_dirs = dedup_paths(self._additional_dirs, drop_nested=True)
add_dirs = dedup_paths(self._additional_dirs)
workdir = self._trusted_workdir
if workdir is None:
return add_dirs
w = workdir.resolve()
return [w, *(p for p in add_dirs if p != w and not p.is_relative_to(w))]
return [w, *(p for p in add_dirs if p != w)]
@property
def hook_files(self) -> list[Path]:
@ -110,23 +109,23 @@ class HarnessFilesManager:
d = GLOBAL_AGENTS_DIR.path
return [d] if d.is_dir() else []
def _walk_project_roots(self) -> ConfigWalkResult:
result = ConfigWalkResult()
def _collect_project_roots(self) -> LocalConfigDirs:
result = LocalConfigDirs()
for root in self.project_roots:
result |= walk_local_config_dirs(root)
result |= find_local_config_dirs(root)
return result
@property
def project_tools_dirs(self) -> list[Path]:
return list(self._walk_project_roots().tools)
return list(self._collect_project_roots().tools)
@property
def project_skills_dirs(self) -> list[Path]:
return list(self._walk_project_roots().skills)
return list(self._collect_project_roots().skills)
@property
def project_agents_dirs(self) -> list[Path]:
return list(self._walk_project_roots().agents)
return list(self._collect_project_roots().agents)
@property
def user_config_file(self) -> Path:

View file

@ -7,6 +7,9 @@ from typing import Any
from pydantic import BaseModel, ConfigDict
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import ConflictStrategy
class RawConfig(BaseModel):
"""Permissive default schema that preserves all fields as extras."""
@ -287,7 +290,12 @@ class ConfigLayer[S: BaseModel](ABC):
"""Return opaque token representing current backing store state."""
raise NotImplementedError
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
async def apply(
self,
patch: ConfigPatch,
*,
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
"""Persist a patch to this layer's backing store."""
raise NotImplementedError

View file

@ -1,7 +1,13 @@
from __future__ import annotations
from vibe.core.config.layers.environment import EnvironmentLayer
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__ = ["OverridesLayer", "ProjectConfigLayer", "UserConfigLayer"]
__all__ = [
"EnvironmentLayer",
"OverridesLayer",
"ProjectConfigLayer",
"UserConfigLayer",
]

View file

@ -0,0 +1,44 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, create_model
from pydantic_settings import BaseSettings, SettingsConfigDict
from vibe.core.config.layer import ConfigLayer, RawConfig
class _EnvBase(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="VIBE_",
case_sensitive=False,
env_nested_delimiter="__",
env_ignore_empty=True,
extra="ignore",
)
class EnvironmentLayer(ConfigLayer[RawConfig]):
"""Reads VIBE_* env vars via pydantic-settings, which handles type coercion
and validation against the schema.
"""
def __init__(self, *, name: str = "environment", schema: type[BaseModel]) -> None:
super().__init__(name=name)
fields: dict[str, Any] = {
field_name: (info.annotation, info)
for field_name, info in schema.model_fields.items()
}
self._settings_class: type[BaseSettings] = create_model(
"_EnvSchema", __base__=_EnvBase, **fields
)
async def _check_trust(self) -> bool:
return True
async def _read_config(self) -> dict[str, Any]:
return self._settings_class().model_dump(exclude_unset=True)
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
raise NotImplementedError("EnvironmentLayer.apply() is not implemented (M2)")

View file

@ -4,6 +4,8 @@ import copy
from typing import Any
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import ConflictStrategy
class OverridesLayer(ConfigLayer[RawConfig]):
@ -23,5 +25,10 @@ class OverridesLayer(ConfigLayer[RawConfig]):
async def _read_config(self) -> dict[str, Any]:
return copy.deepcopy(self._data)
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
async def apply(
self,
patch: ConfigPatch,
*,
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
raise NotImplementedError("OverridesLayer.apply() is not implemented (M2)")

View file

@ -6,6 +6,8 @@ import tomllib
from typing import Any
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import ConflictStrategy
from vibe.core.paths._vibe_home import VIBE_HOME
from vibe.core.trusted_folders import trusted_folders_manager
@ -64,7 +66,12 @@ class ProjectConfigLayer(ConfigLayer[RawConfig]):
await super().revoke_trust()
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
async def apply(
self,
patch: ConfigPatch,
*,
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
raise NotImplementedError("ProjectConfigLayer.apply() is not implemented (M2)")
async def _find_config_file(self) -> None:

View file

@ -5,6 +5,8 @@ import tomllib
from typing import Any
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.types import ConflictStrategy
from vibe.core.paths._vibe_home import VIBE_HOME
@ -28,5 +30,10 @@ class UserConfigLayer(ConfigLayer[RawConfig]):
with self._path.open("rb") as f:
return tomllib.load(f)
async def apply(self, patch: Any, *, on_conflict: str = "cancel") -> None:
async def apply(
self,
patch: ConfigPatch,
*,
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
raise NotImplementedError("UserConfigLayer.apply() is not implemented (M2)")

View file

@ -4,7 +4,9 @@ from typing import Any
from vibe.core.config.builder import ConfigBuilder
from vibe.core.config.layer import ConfigLayer, RawConfig
from vibe.core.config.patch import ConfigPatch
from vibe.core.config.schema import ConfigSchema
from vibe.core.config.types import ConflictStrategy
class ConfigOrchestrator[S: ConfigSchema]:
@ -39,7 +41,12 @@ class ConfigOrchestrator[S: ConfigSchema]:
"""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:
async def apply_patch(
self,
patch: ConfigPatch,
*,
on_conflict: ConflictStrategy = ConflictStrategy.CANCEL,
) -> None:
raise NotImplementedError("apply_patch() is not implemented (M2)")
async def subscribe(self, callback: Any) -> None:

View file

@ -1,8 +1,25 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
@dataclass(frozen=True, slots=True)
class ConfigOrigin:
layer_name: str
class ConflictStrategy(StrEnum):
CANCEL = auto() # abort on conflict (default)
REPLACE = auto() # force-overwrite, discard external changes
class ConcurrencyConflictError(Exception):
"""Raised when backing store was modified externally between load and apply."""
def __init__(self, expected_fp: str, actual_fp: str) -> None:
super().__init__(
f"Backing store was modified externally (expected fingerprint '{expected_fp}', got '{actual_fp}')"
)
self.expected_fp = expected_fp
self.actual_fp = actual_fp

View file

@ -0,0 +1,242 @@
from __future__ import annotations
from pathlib import Path
from typing import Annotated, Any
from pydantic import Field
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config._settings import (
DEFAULT_ACTIVE_MODEL_CONFIG,
DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG,
DEFAULT_ACTIVE_TTS_MODEL_CONFIG,
DEFAULT_API_TIMEOUT,
DEFAULT_AUTO_COMPACT_THRESHOLD,
DEFAULT_CONSOLE_BASE_URL,
DEFAULT_MISTRAL_API_ENV_KEY,
DEFAULT_MISTRAL_SERVER_URL,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
DEFAULT_THEME,
DEFAULT_TRANSCRIBE_MODELS,
DEFAULT_TRANSCRIBE_PROVIDERS,
DEFAULT_TTS_MODELS,
DEFAULT_TTS_PROVIDERS,
DEFAULT_VIBE_BASE_URL,
DEFAULT_VIBE_CODE_TASK_QUEUE,
DEFAULT_VIBE_CODE_WORKFLOW_ID,
ConnectorConfig,
ExperimentsConfig,
MCPServer,
ModelConfig,
ProjectContextConfig,
ProviderConfig,
SessionLoggingConfig,
TranscribeModelConfig,
TranscribeProviderConfig,
TTSModelConfig,
TTSProviderConfig,
)
from vibe.core.config.schema import (
ConfigSchema,
WithConcatMerge,
WithReplaceMerge,
WithShallowMerge,
WithUnionMerge,
)
from vibe.core.prompts import SystemPrompt, UtilityPrompt
class VibeConfigSchema(ConfigSchema):
# Models
active_model: Annotated[str, WithReplaceMerge()] = DEFAULT_ACTIVE_MODEL_CONFIG.alias
providers: Annotated[list[ProviderConfig], WithUnionMerge(merge_key="name")] = (
Field(default_factory=lambda: list(DEFAULT_PROVIDERS))
)
models: Annotated[list[ModelConfig], WithUnionMerge(merge_key="alias")] = Field(
default_factory=lambda: list(DEFAULT_MODELS)
)
compaction_model: Annotated[ModelConfig | None, WithReplaceMerge()] = None
auto_compact_threshold: Annotated[int, WithReplaceMerge()] = (
DEFAULT_AUTO_COMPACT_THRESHOLD
)
active_transcribe_model: Annotated[str, WithReplaceMerge()] = (
DEFAULT_ACTIVE_TRANSCRIBE_MODEL_CONFIG.alias
)
transcribe_providers: Annotated[
list[TranscribeProviderConfig], WithUnionMerge(merge_key="name")
] = Field(default_factory=lambda: list(DEFAULT_TRANSCRIBE_PROVIDERS))
transcribe_models: Annotated[
list[TranscribeModelConfig], WithUnionMerge(merge_key="alias")
] = Field(default_factory=lambda: list(DEFAULT_TRANSCRIBE_MODELS))
active_tts_model: Annotated[str, WithReplaceMerge()] = (
DEFAULT_ACTIVE_TTS_MODEL_CONFIG.alias
)
tts_providers: Annotated[
list[TTSProviderConfig], WithUnionMerge(merge_key="name")
] = Field(default_factory=lambda: list(DEFAULT_TTS_PROVIDERS))
tts_models: Annotated[list[TTSModelConfig], WithUnionMerge(merge_key="alias")] = (
Field(default_factory=lambda: list(DEFAULT_TTS_MODELS))
)
# Tools
tools: Annotated[dict[str, dict[str, Any]], WithShallowMerge()] = Field(
default_factory=dict
)
tool_paths: Annotated[list[Path], WithConcatMerge()] = Field(
default_factory=list,
description=(
"Additional directories or files to explore for custom tools. "
"Paths may be absolute or relative to the current working directory. "
"Directories are shallow-searched for tool definition files, "
"while files are loaded directly if valid."
),
)
enabled_tools: Annotated[list[str], WithConcatMerge()] = Field(
default_factory=list,
description=(
"An explicit list of tool names/patterns to enable. If set, only these"
" tools will be active. Supports glob patterns (e.g., 'serena_*') and"
" regex with 're:' prefix (e.g., 're:^serena_.*')."
),
)
disabled_tools: Annotated[list[str], WithConcatMerge()] = Field(
default_factory=list,
description=(
"A list of tool names/patterns to disable. Ignored if 'enabled_tools'"
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
mcp_servers: Annotated[list[MCPServer], WithUnionMerge(merge_key="name")] = Field(
default_factory=list, description="Preferred MCP server configuration entries."
)
enable_connectors: Annotated[bool, WithReplaceMerge()] = True
connectors: Annotated[list[ConnectorConfig], WithUnionMerge(merge_key="name")] = (
Field(
default_factory=list,
description="Per-connector settings (disable, disabled_tools).",
)
)
# Agents
agent_paths: Annotated[list[Path], WithConcatMerge()] = Field(
default_factory=list,
description=(
"Additional directories to search for custom agent profiles. "
"Each path may be absolute or relative to the current working directory."
),
)
enabled_agents: Annotated[list[str], WithConcatMerge()] = Field(
default_factory=list,
description=(
"An explicit list of agent names/patterns to enable. If set, only these"
" agents will be available. Supports glob patterns (e.g., 'custom-*')"
" and regex with 're:' prefix."
),
)
disabled_agents: Annotated[list[str], WithConcatMerge()] = Field(
default_factory=list,
description=(
"A list of agent names/patterns to disable. Ignored if 'enabled_agents'"
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
installed_agents: Annotated[list[str], WithConcatMerge()] = Field(
default_factory=list,
description=(
"A list of opt-in builtin agent names that have been explicitly installed."
),
)
default_agent: Annotated[str, WithReplaceMerge()] = Field(
default=BuiltinAgentName.DEFAULT,
description=(
"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."
),
)
# Skills
skill_paths: Annotated[list[Path], WithConcatMerge()] = Field(
default_factory=list,
description=(
"Additional directories to search for skills. "
"Each path may be absolute or relative to the current working directory."
),
)
enabled_skills: Annotated[list[str], WithConcatMerge()] = Field(
default_factory=list,
description=(
"An explicit list of skill names/patterns to enable. If set, only these"
" skills will be active. Supports glob patterns (e.g., 'search-*') and"
" regex with 're:' prefix."
),
)
disabled_skills: Annotated[list[str], WithConcatMerge()] = Field(
default_factory=list,
description=(
"A list of skill names/patterns to disable. Ignored if 'enabled_skills'"
" is set. Supports glob patterns and regex with 're:' prefix."
),
)
# Internal
vibe_code_enabled: Annotated[bool, WithReplaceMerge()] = True
vibe_code_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_MISTRAL_SERVER_URL
vibe_code_workflow_id: Annotated[str, WithReplaceMerge()] = (
DEFAULT_VIBE_CODE_WORKFLOW_ID
)
vibe_code_task_queue: Annotated[str | None, WithReplaceMerge()] = (
DEFAULT_VIBE_CODE_TASK_QUEUE
)
vibe_code_api_key_env_var: Annotated[str, WithReplaceMerge()] = (
DEFAULT_MISTRAL_API_ENV_KEY
)
vibe_code_project_name: Annotated[str | None, WithReplaceMerge()] = None
vibe_code_experimental_nuage_enabled: Annotated[bool, WithReplaceMerge()] = False
enable_otel: Annotated[bool, WithReplaceMerge()] = False
otel_endpoint: Annotated[str, WithReplaceMerge()] = ""
console_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_CONSOLE_BASE_URL
enable_experimental_hooks: Annotated[bool, WithReplaceMerge()] = False
# Top-level scalars
theme: Annotated[str, WithReplaceMerge()] = DEFAULT_THEME
experiment_overrides: Annotated[dict[str, str], WithReplaceMerge()] = Field(
default_factory=dict
)
vim_keybindings: Annotated[bool, WithReplaceMerge()] = False
disable_welcome_banner_animation: Annotated[bool, WithReplaceMerge()] = False
autocopy_to_clipboard: Annotated[bool, WithReplaceMerge()] = True
file_watcher_for_autocomplete: Annotated[bool, WithReplaceMerge()] = False
displayed_workdir: Annotated[str, WithReplaceMerge()] = ""
context_warnings: Annotated[bool, WithReplaceMerge()] = False
voice_mode_enabled: Annotated[bool, WithReplaceMerge()] = False
narrator_enabled: Annotated[bool, WithReplaceMerge()] = False
bypass_tool_permissions: Annotated[bool, WithReplaceMerge()] = False
enable_telemetry: Annotated[bool, WithReplaceMerge()] = True
system_prompt_id: Annotated[str, WithReplaceMerge()] = SystemPrompt.CLI
compaction_prompt_id: Annotated[str, WithReplaceMerge()] = UtilityPrompt.COMPACT
include_commit_signature: Annotated[bool, WithReplaceMerge()] = True
include_model_info: Annotated[bool, WithReplaceMerge()] = True
include_project_context: Annotated[bool, WithReplaceMerge()] = True
include_prompt_detail: Annotated[bool, WithReplaceMerge()] = True
enable_update_checks: Annotated[bool, WithReplaceMerge()] = True
enable_auto_update: Annotated[bool, WithReplaceMerge()] = True
enable_notifications: Annotated[bool, WithReplaceMerge()] = True
enable_system_trust_store: Annotated[bool, WithReplaceMerge()] = False
api_timeout: Annotated[float, WithReplaceMerge()] = DEFAULT_API_TIMEOUT
vibe_base_url: Annotated[str, WithReplaceMerge()] = DEFAULT_VIBE_BASE_URL
vibe_code_sessions_base_url: Annotated[str, WithReplaceMerge()] = (
"https://chat.mistral.ai"
)
# Nested configs (REPLACE — simple nested models, no merge semantics)
project_context: Annotated[ProjectContextConfig, WithReplaceMerge()] = Field(
default_factory=ProjectContextConfig
)
session_logging: Annotated[SessionLoggingConfig, WithReplaceMerge()] = Field(
default_factory=SessionLoggingConfig
)
experiments: Annotated[ExperimentsConfig, WithReplaceMerge()] = Field(
default_factory=ExperimentsConfig
)

View file

@ -0,0 +1,38 @@
from __future__ import annotations
import base64
from functools import lru_cache
from pathlib import Path
from vibe.core.types import ImageAttachment
class ImageReadError(Exception):
pass
_CACHE_MAX = 32
@lru_cache(maxsize=_CACHE_MAX)
def _encode_cached(path_str: str, mtime_ns: int, size: int) -> str:
try:
return base64.b64encode(Path(path_str).read_bytes()).decode("ascii")
except OSError as e:
raise ImageReadError(f"Failed to read image {path_str}: {e}") from e
def _encode(att: ImageAttachment) -> str:
try:
stat = att.path.stat()
except OSError as e:
raise ImageReadError(f"Failed to stat image {att.path}: {e}") from e
return _encode_cached(str(att.path), stat.st_mtime_ns, stat.st_size)
def to_data_uri(att: ImageAttachment) -> str:
return f"data:{att.mime_type};base64,{_encode(att)}"
def to_base64(att: ImageAttachment) -> str:
return _encode(att)

View file

@ -6,6 +6,7 @@ import re
from typing import Any, ClassVar
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend._image import to_base64 as _to_base64
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.types import (
AvailableTool,
@ -36,6 +37,18 @@ class AnthropicMapper:
user_content: list[dict[str, Any]] = []
if msg.content:
user_content.append({"type": "text", "text": msg.content})
if msg.images:
user_content.extend(
{
"type": "image",
"source": {
"type": "base64",
"media_type": att.mime_type,
"data": _to_base64(att),
},
}
for att in msg.images
)
converted.append({"role": "user", "content": user_content or ""})
case Role.assistant:
converted.append(self._convert_assistant_message(msg))

View file

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
import httpx
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
from vibe.core.llm.backend.anthropic import AnthropicAdapter
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.llm.backend.openai_responses import OpenAIResponsesAdapter
@ -79,6 +80,22 @@ class OpenAIAdapter(APIAdapter):
msg_dict["reasoning_content"] = msg_dict.pop(field_name)
return msg_dict
def _user_with_images_to_parts(
self, msg_dict: dict[str, Any], source: LLMMessage
) -> dict[str, Any]:
if source.role != Role.user or not source.images:
return msg_dict
parts: list[dict[str, Any]] = []
text = msg_dict.get("content")
if isinstance(text, str) and text:
parts.append({"type": "text", "text": text})
parts.extend(
{"type": "image_url", "image_url": {"url": _to_data_uri(att)}}
for att in source.images
)
msg_dict["content"] = parts
return msg_dict
def prepare_request(
self,
*,
@ -95,17 +112,21 @@ class OpenAIAdapter(APIAdapter):
) -> PreparedRequest:
field_name = provider.reasoning_field_name
converted_messages = [
self._reasoning_to_api(
msg.model_dump(
exclude_none=True,
exclude={
"message_id",
"reasoning_message_id",
"reasoning_state",
"injected",
},
self._user_with_images_to_parts(
self._reasoning_to_api(
msg.model_dump(
exclude_none=True,
exclude={
"message_id",
"reasoning_message_id",
"reasoning_state",
"injected",
"images",
},
),
field_name,
),
field_name,
msg,
)
for msg in messages
]

View file

@ -19,6 +19,8 @@ from mistralai.client.models import (
Function,
FunctionCall as MistralFunctionCall,
FunctionName,
ImageURL,
ImageURLChunk,
SystemMessage,
TextChunk,
ThinkChunk,
@ -31,6 +33,7 @@ from mistralai.client.models import (
)
from mistralai.client.utils.retries import BackoffStrategy, RetryConfig
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
from vibe.core.llm.exceptions import BackendErrorBuilder
from vibe.core.types import (
AvailableTool,
@ -61,6 +64,17 @@ class MistralMapper:
case Role.system:
return SystemMessage(role="system", content=msg.content or "")
case Role.user:
if msg.images:
user_parts: list[ContentChunk] = []
if msg.content:
user_parts.append(TextChunk(type="text", text=msg.content))
user_parts.extend(
ImageURLChunk(
type="image_url", image_url=ImageURL(url=_to_data_uri(att))
)
for att in msg.images
)
return UserMessage(role="user", content=user_parts)
return UserMessage(role="user", content=msg.content)
case Role.assistant:
content: AssistantMessageContent

View file

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, cast
from pydantic import TypeAdapter
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.types import (
AvailableTool,
@ -434,7 +435,20 @@ class OpenAIResponsesAdapter(APIAdapter):
input_items.append({"role": "system", "content": msg.content or ""})
case Role.user:
input_items.append({"role": "user", "content": msg.content or ""})
if msg.images:
parts: list[dict[str, Any]] = []
if msg.content:
parts.append({"type": "input_text", "text": msg.content})
parts.extend(
{"type": "input_image", "image_url": _to_data_uri(att)}
for att in msg.images
)
input_items.append({"role": "user", "content": parts})
else:
input_items.append({
"role": "user",
"content": msg.content or "",
})
case Role.assistant:
for encrypted_content in msg.reasoning_state or []:

View file

@ -5,6 +5,7 @@ import json
from typing import Any, ClassVar
from vibe.core.config import ProviderConfig
from vibe.core.llm.backend._image import to_data_uri as _to_data_uri
from vibe.core.llm.backend.base import APIAdapter, PreparedRequest
from vibe.core.types import (
AvailableTool,
@ -26,6 +27,15 @@ class ReasoningAdapter(APIAdapter):
case Role.system:
return {"role": "system", "content": msg.content or ""}
case Role.user:
if msg.images:
parts: list[dict[str, Any]] = []
if msg.content:
parts.append({"type": "text", "text": msg.content})
parts.extend(
{"type": "image_url", "image_url": {"url": _to_data_uri(att)}}
for att in msg.images
)
return {"role": "user", "content": parts}
return {"role": "user", "content": msg.content or ""}
case Role.assistant:
return self._convert_assistant_message(msg)

View file

@ -151,7 +151,7 @@ def make_plan_agent_reminder(
has_exit_plan_mode: bool = True,
) -> str:
instructions = [
"Research the user's query using read-only tools (grep, read_file, etc.)"
"Research the user's query using read-only tools (grep, read, etc.)"
]
if has_ask_user_question:
instructions.append(
@ -171,7 +171,7 @@ def make_plan_agent_reminder(
return f"""<{VIBE_WARNING_TAG}>Plan mode is active. You MUST NOT make any edits (except to the plan file below, or in your scratchpad), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received.
## Plan File Info
Create or edit your plan at {plan_file_path} using the write_file and search_replace tools.
Create or edit your plan at {plan_file_path} using the write_file and edit tools.
Build your plan incrementally by writing to or editing this file.
This is the only file you are allowed to edit. Make sure to create it early and edit as soon as you internally update your plan.
@ -184,7 +184,7 @@ PLAN_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Plan mode has ended. If you have a pla
CHAT_AGENT_REMINDER = f"""<{VIBE_WARNING_TAG}>Chat mode is active. The user wants to have a conversation -- ask questions, get explanations, or discuss code and architecture. You MUST NOT make any edits, run any non-readonly tools, or otherwise make any changes to the system. This supersedes any other instructions you have received. Instead, you should:
1. Answer the user's questions directly and comprehensively
2. Explain code, concepts, or architecture as requested
3. Use read-only tools (grep, read_file) to look up relevant code when needed
3. Use read-only tools (grep, read) to look up relevant code when needed
4. Focus on being informative and conversational -- your response IS the deliverable, not a precursor to action</{VIBE_WARNING_TAG}>"""
CHAT_AGENT_EXIT = f"""<{VIBE_WARNING_TAG}>Chat mode has ended. You can now use editing tools and make changes to the system.</{VIBE_WARNING_TAG}>"""

View file

@ -40,7 +40,7 @@ class RemoteEventsSource:
self._next_start_seq = 0
self._client: WorkflowsClient | None = None
self._translator = RemoteWorkflowEventTranslator(
available_tools=self._tool_manager._available,
available_tools=self._tool_manager._all_tools,
stats=self.stats,
merge_message=self._merge_message,
)

View file

@ -822,11 +822,7 @@ class RemoteWorkflowEventTranslator:
if not ui_state.operations:
return {}
op = ui_state.operations[0]
return {
"path": op.uri,
"content": op.content,
"overwrite": op.type == "replace",
}
return {"path": op.uri, "content": op.content}
if isinstance(ui_state, CommandUIState):
return {"command": ui_state.command}
if isinstance(ui_state, GenericToolUIState):
@ -853,7 +849,6 @@ class RemoteWorkflowEventTranslator:
return {
"path": op.uri,
"bytes_written": len(op.content.encode()),
"file_existed": op.type == "replace",
"content": op.content,
}, None

View file

@ -1,11 +1,10 @@
from __future__ import annotations
from vibe.core.paths._agents_home import AGENTS_HOME
from vibe.core.paths._local_config_walk import (
WALK_MAX_DEPTH,
ConfigWalkResult,
from vibe.core.paths._local_config_files import (
LocalConfigDirs,
dedup_paths,
walk_local_config_dirs,
find_local_config_dirs,
)
from vibe.core.paths._vibe_home import (
CACHE_FILE,
@ -35,9 +34,8 @@ __all__ = [
"SESSION_LOG_DIR",
"TRUSTED_FOLDERS_FILE",
"VIBE_HOME",
"WALK_MAX_DEPTH",
"ConfigWalkResult",
"GlobalPath",
"LocalConfigDirs",
"dedup_paths",
"walk_local_config_dirs",
"find_local_config_dirs",
]

View file

@ -0,0 +1,80 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
def dedup_paths(paths: Iterable[Path]) -> list[Path]:
"""Resolve and dedup paths, preserving first-occurrence order."""
resolved = [p.resolve() for p in paths]
return [p for i, p in enumerate(resolved) if p not in resolved[:i]]
_VIBE_DIR = Path(".vibe")
_TOOLS_SUBDIR = _VIBE_DIR / "tools"
_VIBE_SKILLS_SUBDIR = _VIBE_DIR / "skills"
_AGENTS_SUBDIR = _VIBE_DIR / "agents"
_AGENTS_DIR = Path(".agents")
_AGENTS_SKILLS_SUBDIR = _AGENTS_DIR / "skills"
@dataclass(frozen=True)
class LocalConfigDirs:
"""Local config directories discovered at a project root."""
config_dirs: tuple[Path, ...] = ()
tools: tuple[Path, ...] = ()
skills: tuple[Path, ...] = ()
agents: tuple[Path, ...] = ()
def __or__(self, other: LocalConfigDirs) -> LocalConfigDirs:
return LocalConfigDirs(
config_dirs=tuple(dedup_paths([*self.config_dirs, *other.config_dirs])),
tools=tuple(dedup_paths([*self.tools, *other.tools])),
skills=tuple(dedup_paths([*self.skills, *other.skills])),
agents=tuple(dedup_paths([*self.agents, *other.agents])),
)
def find_local_config_dirs(root: Path) -> LocalConfigDirs:
"""Inspect *root* for ``.vibe/`` and ``.agents/`` config directories.
Only the root itself is examined no recursion into subdirectories.
"""
resolved = root.resolve()
config_dirs: list[Path] = []
tools: list[Path] = []
skills: list[Path] = []
agents: list[Path] = []
vibe_dir = resolved / _VIBE_DIR
if vibe_dir.is_dir():
has_content = False
if (candidate := resolved / _TOOLS_SUBDIR).is_dir():
tools.append(candidate)
has_content = True
if (candidate := resolved / _VIBE_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
has_content = True
if (candidate := resolved / _AGENTS_SUBDIR).is_dir():
agents.append(candidate)
has_content = True
if (
has_content
or (vibe_dir / "prompts").is_dir()
or (vibe_dir / "config.toml").is_file()
):
config_dirs.append(vibe_dir)
agents_dir = resolved / _AGENTS_DIR
if agents_dir.is_dir() and (candidate := resolved / _AGENTS_SKILLS_SUBDIR).is_dir():
skills.append(candidate)
config_dirs.append(agents_dir)
return LocalConfigDirs(
config_dirs=tuple(config_dirs),
tools=tuple(tools),
skills=tuple(skills),
agents=tuple(agents),
)

View file

@ -1,168 +0,0 @@
from __future__ import annotations
from collections import deque
from collections.abc import Iterable
from dataclasses import dataclass, field
from functools import cache
import logging
import os
from pathlib import Path
from vibe.core.autocompletion.file_indexer.ignore_rules import WALK_SKIP_DIR_NAMES
def dedup_paths(paths: Iterable[Path], *, drop_nested: bool = False) -> list[Path]:
"""Resolve and dedup paths, preserving first-occurrence order.
With ``drop_nested=True``, also drop paths contained inside another path
(so adding ``/x`` and ``/x/y`` collapses to just ``/x``).
"""
resolved = [p.resolve() for p in paths]
return [
p
for i, p in enumerate(resolved)
if p not in resolved[:i]
and not (drop_nested and any(p != q and p.is_relative_to(q) for q in resolved))
]
logger = logging.getLogger("vibe")
_VIBE_DIR = ".vibe"
_TOOLS_SUBDIR = Path(_VIBE_DIR) / "tools"
_VIBE_SKILLS_SUBDIR = Path(_VIBE_DIR) / "skills"
_AGENTS_SUBDIR = Path(_VIBE_DIR) / "agents"
_AGENTS_DIR = ".agents"
_AGENTS_SKILLS_SUBDIR = Path(_AGENTS_DIR) / "skills"
WALK_MAX_DEPTH = 4
_MAX_DIRS = 2000
@dataclass(frozen=True)
class ConfigWalkResult:
"""Aggregated results of a config directory walk."""
config_dirs: tuple[Path, ...] = ()
tools: tuple[Path, ...] = ()
skills: tuple[Path, ...] = ()
agents: tuple[Path, ...] = ()
def __or__(self, other: ConfigWalkResult) -> ConfigWalkResult:
return ConfigWalkResult(
config_dirs=tuple(dedup_paths([*self.config_dirs, *other.config_dirs])),
tools=tuple(dedup_paths([*self.tools, *other.tools])),
skills=tuple(dedup_paths([*self.skills, *other.skills])),
agents=tuple(dedup_paths([*self.agents, *other.agents])),
)
@dataclass
class _ConfigWalkCollector:
"""Mutable accumulator used during BFS, frozen into ConfigWalkResult at the end."""
config_dirs: list[Path] = field(default_factory=list)
tools: list[Path] = field(default_factory=list)
skills: list[Path] = field(default_factory=list)
agents: list[Path] = field(default_factory=list)
def freeze(self) -> ConfigWalkResult:
return ConfigWalkResult(
config_dirs=tuple(self.config_dirs),
tools=tuple(self.tools),
skills=tuple(self.skills),
agents=tuple(self.agents),
)
def _collect_at(
path: Path, entry_names: set[str], collector: _ConfigWalkCollector
) -> None:
"""Check a single directory for .vibe/ and .agents/ config subdirs."""
if _VIBE_DIR in entry_names and (vibe_dir := path / _VIBE_DIR).is_dir():
has_content = False
if (candidate := path / _TOOLS_SUBDIR).is_dir():
collector.tools.append(candidate)
has_content = True
if (candidate := path / _VIBE_SKILLS_SUBDIR).is_dir():
collector.skills.append(candidate)
has_content = True
if (candidate := path / _AGENTS_SUBDIR).is_dir():
collector.agents.append(candidate)
has_content = True
if (
has_content
or (vibe_dir / "prompts").is_dir()
or (vibe_dir / "config.toml").is_file()
):
collector.config_dirs.append(vibe_dir)
if _AGENTS_DIR in entry_names and (agents_dir := path / _AGENTS_DIR).is_dir():
if (candidate := path / _AGENTS_SKILLS_SUBDIR).is_dir():
collector.skills.append(candidate)
collector.config_dirs.append(agents_dir)
def _scandir_entries(path: Path) -> tuple[set[str], list[Path]]:
"""Scan a directory, returning entry names and sorted child directories to descend into.
Uses ``os.scandir`` so that ``DirEntry.is_dir()`` leverages the dirent
d_type field and avoids a separate ``stat`` syscall on most filesystems.
"""
try:
entries = list(os.scandir(path))
except OSError:
return set(), []
entry_names = {e.name for e in entries}
children: list[Path] = []
for entry in entries:
name = entry.name
if name in WALK_SKIP_DIR_NAMES or name.startswith("."):
continue
try:
if entry.is_dir():
children.append(path / name)
except OSError:
continue
children.sort()
return entry_names, children
@cache
def walk_local_config_dirs(
root: Path, *, max_depth: int = WALK_MAX_DEPTH, max_dirs: int = _MAX_DIRS
) -> ConfigWalkResult:
"""Discover .vibe/ and .agents/ config directories under *root*.
Uses breadth-first search bounded by *max_depth* and *max_dirs*
to avoid unbounded traversal in large repositories.
Returns a ``ConfigWalkResult`` containing both the parent config dirs
(for trust decisions) and the categorised subdirs (for loading).
"""
collector = _ConfigWalkCollector()
resolved_root = root.resolve()
queue: deque[tuple[Path, int]] = deque([(resolved_root, 0)])
visited = 0
while queue and visited < max_dirs:
current, depth = queue.popleft()
visited += 1
entry_names, children = _scandir_entries(current)
if not entry_names:
continue
_collect_at(current, entry_names, collector)
if depth < max_depth:
queue.extend((child, depth + 1) for child in children)
if visited >= max_dirs:
logger.warning(
"Config directory scan reached directory limit (%d dirs) at %s",
max_dirs,
resolved_root,
)
return collector.freeze()

View file

@ -76,7 +76,7 @@ When editing:
- Match existing style (indentation, naming, error handling density).
- Minimal diff. Remove completely when removing — no `_unused` renames, no `// removed` comments, no wrapper shims. Update all call sites.
- Whitespace and line endings matter for search_replace. Copy exactly from the read.
- Whitespace matters for `edit`. Copy `old_string` exactly from the read.
**Prove it worked**
@ -93,7 +93,7 @@ You are **not** done when the edit landed, when there are no syntax errors, or w
If you see any of these, the current approach is not working:
- `lines_changed: 0` or a no-op result
- `diff_error`, "string not found", repeated search_replace failures
- `diff_error`, "string not found", repeated `edit` failures
- The same error twice in a row
- Three edits to the same file without the problem resolving
- Whitespace/CRLF mismatch

View file

@ -0,0 +1,56 @@
from __future__ import annotations
import hashlib
import mimetypes
from pathlib import Path
from vibe.core.types import IMAGE_EXTENSIONS, ImageAttachment
_DEFAULT_MIME_BY_EXT = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
class ImageSnapshotError(Exception):
pass
def snapshot_image(
source: Path, *, alias: str, session_dir: Path | None
) -> ImageAttachment:
source_abs = source.expanduser().resolve()
if not source_abs.is_file():
raise ImageSnapshotError(f"Not a file: {source_abs}")
ext = source_abs.suffix.lower()
if ext not in IMAGE_EXTENSIONS:
raise ImageSnapshotError(f"Unsupported image extension: {ext}")
mime_type = _DEFAULT_MIME_BY_EXT.get(ext) or (
mimetypes.guess_type(source_abs.name)[0] or "application/octet-stream"
)
try:
data = source_abs.read_bytes()
except OSError as e:
raise ImageSnapshotError(f"Failed to read image {source_abs}: {e}") from e
# Session logging disabled: no snapshot copy is made; the attachment
# points to the original source. Resume is not possible in this mode so
# snapshot stability is not required.
if session_dir is None:
return ImageAttachment(path=source_abs, alias=alias, mime_type=mime_type)
attachments_dir = session_dir / "attachments"
attachments_dir.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha1(data, usedforsecurity=False).hexdigest()
dest = attachments_dir / f"{digest}{ext}"
if not dest.exists():
dest.write_bytes(data)
return ImageAttachment(path=dest.resolve(), alias=alias, mime_type=mime_type)

View file

@ -96,3 +96,37 @@ def load(config: SessionLoggingConfig) -> str | None:
logger.debug("Failed to read last session pointer path=%s err=%s", path, e)
return None
return content or None
def clear_matching(config: SessionLoggingConfig, session_id: str) -> None:
if not session_id or not config.enabled:
return
pointer_dir = _pointer_dir(config)
if not pointer_dir.is_dir():
return
try:
pointer_paths = list(pointer_dir.iterdir())
except OSError as e:
logger.debug(
"Failed to list last session pointers path=%s err=%s", pointer_dir, e
)
return
for path in pointer_paths:
if not path.is_file():
continue
try:
content = read_safe(path).text.strip()
except OSError as e:
logger.debug("Failed to read last session pointer path=%s err=%s", path, e)
continue
if content != session_id:
continue
try:
path.unlink()
except OSError as e:
logger.debug("Failed to clear last session pointer path=%s err=%s", path, e)

View file

@ -1,10 +1,13 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import shutil
from typing import Any
from vibe.core.config import SessionLoggingConfig
from vibe.core.session import last_session_pointer
from vibe.core.session.session_loader import METADATA_FILENAME, SessionLoader
from vibe.core.session.session_logger import SessionLogger
from vibe.core.utils.io import read_safe
@ -21,23 +24,37 @@ def _normalize_session_title(title: str) -> str:
def _resolve_saved_session_dir(
session_id: str, session_config: SessionLoggingConfig
) -> Path:
session_dir = _find_saved_session_dir(session_id, session_config)
if session_dir is None:
raise ValueError(f"Session not found: {session_id}")
return session_dir
def _find_saved_session_dir(
session_id: str, session_config: SessionLoggingConfig
) -> Path | None:
for session_dir in SessionLoader._find_session_dirs_by_short_id(
session_id, session_config
):
try:
metadata = _load_raw_metadata(session_dir)
except (OSError, json.JSONDecodeError):
except (OSError, ValueError, json.JSONDecodeError):
continue
if metadata.get("session_id") == session_id:
return session_dir
raise ValueError(f"Session not found: {session_id}")
return None
def _load_raw_metadata(session_dir: Path) -> dict[str, Any]:
metadata_path = session_dir / METADATA_FILENAME
return json.loads(read_safe(metadata_path).text)
metadata = json.loads(read_safe(metadata_path).text)
if not isinstance(metadata, dict):
raise ValueError(f"Session metadata must be an object: {metadata_path}")
return metadata
async def update_saved_session_title_at_path(
@ -56,3 +73,15 @@ async def update_saved_session_title(
) -> dict[str, Any]:
session_dir = _resolve_saved_session_dir(session_id, session_config)
return await update_saved_session_title_at_path(session_dir, title)
async def delete_saved_session(
session_id: str, session_config: SessionLoggingConfig
) -> None:
session_dir = _find_saved_session_dir(session_id, session_config)
if session_dir is None:
last_session_pointer.clear_matching(session_config, session_id)
return
await asyncio.to_thread(shutil.rmtree, session_dir)
last_session_pointer.clear_matching(session_config, session_id)

View file

@ -302,7 +302,9 @@ class SessionLogger:
if len(new_messages) == 0:
return
messages_data = [m.model_dump(exclude_none=True) for m in new_messages]
messages_data = [
m.model_dump(exclude_none=True, mode="json") for m in new_messages
]
await SessionLogger.persist_messages(messages_data, session_dir)
# If message update succeeded, write metadata

View file

@ -1,7 +1,13 @@
from __future__ import annotations
from vibe.core.skills.manager import SkillManager
from vibe.core.skills.models import SkillInfo, SkillMetadata
from vibe.core.skills.models import SkillConfigIssue, SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError
__all__ = ["SkillInfo", "SkillManager", "SkillMetadata", "SkillParseError"]
__all__ = [
"SkillConfigIssue",
"SkillInfo",
"SkillManager",
"SkillMetadata",
"SkillParseError",
]

View file

@ -126,6 +126,7 @@ input_price = 1.5
output_price = 7.5
thinking = "high" # "off", "low", "medium", "high", "max"
auto_compact_threshold = 200000
supports_images = true # vision-capable; allows @-mentioned images
[[models]]
name = "devstral-small-latest"
@ -147,7 +148,7 @@ alias = "local"
tool_paths = ["/path/to/custom/tools"]
# Enable only specific tools (glob and regex supported)
enabled_tools = ["bash", "read_file", "grep"]
enabled_tools = ["bash", "read", "grep"]
# Disable specific tools
disabled_tools = ["webfetch"]
@ -163,7 +164,7 @@ prompt for user permission before running the command.
#### File Tool Permission Resolution
File-based tools (`read_file`, `grep`, `write_file`, `search_replace`) resolve
File-based tools (`read`, `grep`, `write_file`, `edit`) resolve
permissions in this order (first match wins):
1. **Scratchpad** path always allowed
@ -366,7 +367,7 @@ fix the problems. After 3 failed retries the hook stops retrying.
### Pattern Matching
Tool, skill, and agent names support three matching modes:
- **Exact**: `"bash"`, `"read_file"`
- **Exact**: `"bash"`, `"read"`
- **Glob**: `"bash*"`, `"mcp_*"`
- **Regex**: `"re:^serena_.*$"` (full match, case-insensitive)
@ -409,7 +410,7 @@ There are two kinds of agents:
### Subagents
- **explore**: Read-only codebase exploration subagent (grep + read_file only).
- **explore**: Read-only codebase exploration subagent (grep + read only).
Spawned by the model, not selectable by the user.
Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
@ -448,6 +449,40 @@ Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
- `/teleport` - Teleport session to Vibe Code Web (only available when Vibe Code is enabled)
- `/exit` - Exit the application
## File Mentions (`@`)
Type `@` in the chat input to autocomplete files and folders from the
project tree. Pressing Tab/Enter inserts the chosen path. Behavior
depends on the file kind:
- **Text files** are read at submit and their contents are inlined into the
prompt text (up to ~256KB).
- **Folders** are inserted as a resource link header (name + uri).
- **Image files** (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`) become image
attachments sent alongside the prompt as native multimodal content for
vision-capable models.
Image attachments:
- Require `supports_images = true` on the active model in `config.toml`.
By default this is enabled only on `mistral-vibe-cli-latest`. Sending
images to a non-vision model raises a clear error and the message is
not added to the conversation.
- Snapshotted into `<session_dir>/attachments/<sha1>.<ext>` so that
resumed sessions stay reproducible even if the source file is moved.
- Capped at 10 MB per image and 8 images per message.
- Out-of-project paths work via `@/abs/path/to.png` (the picker only
suggests project files, but the `@`-parser accepts absolute paths).
Drag-and-drop from Finder into Terminal, iTerm2, or Ghostty is
intercepted at paste time: if the pasted content is a single bare
path to an image file (raw, `\\ `-escaped, or quoted), the input
automatically prepends `@` (and quotes paths containing spaces).
Non-image paths are pasted verbatim so non-image use cases are not
affected.
- Rendered in the chat bubble as a dim footer line linking each
attachment to its snapshot. Clicking opens the file with the OS
default image viewer.
## Skills System
Skills are specialized instruction sets the model can load on demand.
@ -460,7 +495,7 @@ Each skill is a directory containing a `SKILL.md` file with YAML frontmatter.
name: my-skill
description: What this skill does and when to use it.
user-invocable: true
allowed-tools: bash read_file
allowed-tools: bash read
---
# Skill Instructions
@ -511,9 +546,12 @@ directories. The trust database is stored in `~/.vibe/trusted_folders.toml`.
Project-local config (`.vibe/` directory) is only loaded when the current
directory is explicitly trusted.
Interactive mode prompts to trust unknown folders. Programmatic mode
(`-p`/`--prompt`) never prompts: the folder is untrusted. Use `--trust` to
trust cwd for the current invocation only (not persisted).
Interactive mode prompts to trust unknown folders. The prompt targets the
closest ancestor of the cwd (the cwd itself included) containing a `.git`
entry; the search excludes the user's home directory and the filesystem
root, and falls back to the cwd if no qualifying ancestor is found.
Programmatic mode (`-p`/`--prompt`) never prompts: the folder is untrusted.
Use `--trust` to trust cwd for the current invocation only (not persisted).
## Sensitive Files — DO NOT READ OR EDIT
@ -523,7 +561,7 @@ NEVER read, display, or edit any of these files:
If the user asks to set or change an API key, instruct them to edit the `.env`
file themselves. Do not offer to read it, write it, or display its contents.
Do not use tools (read_file, write_file, bash cat/echo, etc.) to access these files.
Do not use tools (read, write_file, bash cat/echo, etc.) to access these files.
## How to Modify Configuration
@ -535,7 +573,7 @@ To help the user modify their Vibe configuration:
same directory (e.g. `cp ~/.vibe/config.toml ~/.vibe/config.toml.bak`). This
applies to any config file you are about to modify (`config.toml`,
`trusted_folders.toml`, agent TOML files, etc.)
3. **Edit the TOML file**: Make changes using the search_replace or write_file tool
3. **Edit the TOML file**: Make changes using the edit tool
4. **Reload**: The user can run `/reload` to apply changes without restarting
For API keys, tell the user to edit `~/.vibe/.env` directly never read or

View file

@ -8,7 +8,12 @@ from typing import TYPE_CHECKING
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.skills.builtins import BUILTIN_SKILLS
from vibe.core.skills.models import ParsedSkillCommand, SkillInfo, SkillMetadata
from vibe.core.skills.models import (
ParsedSkillCommand,
SkillConfigIssue,
SkillInfo,
SkillMetadata,
)
from vibe.core.skills.parser import SkillParseError, parse_skill_markdown
from vibe.core.utils import name_matches
from vibe.core.utils.io import read_safe
@ -21,6 +26,7 @@ class SkillManager:
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
self._config_getter = config_getter
self._search_paths = self._compute_search_paths(self._config)
self._config_issues: list[SkillConfigIssue] = []
self.available_skills: Mapping[str, SkillInfo] = MappingProxyType(
self._apply_filters(self._discover_skills())
)
@ -36,6 +42,10 @@ class SkillManager:
def _config(self) -> VibeConfig:
return self._config_getter()
@property
def config_issues(self) -> tuple[SkillConfigIssue, ...]:
return tuple(self._config_issues)
def _apply_filters(self, skills: dict[str, SkillInfo]) -> dict[str, SkillInfo]:
if self._config.enabled_skills:
return {
@ -121,6 +131,9 @@ class SkillManager:
skill_info = self._parse_skill_file(skill_file)
except Exception as e:
logger.warning("Failed to parse skill at %s: %s", skill_file, e)
self._config_issues.append(
SkillConfigIssue(file=skill_file, message=f"Failed to load: {e}")
)
return None
return skill_info

View file

@ -98,6 +98,11 @@ class SkillInfo(BaseModel):
)
class SkillConfigIssue(BaseModel):
file: Path
message: str
class ParsedSkillCommand(BaseModel):
name: str
content: str

View file

@ -193,13 +193,9 @@ class TelemetryClient:
nb_files_modified = 0
if status == "success" and result is not None:
if tool_call.tool_name == "write_file":
file_existed = result.get("file_existed", False)
if file_existed:
nb_files_modified = 1
else:
nb_files_created = 1
elif tool_call.tool_name == "search_replace":
nb_files_modified = 1 if result.get("blocks_applied", 0) > 0 else 0
nb_files_created = 1
elif tool_call.tool_name == "edit":
nb_files_modified = 1
return nb_files_created, nb_files_modified
def send_tool_call_finished(

View file

@ -58,6 +58,13 @@ class NuageRequest(BaseModel):
context: NuageContext
class TeleportSession(BaseModel):
model_config = ConfigDict(extra="forbid")
metadata: dict[str, object]
messages: list[dict[str, object]]
class NuageResponse(BaseModel):
model_config = ConfigDict(extra="ignore")

View file

@ -0,0 +1,205 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import ClassVar, final
from pydantic import BaseModel, Field
from vibe.core.rewind.manager import FileSnapshot
from vibe.core.scratchpad import is_scratchpad_path
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils.io import (
ReadSafeResult,
atomic_replace,
file_write_lock,
read_safe_async,
)
class EditArgs(BaseModel):
file_path: str = Field(description="The absolute path to the file to modify")
old_string: str = Field(description="The text to replace")
new_string: str = Field(
description="The text to replace it with (must be different from old_string)"
)
replace_all: bool = Field(
default=False,
description="Replace all occurrences of old_string (default false)",
)
class EditResult(BaseModel):
file: str
message: str
old_string: str
new_string: str
class EditConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
sensitive_patterns: list[str] = Field(
default=["**/.env", "**/.env.*"],
description="File patterns that trigger ASK even when permission is ALWAYS.",
)
class Edit(
BaseTool[EditArgs, EditResult, EditConfig, BaseToolState],
ToolUIData[EditArgs, EditResult],
):
description: ClassVar[str] = (
"Perform exact string replacements in files. "
"Supports single or bulk (replace_all) substitutions "
"with atomic, concurrent-safe writes."
)
def resolve_permission(self, args: EditArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.file_path,
tool_name=self.get_name(),
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
sensitive_patterns=self.config.sensitive_patterns,
)
def get_file_snapshot(self, args: EditArgs) -> FileSnapshot | None:
return self.get_file_snapshot_for_path(args.file_path)
@classmethod
def format_call_display(cls, args: EditArgs) -> ToolCallDisplay:
tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else ""
return ToolCallDisplay(
summary=f"Editing {Path(args.file_path).name}{tag}",
content=f"old_string: {args.old_string!r}\nnew_string: {args.new_string!r}",
)
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if isinstance(event.result, EditResult):
tag = " (scratchpad)" if is_scratchpad_path(event.result.file) else ""
return ToolResultDisplay(
success=True, message=f"Edited {Path(event.result.file).name}{tag}"
)
return ToolResultDisplay(
success=False, message=event.error or event.skip_reason or "No result"
)
@classmethod
def get_status_text(cls) -> str:
return "Editing files"
async def _read_file(self, file_path: Path) -> ReadSafeResult:
return await read_safe_async(file_path, raise_on_error=True)
async def _write_file(
self, file_path: Path, content: str, encoding: str, newline: str
) -> None:
await atomic_replace(file_path, content, encoding=encoding, newline=newline)
@final
async def run(
self, args: EditArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | EditResult, None]:
file_path = self._validate_args(args)
try:
async with file_write_lock(file_path):
result = await self._read_file(file_path)
original = result.text
if args.old_string not in original:
raise ToolError(
f"String to replace not found in file.\n"
f"String: {args.old_string}"
)
occurrences = original.count(args.old_string)
if occurrences > 1 and not args.replace_all:
raise ToolError(
f"Found {occurrences} matches of the string to replace, "
f"but replace_all is false. To replace all occurrences, "
f"set replace_all to true. To replace only one occurrence, "
f"please provide more context to uniquely identify the "
f"instance.\nString: {args.old_string}"
)
modified = self._apply_edit(
original, args.old_string, args.new_string, args.replace_all
)
if modified != original:
await self._write_file(
file_path, modified, result.encoding, result.newline
)
except UnicodeDecodeError as e:
raise ToolError(
f"Cannot edit {file_path}: file is not valid text "
f"({e.encoding}, byte {e.start})"
) from e
except PermissionError as e:
raise ToolError(f"Permission denied accessing file: {file_path}") from e
except OSError as e:
raise ToolError(f"OS error accessing {file_path}: {e}") from e
if args.replace_all:
message = (
"The file has been updated. All occurrences were successfully replaced"
)
else:
message = "The file has been updated successfully."
yield EditResult(
file=str(file_path),
message=message,
old_string=args.old_string,
new_string=args.new_string,
)
@final
def _validate_args(self, args: EditArgs) -> Path:
file_path_str = args.file_path.strip()
if not file_path_str:
raise ToolError("File path cannot be empty")
if not args.old_string:
raise ToolError(
"old_string cannot be empty. Use write_file to create new files."
)
if args.old_string == args.new_string:
raise ToolError(
"No changes to make — old_string and new_string are identical"
)
file_path = Path(file_path_str).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
file_path = file_path.resolve()
if not file_path.exists():
raise ToolError(f"File does not exist: {file_path}")
if not file_path.is_file():
raise ToolError(f"Path is not a file: {file_path}")
return file_path
@staticmethod
def _apply_edit(
content: str, old_string: str, new_string: str, replace_all: bool
) -> str:
if replace_all:
return content.replace(old_string, new_string)
return content.replace(old_string, new_string, 1)

View file

@ -11,13 +11,13 @@ Use the `bash` tool to run one-off shell commands.
**IMPORTANT: Use dedicated tools if available instead of these bash commands:**
**File Operations - DO NOT USE:**
- `cat filename` → Use `read_file(path="filename")`
- `head -n 20 filename` → Use `read_file(path="filename", limit=20)`
- `tail -n 20 filename` → Read with offset: `read_file(path="filename", offset=<line_number>, limit=20)`
- `sed -n '100,200p' filename` → Use `read_file(path="filename", offset=99, limit=101)`
- `less`, `more`, `vim`, `nano` → Use `read_file` with offset/limit for navigation
- `cat filename` → Use `read(file_path="filename")`
- `head -n 20 filename` → Use `read(file_path="filename", limit=20)`
- `tail -n 20 filename` → Read with offset: `read(file_path="filename", offset=<line_number>, limit=20)`
- `sed -n '100,200p' filename` → Use `read(file_path="filename", offset=100, limit=101)`
- `less`, `more`, `vim`, `nano` → Use `read` with offset/limit for navigation
- `echo "content" > file` → Use `write_file(path="file", content="content")`
- `echo "content" >> file` → Read first, then `write_file` with overwrite=true
- `echo "content" >> existing_file` → Read first, then use `search_replace` to append (write_file refuses to overwrite)
**Search Operations - DO NOT USE:**
- `grep -r "pattern" .` → Use `grep(pattern="pattern", path=".")`
@ -26,9 +26,9 @@ Use the `bash` tool to run one-off shell commands.
- `locate` → Use `grep` tool
**File Modification - DO NOT USE:**
- `sed -i 's/old/new/g' file` → Use `search_replace` tool
- `awk` for file editing → Use `search_replace` tool
- Any in-place file editing → Use `search_replace` tool
- `sed -i 's/old/new/g' file` → Use `edit` tool
- `awk` for file editing → Use `edit` tool
- Any in-place file editing → Use `edit` tool
**APPROPRIATE bash uses:**
- System information: `pwd`, `whoami`, `date`, `uname -a`
@ -51,9 +51,9 @@ bash("head -1000 large_file.txt") # Inefficient
RIGHT:
```python
# First chunk
read_file(path="large_file.txt", limit=1000)
# If was_truncated=true, read next chunk
read_file(path="large_file.txt", offset=1000, limit=1000)
read(file_path="large_file.txt", limit=1000)
# If output is truncated, read next chunk
read(file_path="large_file.txt", offset=1001, limit=1000)
```
**Example: Searching for patterns**

View file

@ -0,0 +1,19 @@
Use `edit` to make exact string replacements in files.
**Arguments:**
- `file_path`: The path to the file to modify
- `old_string`: The exact text to find and replace
- `new_string`: The replacement text (must differ from old_string)
- `replace_all`: Set to `true` to replace all occurrences (default: `false`)
**IMPORTANT:**
- **ALWAYS** call `read` on the target before `edit`. The on-disk content may have changed since you last saw it (user edits, prior tool calls, external processes). Operating on stale content will either fail the exact-match check or silently apply the edit to the wrong place.
- The `old_string` must match the file content exactly, including whitespace and indentation
- When editing text from `read` output, match only the content AFTER the line number prefix (the ` 1→` part is not in the file). Never include any part of the line number prefix in old_string or new_string.
- If `old_string` appears multiple times, the edit will fail unless `replace_all` is `true`. Either provide more surrounding context to uniquely identify the target, or set `replace_all` to `true`.
- Use `replace_all` for renaming variables or strings across the file
- Prefer editing existing files over writing new ones
- `old_string` cannot be empty; use `write_file` to create new files
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- If an `edit` fails because the `old_string` was not found, re-read the file before retrying — do not guess at variations.

View file

@ -0,0 +1,19 @@
Use `read` to read the content of a file with line numbers. It handles encoding safely.
- By default, it reads up to 2000 lines from the beginning of the file
- Output larger than 50KB returns an error; use `offset` and `limit` for larger files
- Results include line numbers in ` 1→content` format (1-indexed)
- Use `offset` (1-indexed line number) and `limit` to read specific portions
- This is more efficient than using `bash` with `cat` or `wc`
**Strategy for large files:**
1. Call `read` without offset/limit to get the start of the file
2. If the output is too large, use `offset` and `limit` to read targeted sections
3. Prefer `grep` to find specific content rather than reading sequentially chunk by chunk
4. Do not call `read` more than 3 times on the same file without responding to the user first
**Do not read:**
- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.)
- Binary files of any kind
- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file.

View file

@ -1,18 +0,0 @@
Use `read_file` to read the content of a file. It's designed to handle large files safely.
- By default, it reads from the beginning of the file.
- Use `offset` (line number) and `limit` (number of lines) to read specific parts or chunks of a file. This is efficient for exploring large files.
- The result includes `was_truncated: true` if the file content was cut short due to size limits.
- This is more efficient than using `bash` with `cat` or `wc`.
**Strategy for large files:**
1. Call `read_file` with a `limit` (e.g., 1000 lines) to get the start of the file.
2. If `was_truncated` is true, the file is large. STOP and assess: do you already have enough information to answer the user's question? If yes, respond immediately — do not keep reading.
3. If you need more, prefer targeted reads (e.g., jump to a specific offset, read the last 100 lines, search for a relevant section) over reading sequentially chunk by chunk.
4. Do not call `read_file` more than 3 times on the same file without responding to the user first.
**Do not read or explore:**
- Model checkpoint directories or weight files (.bin, .safetensors, .pt, .gguf, optimizer states, etc.)
- Binary files of any kind
- Entire directory trees of training runs or large codebases. If the user provides paths to such files, treat them as references. Do not open them unless the user explicitly asks you to inspect a specific file.

View file

@ -1,43 +0,0 @@
Use `search_replace` to make targeted changes to files using SEARCH/REPLACE blocks. This tool finds exact text matches and replaces them.
Arguments:
- `file_path`: The path to the file to modify
- `content`: The SEARCH/REPLACE blocks defining the changes
The content format is:
```
<<<<<<< SEARCH
[exact text to find in the file]
=======
[exact text to replace it with]
>>>>>>> REPLACE
```
You can include multiple SEARCH/REPLACE blocks to make multiple changes to the same file:
```
<<<<<<< SEARCH
def old_function():
return "old value"
=======
def new_function():
return "new value"
>>>>>>> REPLACE
<<<<<<< SEARCH
import os
=======
import os
import sys
>>>>>>> REPLACE
```
IMPORTANT:
- The SEARCH text must match EXACTLY (including whitespace, indentation, and line endings)
- The SEARCH text must appear exactly once in the file - if it appears multiple times, the tool will error
- Use at least 5 equals signs (=====) between SEARCH and REPLACE sections
- The tool will provide detailed error messages showing context if search text is not found
- Each search/replace block is applied in order, so later blocks see the results of earlier ones
- Be careful with escape sequences in string literals - use \n not \\n for newlines in code

View file

@ -1,42 +1,27 @@
Use `write_file` to write content to a file.
Use `write_file` to create a new file.
**Arguments:**
- `path`: The file path (relative or absolute)
- `content`: The content to write to the file
- `overwrite`: Must be set to `true` to overwrite an existing file (default: `false`)
**IMPORTANT SAFETY RULES:**
**BEHAVIOR:**
- By default, the tool will **fail if the file already exists** to prevent accidental data loss
- To **overwrite** an existing file, you **MUST** set `overwrite: true`
- To **create a new file**, just provide the `path` and `content` (overwrite defaults to false)
- If parent directories don't exist, they will be created automatically
- `write_file` can ONLY create new files.
- If the file already exists, the tool returns an error. Use `search_replace` to edit existing files.
- Parent directories are created automatically if they don't exist.
**BEST PRACTICES:**
- **ALWAYS** use the `read_file` tool first before overwriting an existing file to understand its current contents
- **ALWAYS** prefer using `search_replace` to edit existing files rather than overwriting them completely
- **NEVER** write new files unless explicitly required - prefer modifying existing files
- **NEVER** proactively create documentation files (*.md) or README files unless explicitly requested
- **AVOID** using emojis in file content unless the user explicitly requests them
- **NEVER** use `write_file` to modify an existing file — it will fail. Use `edit` instead.
- **NEVER** write new files unless explicitly required — prefer modifying existing files via `edit`.
- **NEVER** proactively create documentation files (*.md) or README files unless explicitly requested.
- **AVOID** using emojis in file content unless the user explicitly requests them.
**Usage Examples:**
**Usage Example:**
```python
# Create a new file (will error if file exists)
write_file(
path="src/new_module.py",
content="def hello():\n return 'Hello World'"
)
# Overwrite an existing file (must read it first!)
# First: read_file(path="src/existing.py")
# Then:
write_file(
path="src/existing.py",
content="# Updated content\ndef new_function():\n pass",
overwrite=True
)
```
**Remember:** For editing existing files, prefer `search_replace` over `write_file` to preserve unchanged portions and avoid accidental data loss.

View file

@ -0,0 +1,235 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, final
from humanize import naturalsize
from pydantic import BaseModel, Field
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.scratchpad import is_scratchpad_path
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent
from vibe.core.utils import VIBE_WARNING_TAG
from vibe.core.utils.io import read_lines_safe_async
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
_KB = 1024
DEFAULT_LINE_LIMIT = 2000
MAX_BYTES = 50 * _KB
def _add_line_numbers(lines: list[str], *, start: int) -> str:
return "\n".join(
f"{str(n).rjust(9)}\u2192{line}" for n, line in enumerate(lines, start=start)
)
def _warning(message: str) -> str:
return f"<{VIBE_WARNING_TAG}>{message}</{VIBE_WARNING_TAG}>"
class ReadArgs(BaseModel):
file_path: str = Field(description="The absolute path to the file to read.")
offset: int | None = Field(
default=None,
ge=1,
description=(
"The line number to start reading from (1-indexed). "
"Only provide if the file is too large to read at once."
),
)
limit: int = Field(
default=DEFAULT_LINE_LIMIT,
gt=0,
description=(
"The number of lines to read. Lower it to read a smaller portion "
"of a large file."
),
)
class ReadResult(BaseModel):
file_path: str
content: str
num_lines: int
start_line: int
total_lines: int | None = None
was_truncated: bool = False
class ReadConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ALWAYS
sensitive_patterns: list[str] = Field(
default=["**/.env", "**/.env.*"],
description="File patterns that trigger ASK even when permission is ALWAYS.",
)
max_read_bytes: int = Field(
default=MAX_BYTES,
gt=0,
description="Maximum selected/output bytes to return in one call.",
)
class ReadState(BaseToolState):
injected_agents_md: set[str] = Field(default_factory=set)
class Read(
BaseTool[ReadArgs, ReadResult, ReadConfig, ReadState],
ToolUIData[ReadArgs, ReadResult],
):
description: ClassVar[str] = (
"Read a text file with line numbers. "
"Results are formatted with line number prefixes for easy reference."
)
def resolve_permission(self, args: ReadArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.file_path,
tool_name=self.get_name(),
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
sensitive_patterns=self.config.sensitive_patterns,
)
def get_result_extra(self, result: ReadResult) -> str | None:
try:
mgr = get_harness_files_manager()
except RuntimeError:
return None
docs = mgr.find_subdirectory_agents_md(Path(result.file_path))
new_docs = [
(d, c)
for d, c in docs
if str(d.resolve()) not in self.state.injected_agents_md
]
if not new_docs:
return None
for d, _ in new_docs:
self.state.injected_agents_md.add(str(d.resolve()))
sections = [
f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}"
for d, c in new_docs
]
return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n</{VIBE_WARNING_TAG}>"
async def _read_file(
self, args: ReadArgs, file_path: Path
) -> tuple[list[str], int | None, bool]:
start_line = args.offset or 1
try:
result = await read_lines_safe_async(
file_path,
start_line=start_line,
limit=args.limit,
max_bytes=self.config.max_read_bytes,
)
except OSError as exc:
raise ToolError(f"Error reading {file_path}: {exc}") from exc
return result.lines, result.total_lines, result.was_truncated
@final
async def run(
self, args: ReadArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | ReadResult, None]:
file_path = self._resolve_path(args.file_path)
start_line = args.offset or 1
selected, total_lines, was_truncated = await self._read_file(args, file_path)
if selected:
content = _add_line_numbers(selected, start=start_line)
elif total_lines == 0:
content = _warning("Warning: the file exists but the contents are empty.")
elif total_lines is None:
content = _warning(f"Warning: no content returned for offset {start_line}.")
else:
content = _warning(
f"Warning: the file exists but is shorter than the provided "
f"offset ({start_line}). The file has {total_lines} lines."
)
size = len(content.encode("utf-8"))
if size > self.config.max_read_bytes:
raise ToolError(
f"Output ({naturalsize(size, binary=True)}) exceeds maximum "
f"allowed size ({naturalsize(self.config.max_read_bytes, binary=True)}). "
f"Use offset and limit to read a smaller portion of the file."
)
yield ReadResult(
file_path=str(file_path),
content=content,
num_lines=len(selected),
start_line=start_line,
total_lines=total_lines,
was_truncated=was_truncated,
)
def _resolve_path(self, raw_path: str) -> Path:
if not raw_path.strip():
raise ToolError("file_path cannot be empty")
path = Path(raw_path).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
path = path.resolve()
if not path.exists():
raise ToolError(f"File not found at: {path}")
if path.is_dir():
raise ToolError(f"Path is a directory, not a file: {path}")
return path
@classmethod
def format_call_display(cls, args: ReadArgs) -> ToolCallDisplay:
tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else ""
summary = f"Reading {args.file_path}"
extras: list[str] = []
if args.offset:
extras.append(f"from line {args.offset}")
if args.limit != DEFAULT_LINE_LIMIT:
extras.append(f"limit {args.limit} lines")
if extras:
summary += f" ({', '.join(extras)})"
return ToolCallDisplay(summary=f"{summary}{tag}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, ReadResult):
return ToolResultDisplay(
success=False, message=event.error or event.skip_reason or "No result"
)
path_obj = Path(event.result.file_path)
tag = " (scratchpad)" if is_scratchpad_path(event.result.file_path) else ""
word = "line" if event.result.num_lines == 1 else "lines"
message = f"Read {event.result.num_lines} {word} from {path_obj.name}{tag}"
if event.result.was_truncated or (
event.result.total_lines is not None
and event.result.start_line + event.result.num_lines - 1
< event.result.total_lines
):
message += " (truncated)"
return ToolResultDisplay(success=True, message=message)
@classmethod
def get_status_text(cls) -> str:
return "Reading file"

View file

@ -1,234 +0,0 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, NamedTuple, final
import anyio
from pydantic import BaseModel, Field
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.scratchpad import is_scratchpad_path
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolStreamEvent
from vibe.core.utils import VIBE_WARNING_TAG
from vibe.core.utils.io import decode_safe
if TYPE_CHECKING:
from vibe.core.types import ToolResultEvent
class _ReadResult(NamedTuple):
lines: list[str]
bytes_read: int
was_truncated: bool
class ReadFileArgs(BaseModel):
path: str
offset: int = Field(
default=0,
description="Line number to start reading from (0-indexed, inclusive).",
)
limit: int | None = Field(
default=None, description="Maximum number of lines to read."
)
class ReadFileResult(BaseModel):
path: str
content: str
offset: int = 0
lines_read: int
was_truncated: bool = Field(
description="True if the reading was stopped due to the max_read_bytes limit."
)
class ReadFileToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ALWAYS
sensitive_patterns: list[str] = Field(
default=["**/.env", "**/.env.*"],
description="File patterns that trigger ASK even when permission is ALWAYS.",
)
max_read_bytes: int = Field(
default=64_000, description="Maximum total bytes to read from a file in one go."
)
class ReadFileState(BaseToolState):
injected_agents_md: set[str] = Field(default_factory=set)
class ReadFile(
BaseTool[ReadFileArgs, ReadFileResult, ReadFileToolConfig, ReadFileState],
ToolUIData[ReadFileArgs, ReadFileResult],
):
description: ClassVar[str] = (
"Read a text file (encoding detected safely), returning content from a "
"specific line range. Reading is capped by a byte limit for safety."
)
@final
async def run(
self, args: ReadFileArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | ReadFileResult, None]:
file_path = self._prepare_and_validate_path(args)
read_result = await self._read_file(args, file_path)
yield ReadFileResult(
path=str(file_path),
content="".join(read_result.lines),
offset=args.offset,
lines_read=len(read_result.lines),
was_truncated=read_result.was_truncated,
)
def resolve_permission(self, args: ReadFileArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.path,
tool_name=self.get_name(),
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
sensitive_patterns=self.config.sensitive_patterns,
)
def get_result_extra(self, result: ReadFileResult) -> str | None:
try:
mgr = get_harness_files_manager()
except RuntimeError:
return None
docs = mgr.find_subdirectory_agents_md(Path(result.path))
new_docs = [
(d, c)
for d, c in docs
if str(d.resolve()) not in self.state.injected_agents_md
]
if not new_docs:
return None
for d, _ in new_docs:
self.state.injected_agents_md.add(str(d.resolve()))
sections = [
f"Contents of {d}/AGENTS.md (project instructions for this directory):\n\n{c.strip()}"
for d, c in new_docs
]
return f"<{VIBE_WARNING_TAG}>\n{'\n\n'.join(sections)}\n</{VIBE_WARNING_TAG}>"
def _prepare_and_validate_path(self, args: ReadFileArgs) -> Path:
self._validate_inputs(args)
file_path = Path(args.path).expanduser()
if not file_path.is_absolute():
file_path = Path.cwd() / file_path
self._validate_path(file_path)
return file_path
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
try:
raw_lines: list[bytes] = []
bytes_read = 0
was_truncated = True
async with await anyio.Path(file_path).open("rb") as f:
line_index = 0
while raw_line := await f.readline():
if line_index < args.offset:
line_index += 1
continue
if args.limit is not None and len(raw_lines) >= args.limit:
break
line_bytes = len(raw_line)
if bytes_read + line_bytes > self.config.max_read_bytes:
break
raw_lines.append(raw_line)
bytes_read += line_bytes
line_index += 1
else:
was_truncated = False
except OSError as exc:
raise ToolError(f"Error reading {file_path}: {exc}") from exc
lines_to_return = decode_safe(b"".join(raw_lines)).text.splitlines(
keepends=True
)
return _ReadResult(
lines=lines_to_return, bytes_read=bytes_read, was_truncated=was_truncated
)
def _validate_inputs(self, args: ReadFileArgs) -> None:
if not args.path.strip():
raise ToolError("Path cannot be empty")
if args.offset < 0:
raise ToolError("Offset cannot be negative")
if args.limit is not None and args.limit <= 0:
raise ToolError("Limit, if provided, must be a positive number")
def _validate_path(self, file_path: Path) -> None:
try:
resolved_path = file_path.resolve()
except ValueError:
raise ToolError(
f"Security error: Cannot read path '{file_path}' outside of the project directory '{Path.cwd()}'."
)
except FileNotFoundError:
raise ToolError(f"File not found at: {file_path}")
if not resolved_path.exists():
raise ToolError(f"File not found at: {file_path}")
if resolved_path.is_dir():
raise ToolError(f"Path is a directory, not a file: {file_path}")
@classmethod
def format_call_display(cls, args: ReadFileArgs) -> ToolCallDisplay:
tag = " (scratchpad)" if is_scratchpad_path(args.path) else ""
summary = f"Reading {args.path}"
if args.offset > 0 or args.limit is not None:
parts = []
if args.offset > 0:
parts.append(f"from line {args.offset}")
if args.limit is not None:
parts.append(f"limit {args.limit} lines")
summary += f" ({', '.join(parts)})"
return ToolCallDisplay(summary=f"{summary}{tag}")
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if not isinstance(event.result, ReadFileResult):
return ToolResultDisplay(
success=False, message=event.error or event.skip_reason or "No result"
)
path_obj = Path(event.result.path)
tag = " (scratchpad)" if is_scratchpad_path(event.result.path) else ""
message = f"Read {event.result.lines_read} line{'' if event.result.lines_read <= 1 else 's'} from {path_obj.name}{tag}"
if event.result.was_truncated:
message += " (truncated)"
return ToolResultDisplay(
success=True,
message=message,
warnings=["File was truncated due to size limit"]
if event.result.was_truncated
else [],
)
@classmethod
def get_status_text(cls) -> str:
return "Reading file"

View file

@ -1,480 +0,0 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
import difflib
from pathlib import Path
import re
import shutil
from typing import ClassVar, NamedTuple, final
import anyio
from pydantic import BaseModel, Field
from vibe.core.rewind.manager import FileSnapshot
from vibe.core.scratchpad import is_scratchpad_path
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
BaseToolState,
InvokeContext,
ToolError,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.tools.utils import resolve_file_tool_permission
from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils.io import ReadSafeResult, read_safe_async
SEARCH_REPLACE_BLOCK_RE = re.compile(
r"<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE", flags=re.DOTALL
)
SEARCH_REPLACE_BLOCK_WITH_FENCE_RE = re.compile(
r"```[\s\S]*?\n<{5,} SEARCH\r?\n(.*?)\r?\n?={5,}\r?\n(.*?)\r?\n?>{5,} REPLACE\s*\n```",
flags=re.DOTALL,
)
class SearchReplaceBlock(NamedTuple):
search: str
replace: str
class FuzzyMatch(NamedTuple):
similarity: float
start_line: int
end_line: int
text: str
class BlockApplyResult(NamedTuple):
content: str
applied: int
errors: list[str]
warnings: list[str]
class SearchReplaceArgs(BaseModel):
file_path: str
content: str
class SearchReplaceResult(BaseModel):
file: str
blocks_applied: int
lines_changed: int
content: str
warnings: list[str] = Field(default_factory=list)
class SearchReplaceConfig(BaseToolConfig):
sensitive_patterns: list[str] = Field(
default=["**/.env", "**/.env.*"],
description="File patterns that trigger ASK even when permission is ALWAYS.",
)
max_content_size: int = 100_000
create_backup: bool = False
fuzzy_threshold: float = 0.9
class SearchReplace(
BaseTool[
SearchReplaceArgs, SearchReplaceResult, SearchReplaceConfig, BaseToolState
],
ToolUIData[SearchReplaceArgs, SearchReplaceResult],
):
description: ClassVar[str] = (
"Replace sections of files using SEARCH/REPLACE blocks. "
"Supports fuzzy matching and detailed error reporting. "
"Format: <<<<<<< SEARCH\\n[text]\\n=======\\n[replacement]\\n>>>>>>> REPLACE"
)
@classmethod
def format_call_display(cls, args: SearchReplaceArgs) -> ToolCallDisplay:
tag = " (scratchpad)" if is_scratchpad_path(args.file_path) else ""
blocks = cls._parse_search_replace_blocks(args.content)
return ToolCallDisplay(
summary=f"Patching {args.file_path} ({len(blocks)} blocks){tag}",
content=args.content,
)
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if isinstance(event.result, SearchReplaceResult):
path_name = Path(event.result.file).name
tag = " (scratchpad)" if is_scratchpad_path(event.result.file) else ""
return ToolResultDisplay(
success=True,
message=f"Applied {event.result.blocks_applied} block{'' if event.result.blocks_applied == 1 else 's'} to {path_name}{tag}",
warnings=event.result.warnings,
)
return ToolResultDisplay(success=True, message="Patch applied")
@classmethod
def get_status_text(cls) -> str:
return "Editing files"
def get_file_snapshot(self, args: SearchReplaceArgs) -> FileSnapshot | None:
return self.get_file_snapshot_for_path(args.file_path)
def resolve_permission(self, args: SearchReplaceArgs) -> PermissionContext | None:
return resolve_file_tool_permission(
args.file_path,
tool_name=self.get_name(),
allowlist=self.config.allowlist,
denylist=self.config.denylist,
config_permission=self.config.permission,
sensitive_patterns=self.config.sensitive_patterns,
)
@final
async def run(
self, args: SearchReplaceArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | SearchReplaceResult, None]:
file_path, search_replace_blocks = self._prepare_and_validate_args(args)
decoded = await self._read_file(file_path)
original_content = decoded.text
block_result = self._apply_blocks(
original_content,
search_replace_blocks,
file_path,
self.config.fuzzy_threshold,
)
if block_result.errors:
error_message = "SEARCH/REPLACE blocks failed:\n" + "\n\n".join(
block_result.errors
)
if block_result.warnings:
error_message += "\n\nWarnings encountered:\n" + "\n".join(
block_result.warnings
)
raise ToolError(error_message)
modified_content = block_result.content
# Calculate line changes
if modified_content == original_content:
lines_changed = 0
else:
original_lines = len(original_content.splitlines())
new_lines = len(modified_content.splitlines())
lines_changed = new_lines - original_lines
try:
if self.config.create_backup:
await self._backup_file(file_path)
except Exception:
pass
await self._write_file(
file_path, modified_content, decoded.encoding, decoded.newline
)
yield SearchReplaceResult(
file=str(file_path),
blocks_applied=block_result.applied,
lines_changed=lines_changed,
warnings=block_result.warnings,
content=args.content,
)
@final
def _prepare_and_validate_args(
self, args: SearchReplaceArgs
) -> tuple[Path, list[SearchReplaceBlock]]:
file_path_str = args.file_path.strip()
content = args.content.strip()
if not file_path_str:
raise ToolError("File path cannot be empty")
if len(content) > self.config.max_content_size:
raise ToolError(
f"Content size ({len(content)} bytes) exceeds max_content_size "
f"({self.config.max_content_size} bytes)"
)
if not content:
raise ToolError("Empty content provided")
project_root = Path.cwd()
file_path = Path(file_path_str).expanduser()
if not file_path.is_absolute():
file_path = project_root / file_path
file_path = file_path.resolve()
if not file_path.exists():
raise ToolError(f"File does not exist: {file_path}")
if not file_path.is_file():
raise ToolError(f"Path is not a file: {file_path}")
search_replace_blocks = self._parse_search_replace_blocks(content)
if not search_replace_blocks:
raise ToolError(
"No valid SEARCH/REPLACE blocks found in content.\n"
"Expected format:\n"
"<<<<<<< SEARCH\n"
"[exact content to find]\n"
"=======\n"
"[new content to replace with]\n"
">>>>>>> REPLACE"
)
return file_path, search_replace_blocks
async def _read_file(self, file_path: Path) -> ReadSafeResult:
try:
return await read_safe_async(file_path, raise_on_error=True)
except PermissionError:
raise ToolError(f"Permission denied reading file: {file_path}")
except OSError as e:
raise ToolError(f"OS error reading {file_path}: {e}") from e
except Exception as e:
raise ToolError(f"Unexpected error reading {file_path}: {e}") from e
async def _backup_file(self, file_path: Path) -> None:
shutil.copy2(file_path, file_path.with_suffix(file_path.suffix + ".bak"))
async def _write_file(
self, file_path: Path, content: str, encoding: str, newline: str
) -> None:
try:
async with await anyio.Path(file_path).open(
mode="w", encoding=encoding, newline=newline
) as f:
await f.write(content)
except UnicodeEncodeError as e:
raise ToolError(
f"Cannot encode patched content for {file_path} using {encoding!r}: {e}"
) from e
except PermissionError:
raise ToolError(f"Permission denied writing to file: {file_path}")
except OSError as e:
raise ToolError(f"OS error writing to {file_path}: {e}") from e
except Exception as e:
raise ToolError(f"Unexpected error writing to {file_path}: {e}") from e
@final
@staticmethod
def _apply_blocks(
content: str,
blocks: list[SearchReplaceBlock],
filepath: Path,
fuzzy_threshold: float = 0.9,
) -> BlockApplyResult:
applied = 0
errors: list[str] = []
warnings: list[str] = []
current_content = content
for i, (search, replace) in enumerate(blocks, 1):
if search not in current_content:
context = SearchReplace._find_search_context(current_content, search)
fuzzy_context = SearchReplace._find_fuzzy_match_context(
current_content, search, fuzzy_threshold
)
error_msg = (
f"SEARCH/REPLACE block {i} failed: Search text not found in {filepath}\n"
f"Search text was:\n{search!r}\n"
f"Context analysis:\n{context}"
)
if fuzzy_context:
error_msg += f"\n{fuzzy_context}"
error_msg += (
"\nDebugging tips:\n"
"1. Check for exact whitespace/indentation match\n"
"2. Verify line endings match the file exactly (\\r\\n vs \\n)\n"
"3. Ensure the search text hasn't been modified by previous blocks or user edits\n"
"4. Check for typos or case sensitivity issues"
)
errors.append(error_msg)
continue
occurrences = current_content.count(search)
if occurrences > 1:
warning_msg = (
f"Search text in block {i} appears {occurrences} times in the file. "
f"Only the first occurrence will be replaced. Consider making your "
f"search pattern more specific to avoid unintended changes."
)
warnings.append(warning_msg)
current_content = current_content.replace(search, replace, 1)
applied += 1
return BlockApplyResult(
content=current_content, applied=applied, errors=errors, warnings=warnings
)
@final
@staticmethod
def _find_fuzzy_match_context(
content: str, search_text: str, threshold: float = 0.9
) -> str | None:
best_match = SearchReplace._find_best_fuzzy_match(
content, search_text, threshold
)
if not best_match:
return None
diff = SearchReplace._create_unified_diff(
search_text, best_match.text, "SEARCH", "CLOSEST MATCH"
)
similarity_pct = best_match.similarity * 100
return (
f"Closest fuzzy match (similarity {similarity_pct:.1f}%) "
f"at lines {best_match.start_line}{best_match.end_line}:\n"
f"```diff\n{diff}\n```"
)
@final
@staticmethod
def _find_best_fuzzy_match( # noqa: PLR0914
content: str, search_text: str, threshold: float = 0.9
) -> FuzzyMatch | None:
content_lines = content.split("\n")
search_lines = search_text.split("\n")
window_size = len(search_lines)
if window_size == 0:
return None
non_empty_search = [line for line in search_lines if line.strip()]
if not non_empty_search:
return None
first_anchor = non_empty_search[0]
last_anchor = (
non_empty_search[-1] if len(non_empty_search) > 1 else first_anchor
)
candidate_starts = set()
spread = 5
for i, line in enumerate(content_lines):
if first_anchor in line or last_anchor in line:
start_min = max(0, i - spread)
start_max = min(len(content_lines) - window_size + 1, i + spread + 1)
for s in range(start_min, start_max):
candidate_starts.add(s)
if not candidate_starts:
max_positions = min(len(content_lines) - window_size + 1, 100)
candidate_starts = set(range(0, max_positions))
best_match = None
best_similarity = 0.0
for start in candidate_starts:
end = start + window_size
window_text = "\n".join(content_lines[start:end])
matcher = difflib.SequenceMatcher(None, search_text, window_text)
similarity = matcher.ratio()
if similarity >= threshold and similarity > best_similarity:
best_similarity = similarity
best_match = FuzzyMatch(
similarity=similarity,
start_line=start + 1, # 1-based line numbers
end_line=end,
text=window_text,
)
return best_match
@final
@staticmethod
def _create_unified_diff(
text1: str, text2: str, label1: str = "SEARCH", label2: str = "CLOSEST MATCH"
) -> str:
lines1 = text1.splitlines(keepends=True)
lines2 = text2.splitlines(keepends=True)
lines1 = [line if line.endswith("\n") else line + "\n" for line in lines1]
lines2 = [line if line.endswith("\n") else line + "\n" for line in lines2]
diff = difflib.unified_diff(
lines1, lines2, fromfile=label1, tofile=label2, lineterm="", n=3
)
diff_lines = list(diff)
if diff_lines and not diff_lines[0].startswith("==="):
diff_lines.insert(2, "=" * 67 + "\n")
result = "".join(diff_lines)
max_chars = 2000
if len(result) > max_chars:
result = result[:max_chars] + "\n...(diff truncated)"
return result.rstrip()
@final
@staticmethod
def _parse_search_replace_blocks(content: str) -> list[SearchReplaceBlock]:
"""Parse SEARCH/REPLACE blocks from content.
Supports two formats:
1. With code block fences (```...```)
2. Without code block fences
"""
matches = SEARCH_REPLACE_BLOCK_WITH_FENCE_RE.findall(content)
if not matches:
matches = SEARCH_REPLACE_BLOCK_RE.findall(content)
return [
SearchReplaceBlock(
search=search.rstrip("\r\n"), replace=replace.rstrip("\r\n")
)
for search, replace in matches
]
@final
@staticmethod
def _find_search_context(
content: str, search_text: str, max_context: int = 5
) -> str:
lines = content.split("\n")
search_lines = search_text.split("\n")
if not search_lines:
return "Search text is empty"
first_search_line = search_lines[0].strip()
if not first_search_line:
return "First line of search text is empty or whitespace only"
matches = []
for i, line in enumerate(lines):
if first_search_line in line:
matches.append(i)
if not matches:
return f"First search line '{first_search_line}' not found anywhere in file"
context_lines = []
for match_idx in matches[:3]:
start = max(0, match_idx - max_context)
end = min(len(lines), match_idx + max_context + 1)
context_lines.append(f"\nPotential match area around line {match_idx + 1}:")
for i in range(start, end):
marker = ">>>" if i == match_idx else " "
context_lines.append(f"{marker} {i + 1:3d}: {lines[i]}")
return "\n".join(context_lines)

View file

@ -26,15 +26,11 @@ from vibe.core.types import ToolResultEvent, ToolStreamEvent
class WriteFileArgs(BaseModel):
path: str
content: str
overwrite: bool = Field(
default=False, description="Must be set to true to overwrite an existing file."
)
class WriteFileResult(BaseModel):
path: str
bytes_written: int
file_existed: bool
content: str
@ -53,24 +49,22 @@ class WriteFile(
ToolUIData[WriteFileArgs, WriteFileResult],
):
description: ClassVar[str] = (
"Create or overwrite a UTF-8 file. Fails if file exists unless 'overwrite=True'."
"Create a UTF-8 file. Fails if the file already exists; use search_replace to edit."
)
@classmethod
def format_call_display(cls, args: WriteFileArgs) -> ToolCallDisplay:
tag = " (scratchpad)" if is_scratchpad_path(args.path) else ""
overwrite = " (overwrite)" if args.overwrite else ""
return ToolCallDisplay(
summary=f"Writing {args.path}{overwrite}{tag}", content=args.content
summary=f"Writing {args.path}{tag}", content=args.content
)
@classmethod
def get_result_display(cls, event: ToolResultEvent) -> ToolResultDisplay:
if isinstance(event.result, WriteFileResult):
action = "Overwritten" if event.result.file_existed else "Created"
tag = " (scratchpad)" if is_scratchpad_path(event.result.path) else ""
return ToolResultDisplay(
success=True, message=f"{action} {Path(event.result.path).name}{tag}"
success=True, message=f"Created {Path(event.result.path).name}{tag}"
)
return ToolResultDisplay(success=True, message="File written")
@ -96,18 +90,15 @@ class WriteFile(
async def run(
self, args: WriteFileArgs, ctx: InvokeContext | None = None
) -> AsyncGenerator[ToolStreamEvent | WriteFileResult, None]:
file_path, file_existed, content_bytes = self._prepare_and_validate_path(args)
file_path, content_bytes = self._prepare_and_validate_path(args)
await self._write_file(args, file_path)
yield WriteFileResult(
path=str(file_path),
bytes_written=content_bytes,
file_existed=file_existed,
content=args.content,
path=str(file_path), bytes_written=content_bytes, content=args.content
)
def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, bool, int]:
def _prepare_and_validate_path(self, args: WriteFileArgs) -> tuple[Path, int]:
if not args.path.strip():
raise ToolError("Path cannot be empty")
@ -122,11 +113,9 @@ class WriteFile(
file_path = Path.cwd() / file_path
file_path = file_path.resolve()
file_existed = file_path.exists()
if file_existed and not args.overwrite:
if file_path.exists():
raise ToolError(
f"File '{file_path}' exists. Set overwrite=True to replace."
f"File '{file_path}' already exists. Use search_replace to edit it."
)
if self.config.create_parent_dirs:
@ -134,13 +123,17 @@ class WriteFile(
elif not file_path.parent.exists():
raise ToolError(f"Parent directory does not exist: {file_path.parent}")
return file_path, file_existed, content_bytes
return file_path, content_bytes
async def _write_file(self, args: WriteFileArgs, file_path: Path) -> None:
try:
async with await anyio.Path(file_path).open(
mode="w", encoding="utf-8"
mode="x", encoding="utf-8"
) as f:
await f.write(args.content)
except FileExistsError as e:
raise ToolError(
f"File '{file_path}' already exists. Use search_replace to edit it."
) from e
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e

View file

@ -4,5 +4,6 @@ from vibe.core.tools.connectors.connector_registry import (
ConnectorAuthAction,
ConnectorRegistry,
)
from vibe.core.tools.connectors.counts import compute_connector_counts
__all__ = ["ConnectorAuthAction", "ConnectorRegistry"]
__all__ = ["ConnectorAuthAction", "ConnectorRegistry", "compute_connector_counts"]

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from vibe.core.tools.connectors.connector_registry import ConnectorRegistry
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
def compute_connector_counts(
config: VibeConfig, connector_registry: ConnectorRegistry | None
) -> tuple[int, int]:
if connector_registry is None:
return (0, 0)
aliases = connector_registry.get_connector_names()
if not aliases:
return (0, 0)
by_name = config.connectors_by_name()
connected = sum(
(cfg := by_name.get(alias)) is not None
and not cfg.disabled
and connector_registry.is_connected(alias)
for alias in aliases
)
return (connected, len(aliases))

View file

@ -87,7 +87,7 @@ class ToolManager:
self._lock = threading.Lock()
self._mcp_integrated = False
self._available: dict[str, type[BaseTool]] = {
self._all_tools: dict[str, type[BaseTool]] = {
cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths)
}
if not defer_mcp:
@ -190,14 +190,14 @@ class ToolManager:
@property
def registered_tools(self) -> dict[str, type[BaseTool]]:
with self._lock:
return dict(self._available)
return dict(self._all_tools)
@property
def available_tools(self) -> dict[str, type[BaseTool]]:
with self._lock:
runtime_available = {
name: cls
for name, cls in self._available.items()
for name, cls in self._all_tools.items()
if self._is_tool_available(cls)
}
@ -254,20 +254,15 @@ class ToolManager:
elif srv.disabled_tools:
per_source_disabled[key] = set(srv.disabled_tools)
# Track connector names that have explicit config entries
configured_connectors = {cfg.name for cfg in self._config.connectors}
for cfg in self._config.connectors:
key = (cfg.name, True)
if cfg.disabled:
disabled_sources.add(key)
elif cfg.disabled_tools:
per_source_disabled[key] = set(cfg.disabled_tools)
if cfg.disabled_tools and not cfg.disabled:
per_source_disabled[(cfg.name, True)] = set(cfg.disabled_tools)
# Disable connectors discovered but not in config (new connectors)
if self._connector_registry is not None:
by_name = self._config.connectors_by_name()
for name in self._connector_registry.get_connector_names():
if name not in configured_connectors:
cfg = by_name.get(name)
if cfg is None or cfg.disabled:
disabled_sources.add((name, True))
return disabled_sources, per_source_disabled
@ -314,7 +309,7 @@ class ToolManager:
return
with self._lock:
self._available = {**self._available, **mcp_tools}
self._all_tools = {**self._all_tools, **mcp_tools}
self._mcp_integrated = True
logger.info(
"MCP integration registered %d tools (via registry)", len(mcp_tools)
@ -324,22 +319,22 @@ class ToolManager:
"""Remove stale connector tool classes and cached instances."""
stale_keys = [
name
for name, cls in self._available.items()
for name, cls in self._all_tools.items()
if issubclass(cls, MCPTool) and cls.is_connector()
]
for key in stale_keys:
self._available.pop(key, None)
self._all_tools.pop(key, None)
self._instances.pop(key, None)
def _purge_mcp_state(self) -> None:
"""Remove stale MCP tool classes and cached instances."""
stale_keys = [
name
for name, cls in self._available.items()
for name, cls in self._all_tools.items()
if issubclass(cls, MCPTool) and not cls.is_connector()
]
for key in stale_keys:
self._available.pop(key, None)
self._all_tools.pop(key, None)
self._instances.pop(key, None)
def integrate_connectors(self) -> None:
@ -364,7 +359,7 @@ class ToolManager:
with self._lock:
self._purge_connector_state()
self._available.update(connector_tools)
self._all_tools.update(connector_tools)
logger.info(f"Connector integration registered {len(connector_tools)} tools")
async def refresh_remote_tools_async(self) -> None:
@ -414,7 +409,7 @@ class ToolManager:
def get_tool_config(self, tool_name: str) -> BaseToolConfig:
with self._lock:
tool_class = self._available.get(tool_name)
tool_class = self._all_tools.get(tool_name)
if tool_class:
config_class = tool_class._get_tool_config_class()
@ -444,12 +439,13 @@ class ToolManager:
if tool_name in self._instances:
return self._instances[tool_name]
with self._lock:
if tool_name not in self._available:
raise NoSuchToolError(
f"Unknown tool: {tool_name}. Available: {list(self._available.keys())}"
)
tool_class = self._available[tool_name]
available = self.available_tools
if tool_name not in available:
raise NoSuchToolError(
f"Unknown or disabled tool: {tool_name}. "
f"Available: {list(available.keys())}"
)
tool_class = available[tool_name]
self._instances[tool_name] = tool_class.from_config(
lambda: self.get_tool_config(tool_name)
)

View file

@ -8,28 +8,77 @@ import tomli_w
from vibe.core.paths import (
AGENTS_MD_FILENAME,
TRUSTED_FOLDERS_FILE,
walk_local_config_dirs,
find_local_config_dirs,
)
def has_agents_md_file(path: Path) -> bool:
return (path / AGENTS_MD_FILENAME).exists()
return (path / AGENTS_MD_FILENAME).is_file()
def _is_git_repo_root(path: Path) -> bool:
git_dir = path / ".git"
return git_dir.is_dir() and (git_dir / "HEAD").is_file()
def find_git_repo_ancestor(path: Path) -> Path | None:
"""Closest ancestor (or *path*) with a real ``.git/HEAD``.
Excludes the home directory and the filesystem root.
"""
resolved = path.expanduser().resolve()
home = Path.home().resolve()
current = resolved
while current not in {home, current.parent}:
if _is_git_repo_root(current):
return current
current = current.parent
return None
def find_trustable_files(path: Path) -> list[str]:
"""Return relative paths of files/dirs that would modify the agent's behavior."""
"""Relative paths of files/dirs under *path* that would modify agent behavior."""
resolved = path.resolve()
found: list[str] = []
if has_agents_md_file(path):
found.append(AGENTS_MD_FILENAME)
for config_dir in walk_local_config_dirs(path).config_dirs:
for config_dir in find_local_config_dirs(path).config_dirs:
label = f"{config_dir.relative_to(resolved)}/"
if label not in found:
found.append(label)
return found
return sorted(found)
def find_repo_trustable_files_for_cwd(cwd: Path, repo_root: Path | None) -> list[str]:
"""Repo-context files that influence *cwd* when inside a git repository.
Includes:
- all trustable files at ``repo_root``
- all ``AGENTS.md`` files on ancestors between ``cwd`` and ``repo_root``
"""
if repo_root is None:
return []
resolved_cwd = cwd.resolve()
resolved_repo_root = repo_root.resolve()
if resolved_repo_root not in resolved_cwd.parents:
return []
found = set(find_trustable_files(resolved_repo_root))
current = resolved_cwd.parent
while current != resolved_repo_root:
if has_agents_md_file(current):
relative_path = (current / AGENTS_MD_FILENAME).relative_to(
resolved_repo_root
)
found.add(relative_path.as_posix())
current = current.parent
return sorted(found)
class TrustedFoldersManager:
@ -72,38 +121,38 @@ class TrustedFoldersManager:
except OSError:
pass
def is_trusted(self, path: Path) -> bool | None:
"""Check trust walking up from *path* to filesystem root.
The first ancestor (or *path* itself) found in either the trusted,
session-trusted, or untrusted list wins. Returns ``None`` when no
decision exists.
"""
def _closest_decision(self, path: Path) -> tuple[bool, Path] | None:
"""``(trusted, ancestor)`` for the closest decision, ``None`` if undecided."""
current = Path(self._normalize_path(path))
while True:
s = str(current)
if s in self._trusted or s in self._session_trusted:
return True
return True, current
if s in self._untrusted:
return False
parent = current.parent
if parent == current:
break
current = parent
return None
return False, current
if current.parent == current:
return None
current = current.parent
def is_trusted(self, path: Path) -> bool | None:
"""Tri-state closest decision; ``None`` when no ancestor has one."""
match self._closest_decision(path):
case (trusted, _):
return trusted
case None:
return None
def is_explicitly_untrusted(self, path: Path) -> bool:
"""*path* literally in the untrusted list (no ancestor walk)."""
return self._normalize_path(path) in self._untrusted
def find_trust_root(self, path: Path) -> Path | None:
"""Return the closest ancestor (or *path* itself) explicitly trusted."""
current = Path(self._normalize_path(path))
while True:
s = str(current)
if s in self._trusted or s in self._session_trusted:
return current
parent = current.parent
if parent == current:
break
current = parent
return None
"""Closest explicitly trusted ancestor; ``None`` if a closer untrust blocks."""
match self._closest_decision(path):
case (True, root):
return root
case _:
return None
def add_trusted(self, path: Path) -> None:
normalized = self._normalize_path(path)

View file

@ -216,11 +216,25 @@ class ApprovalResponse(StrEnum):
NO = "n"
IMAGE_EXTENSIONS: frozenset[str] = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
MAX_IMAGE_BYTES: int = 10 * 1024 * 1024
MAX_IMAGES_PER_MESSAGE: int = 8
class ImageAttachment(BaseModel):
model_config = ConfigDict(extra="ignore")
path: Path
alias: str
mime_type: str
class LLMMessage(BaseModel):
model_config = ConfigDict(extra="ignore")
role: Role
content: Content | None = None
images: list[ImageAttachment] | None = None
injected: bool = False
reasoning_content: Content | None = None
reasoning_state: list[str] | None = None
@ -255,6 +269,7 @@ class LLMMessage(BaseModel):
"tool_calls": getattr(v, "tool_calls", None),
"name": getattr(v, "name", None),
"tool_call_id": getattr(v, "tool_call_id", None),
"images": getattr(v, "images", None),
"message_id": getattr(v, "message_id", None)
or (str(uuid4()) if role != "tool" else None),
}
@ -317,6 +332,7 @@ class LLMMessage(BaseModel):
return LLMMessage(
role=self.role,
content=content,
images=self.images if self.images is not None else other.images,
reasoning_content=reasoning_content,
reasoning_state=reasoning_state,
reasoning_signature=reasoning_signature,

View file

@ -1,9 +1,14 @@
from __future__ import annotations
from collections.abc import Iterator
import asyncio
from collections.abc import AsyncIterator, Iterator
import contextlib
from contextlib import asynccontextmanager
import locale
import os
from pathlib import Path
import shutil
import time
from typing import NamedTuple
import anyio
@ -106,3 +111,106 @@ async def read_safe_async(
"""Async :func:`read_safe` (``anyio``)."""
raw = await anyio.Path(path).read_bytes()
return decode_safe(raw, raise_on_error=raise_on_error)
class BoundedReadResult(NamedTuple):
r"""A bounded slice of a file's lines plus truncation metadata.
``lines`` are decoded and ``\n``-normalized, without trailing newlines.
``total_lines`` is ``None`` when the read stopped early at the line or byte
budget (the true total is unknown without scanning the rest of the file);
otherwise it is the number of lines in the file. ``was_truncated`` is true
when the read stopped before reaching end of file.
"""
lines: list[str]
total_lines: int | None
was_truncated: bool
def read_lines_safe(
path: Path, *, start_line: int = 1, limit: int, max_bytes: int
) -> BoundedReadResult:
r"""Read up to ``limit`` lines from ``start_line`` (1-indexed) bounded by bytes.
Streams the file line-by-line in binary and stops once ``limit`` lines or
``max_bytes`` of selected content have been collected, so large files are
never loaded whole. The collected bytes are decoded once via
:func:`decode_safe`, which also normalizes ``\r\n``/``\r`` to ``\n``.
"""
raw_lines: list[bytes] = []
bytes_read = 0
line_number = 0
was_truncated = True
with path.open("rb") as f:
while raw_line := f.readline():
line_number += 1
if line_number < start_line:
continue
if len(raw_lines) >= limit:
break
if bytes_read + len(raw_line) > max_bytes:
remaining = max_bytes - bytes_read
if remaining > 0:
raw_lines.append(raw_line[:remaining])
break
raw_lines.append(raw_line)
bytes_read += len(raw_line)
else:
was_truncated = False
total_lines = None if was_truncated else line_number
lines = decode_safe(b"".join(raw_lines)).text.splitlines()
return BoundedReadResult(lines, total_lines, was_truncated)
async def read_lines_safe_async(
path: Path, *, start_line: int = 1, limit: int, max_bytes: int
) -> BoundedReadResult:
"""Async :func:`read_lines_safe` (runs the blocking read in a thread)."""
return await asyncio.to_thread(
read_lines_safe, path, start_line=start_line, limit=limit, max_bytes=max_bytes
)
_FILE_WRITE_LOCKS: dict[str, asyncio.Lock] = {}
_FILE_WRITE_LOCK_LOOP: asyncio.AbstractEventLoop | None = None
def _get_lock(path: Path) -> asyncio.Lock:
global _FILE_WRITE_LOCK_LOOP
loop = asyncio.get_running_loop()
if _FILE_WRITE_LOCK_LOOP is not loop:
_FILE_WRITE_LOCKS.clear()
_FILE_WRITE_LOCK_LOOP = loop
key = str(path.resolve())
lock = _FILE_WRITE_LOCKS.get(key)
if lock is None:
lock = _FILE_WRITE_LOCKS[key] = asyncio.Lock()
return lock
@asynccontextmanager
async def file_write_lock(path: Path) -> AsyncIterator[None]:
async with _get_lock(path):
yield
async def atomic_replace(
path: Path, content: str, *, encoding: str = "utf-8", newline: str | None = None
) -> None:
target = Path(path)
tmp = target.parent / f".{target.name}.tmp.{os.getpid()}.{time.time_ns()}"
try:
async with await anyio.Path(tmp).open(
mode="w", encoding=encoding, newline=newline
) as f:
await f.write(content)
with contextlib.suppress(FileNotFoundError):
shutil.copymode(target, tmp)
os.replace(tmp, target)
except BaseException:
with contextlib.suppress(FileNotFoundError):
tmp.unlink()
raise

View file

@ -3,13 +3,26 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Awaitable, Callable
import functools
import logging
import httpx
logger = logging.getLogger("vibe")
_RETRYABLE_REQUEST_ERRORS: tuple[type[httpx.RequestError], ...] = (
httpx.TimeoutException,
httpx.ConnectError,
httpx.ReadError,
httpx.WriteError,
httpx.RemoteProtocolError,
)
def _is_retryable_http_error(e: Exception) -> bool:
if isinstance(e, httpx.HTTPStatusError):
return e.response.status_code in {408, 409, 425, 429, 500, 502, 503, 504, 529}
if isinstance(e, _RETRYABLE_REQUEST_ERRORS):
return True
return False
@ -43,6 +56,14 @@ def async_retry[T, **P](
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
0.05 * attempt
)
logger.warning(
"Retrying %s after error attempt=%d/%d delay=%.2fs error=%r",
func.__qualname__,
attempt + 1,
tries,
current_delay,
e,
)
await asyncio.sleep(current_delay)
continue
raise e
@ -81,19 +102,33 @@ def async_generator_retry[T, **P](
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator[T]:
last_exc = None
for attempt in range(tries):
generator = func(*args, **kwargs)
try:
async for item in func(*args, **kwargs):
yield item
first_item = await anext(generator)
except StopAsyncIteration:
return
except Exception as e:
last_exc = e
await generator.aclose()
if attempt < tries - 1 and is_retryable(e):
current_delay = (delay_seconds * (backoff_factor**attempt)) + (
0.05 * attempt
)
logger.warning(
"Retrying %s after error attempt=%d/%d delay=%.2fs error=%r",
func.__qualname__,
attempt + 1,
tries,
current_delay,
e,
)
await asyncio.sleep(current_delay)
continue
raise e
raise
yield first_item
async for item in generator:
yield item
return
raise RuntimeError(
f"Retries exhausted. Last error: {last_exc}"
) from last_exc

View file

@ -3,8 +3,12 @@ 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,
BrowserSignInAttemptStarted,
BrowserSignInEvent,
BrowserSignInEventCallback,
BrowserSignInService,
BrowserSignInStatus,
BrowserSignInStatusChanged,
)
from vibe.setup.auth.browser_sign_in_gateway import (
BrowserSignInError,
@ -19,13 +23,17 @@ __all__ = [
"AuthState",
"AuthStateKind",
"BrowserSignInAttempt",
"BrowserSignInAttemptStarted",
"BrowserSignInError",
"BrowserSignInErrorCode",
"BrowserSignInEvent",
"BrowserSignInEventCallback",
"BrowserSignInGateway",
"BrowserSignInPollResult",
"BrowserSignInProcess",
"BrowserSignInService",
"BrowserSignInStatus",
"BrowserSignInStatusChanged",
"HttpBrowserSignInGateway",
"assess_auth_state",
]

View file

@ -24,12 +24,27 @@ class BrowserSignInStatus(StrEnum):
COMPLETED = "completed"
StatusCallback = Callable[[BrowserSignInStatus], None]
BrowserOpener = Callable[[str], bool]
SleepFn = Callable[[float], Awaitable[None]]
NowFn = Callable[[], datetime]
@dataclass(frozen=True)
class BrowserSignInAttemptStarted:
sign_in_url: str
# Keeps attempt expiry available to future UIs without changing the event contract.
expires_at: datetime
@dataclass(frozen=True)
class BrowserSignInStatusChanged:
status: BrowserSignInStatus
BrowserSignInEvent = BrowserSignInAttemptStarted | BrowserSignInStatusChanged
BrowserSignInEventCallback = Callable[[BrowserSignInEvent], None]
@dataclass(frozen=True)
class BrowserSignInAttempt:
process_id: str
@ -71,26 +86,39 @@ class BrowserSignInService:
code_verifier=verifier,
)
async def complete_attempt(
async def complete_attempt(self, attempt: BrowserSignInAttempt) -> str:
return await self._complete_attempt(attempt)
async def authenticate(
self, event_callback: BrowserSignInEventCallback | None = None
) -> str:
attempt = await self.start_attempt()
event = BrowserSignInAttemptStarted(
sign_in_url=attempt.sign_in_url, expires_at=attempt.expires_at
)
if event_callback is not None:
event_callback(event)
self._emit_status(event_callback, BrowserSignInStatus.OPENING_BROWSER)
self._open_browser_or_raise(attempt.sign_in_url)
return await self._complete_attempt(attempt, event_callback=event_callback)
async def _complete_attempt(
self,
attempt: BrowserSignInAttempt,
status_callback: StatusCallback | None = None,
*,
event_callback: BrowserSignInEventCallback | None = None,
) -> str:
self._emit(status_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN)
self._emit_status(
event_callback, BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN
)
exchange_token = await self._wait_for_completion(attempt)
self._emit(status_callback, BrowserSignInStatus.EXCHANGING)
self._emit_status(event_callback, BrowserSignInStatus.EXCHANGING)
api_key = await self._gateway.exchange(
attempt.process_id, exchange_token, attempt.code_verifier
)
self._emit(status_callback, BrowserSignInStatus.COMPLETED)
self._emit_status(event_callback, BrowserSignInStatus.COMPLETED)
return api_key
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() < attempt.expires_at:
@ -148,11 +176,11 @@ class BrowserSignInService:
)
await self._sleep(min(self._poll_interval, remaining_seconds))
def _emit(
self, callback: StatusCallback | None, status: BrowserSignInStatus
def _emit_status(
self, callback: BrowserSignInEventCallback | None, status: BrowserSignInStatus
) -> None:
if callback is not None:
callback(status)
callback(BrowserSignInStatusChanged(status=status))
def _open_browser_or_raise(self, sign_in_url: str) -> None:
try:

View file

@ -7,6 +7,7 @@ from typing import Any
from rich import print as rprint
from textual.app import App
from vibe.cli.clipboard import try_copy_text_to_clipboard
from vibe.core.config import VibeConfig
from vibe.core.paths import GLOBAL_ENV_FILE
from vibe.core.telemetry.types import EntrypointMetadata
@ -19,7 +20,11 @@ from vibe.setup.onboarding.screens import (
ThemeSelectionScreen,
WelcomeScreen,
)
from vibe.setup.onboarding.screens.browser_sign_in import SUCCESS_EXIT_DELAY_SECONDS
from vibe.setup.onboarding.screens.browser_sign_in import (
SIGN_IN_URL_HELP_DELAY_SECONDS,
SUCCESS_EXIT_DELAY_SECONDS,
CopySignInUrl,
)
class OnboardingApp(App[str | None]):
@ -32,6 +37,8 @@ class OnboardingApp(App[str | None]):
| None = None,
entrypoint_metadata: EntrypointMetadata | None = None,
browser_sign_in_success_delay: float = SUCCESS_EXIT_DELAY_SECONDS,
browser_sign_in_url_help_delay: float = SIGN_IN_URL_HELP_DELAY_SECONDS,
copy_sign_in_url: CopySignInUrl | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -45,6 +52,8 @@ class OnboardingApp(App[str | None]):
self._vibe_base_url = config.vibe_base_url
self._entrypoint_metadata = entrypoint_metadata
self._browser_sign_in_success_delay = browser_sign_in_success_delay
self._browser_sign_in_url_help_delay = browser_sign_in_url_help_delay
self._copy_sign_in_url = copy_sign_in_url or self._copy_sign_in_url_to_clipboard
self._browser_sign_in_service_factory = self._resolve_browser_sign_in_factory(
browser_sign_in_service_factory
)
@ -72,8 +81,10 @@ class OnboardingApp(App[str | None]):
BrowserSignInScreen(
self._provider,
self._browser_sign_in_service_factory,
copy_sign_in_url=self._copy_sign_in_url,
entrypoint_metadata=self._entrypoint_metadata,
success_exit_delay=self._browser_sign_in_success_delay,
sign_in_url_help_delay=self._browser_sign_in_url_help_delay,
),
"browser_sign_in",
)
@ -109,6 +120,9 @@ class OnboardingApp(App[str | None]):
or self._build_browser_sign_in_service_factory()
)
def _copy_sign_in_url_to_clipboard(self, text: str) -> bool:
return try_copy_text_to_clipboard(text)
def run_onboarding(
app: App | None = None, *, entrypoint_metadata: EntrypointMetadata | None = None

View file

@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, TypeAdapter, ValidationError
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.config._settings import (
DEFAULT_ACTIVE_MODEL,
DEFAULT_ACTIVE_MODEL_CONFIG,
DEFAULT_MODELS,
DEFAULT_PROVIDERS,
DEFAULT_VIBE_BASE_URL,
@ -29,7 +29,7 @@ def _default_model_payloads() -> list[dict[str, Any]]:
class _OnboardingSnapshot(BaseModel):
active_model: str = DEFAULT_ACTIVE_MODEL
active_model: str = DEFAULT_ACTIVE_MODEL_CONFIG.alias
vibe_base_url: str = DEFAULT_VIBE_BASE_URL
providers: list[Any] = Field(default_factory=_default_provider_payloads)
models: list[Any] = Field(default_factory=_default_model_payloads)
@ -168,7 +168,7 @@ def _resolve_provider(
models = _validated_payloads(snapshot.models, ModelConfig)
for model_alias in (active_model, DEFAULT_ACTIVE_MODEL):
for model_alias in (active_model, DEFAULT_ACTIVE_MODEL_CONFIG.alias):
for model in models:
if model.alias != model_alias:
continue

View file

@ -326,8 +326,22 @@ ThemeSelectionScreen #enter-hint {
width: 100%;
color: $foreground-muted;
text-align: center;
margin-top: 2;
padding-left: 2;
}
#browser-sign-in-url {
width: 100%;
height: auto;
color: $foreground-muted;
link-style: none;
link-style-hover: none;
margin-top: 1;
padding-left: 2;
&:ansi {
text-style: dim;
}
}
/* =============================================================================

View file

@ -2,16 +2,18 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from enum import IntEnum
from typing import ClassVar, Literal
from rich.markup import escape
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, Horizontal, Vertical
from textual.reactive import reactive
from textual.timer import Timer
from textual.widgets import Static
from textual.worker import Worker
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
@ -20,10 +22,13 @@ from vibe.core.config import ProviderConfig
from vibe.core.logger import logger
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.setup.auth import (
BrowserSignInAttemptStarted,
BrowserSignInError,
BrowserSignInErrorCode,
BrowserSignInEvent,
BrowserSignInService,
BrowserSignInStatus,
BrowserSignInStatusChanged,
)
from vibe.setup.auth.api_key_persistence import (
persist_api_key,
@ -35,7 +40,12 @@ from vibe.setup.onboarding.gradient_text import GRADIENT_COLORS, append_gradient
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"
SUCCESS_HINT = "Finishing setup..."
SIGN_IN_URL_HELP_PREFIX = "If your browser did not open, "
SIGN_IN_URL_COPY_LABEL = "copy this URL"
SIGN_IN_URL_HELP_SUFFIX = " (press C)."
SIGN_IN_URL_REVEAL_PREFIX = "Copy failed. Open this URL manually:"
SUCCESS_EXIT_DELAY_SECONDS: float = 2.0
SIGN_IN_URL_HELP_DELAY_SECONDS: float = 4.0
WAITING_FOR_AUTHENTICATION_MESSAGE = "Waiting for authentication..."
STEP_DESCRIPTIONS = [
("Open browser", "Your browser should open automatically", "Browser opened"),
@ -49,6 +59,9 @@ UNEXPECTED_ERROR_MESSAGE = (
ERROR_MESSAGES = {
BrowserSignInErrorCode.POLL_FAILED: "We couldn't complete sign-in. Please try again."
}
COPY_URL_SUCCESS_MESSAGE = "Sign-in URL copied to clipboard"
CopySignInUrl = Callable[[str], bool]
class BrowserSignInStep(IntEnum):
@ -61,9 +74,21 @@ class BrowserSignInStep(IntEnum):
class BrowserSignInViewState:
step: BrowserSignInStep
message: str
hint: str
variant: Literal["pending", "error", "success"]
running: bool
sign_in_url: str | None = None
show_sign_in_url_help: bool = False
reveal_sign_in_url: bool = False
@property
def hint(self) -> str:
if self.variant == "success":
return SUCCESS_HINT
if self.variant == "error":
return ERROR_HINT
return PENDING_HINT
@dataclass(frozen=True)
@ -79,7 +104,6 @@ class BrowserSignInScreen(OnboardingScreen):
BrowserSignInViewState(
step=BrowserSignInStep.OPEN,
message="Getting things ready...",
hint=PENDING_HINT,
variant="pending",
running=False,
),
@ -88,6 +112,7 @@ class BrowserSignInScreen(OnboardingScreen):
BINDINGS: ClassVar[list[BindingType]] = [
Binding("r", "retry", "Retry", show=False),
Binding("c", "copy_url", "Copy URL", show=False),
Binding("m", "manual", "Manual", show=False),
Binding("ctrl+c", "cancel", "Cancel", show=False),
Binding("escape", "cancel", "Cancel", show=False),
@ -98,28 +123,33 @@ class BrowserSignInScreen(OnboardingScreen):
provider: ProviderConfig,
browser_sign_in_factory: Callable[[], BrowserSignInService],
*,
copy_sign_in_url: CopySignInUrl,
entrypoint_metadata: EntrypointMetadata | None = None,
success_exit_delay: float = SUCCESS_EXIT_DELAY_SECONDS,
sign_in_url_help_delay: float = SIGN_IN_URL_HELP_DELAY_SECONDS,
) -> None:
super().__init__()
self.provider = provider
self._browser_sign_in_factory = browser_sign_in_factory
self._copy_sign_in_url = copy_sign_in_url
self._entrypoint_metadata = entrypoint_metadata
self._success_exit_delay = success_exit_delay
self._sign_in_url_help_delay = sign_in_url_help_delay
self._attempt_number = 0
self._active_attempt_number: int | None = None
self._worker: Worker[None] | None = None
self._gradient_offset = 0
self._gradient_timer: Timer | None = None
self._sign_in_url_help_timer: Timer | None = None
self._initial_state = BrowserSignInViewState(
step=BrowserSignInStep.OPEN,
message="Getting things ready...",
hint=PENDING_HINT,
variant="pending",
running=False,
)
self._step_widgets: list[BrowserSignInStepWidgets] = []
self._title_widget: NoMarkupStatic
self._url_widget: Static
self._hint_widget: NoMarkupStatic
def compose(self) -> ComposeResult:
@ -141,6 +171,7 @@ class BrowserSignInScreen(OnboardingScreen):
)
with Vertical(id="browser-sign-in-steps"):
yield from self._compose_step_rows()
yield Static("", id="browser-sign-in-url")
yield NoMarkupStatic("", id="browser-sign-in-hint")
def _compose_step_rows(self) -> ComposeResult:
@ -159,6 +190,7 @@ class BrowserSignInScreen(OnboardingScreen):
yield detail
def on_mount(self) -> None:
self._url_widget = self.query_one("#browser-sign-in-url", Static)
self._hint_widget = self.query_one("#browser-sign-in-hint", NoMarkupStatic)
self.state = self._initial_state
self.watch_state(self.state)
@ -187,6 +219,23 @@ class BrowserSignInScreen(OnboardingScreen):
self._cancel_current_attempt()
super().action_cancel()
def action_copy_url(self) -> None:
if self.state.variant == "success" or self.state.sign_in_url is None:
return
if self._copy_sign_in_url(self.state.sign_in_url):
self.app.notify(
COPY_URL_SUCCESS_MESSAGE,
severity="information",
timeout=2,
markup=False,
)
return
self.state = replace(
self.state, show_sign_in_url_help=True, reveal_sign_in_url=True
)
def _start_browser_sign_in(self) -> None:
self._attempt_number += 1
attempt_number = self._attempt_number
@ -194,9 +243,11 @@ class BrowserSignInScreen(OnboardingScreen):
self.state = BrowserSignInViewState(
step=BrowserSignInStep.OPEN,
message="Getting things ready...",
hint=PENDING_HINT,
variant="pending",
running=True,
sign_in_url=None,
show_sign_in_url_help=False,
reveal_sign_in_url=False,
)
self._worker = self.run_worker(
self._authenticate_in_browser(attempt_number),
@ -211,7 +262,7 @@ class BrowserSignInScreen(OnboardingScreen):
try:
browser_sign_in = self._browser_sign_in_factory()
api_key = await browser_sign_in.authenticate(
lambda status: self._on_status(attempt_number, status)
lambda event: self._on_event(attempt_number, event)
)
except asyncio.CancelledError:
return
@ -255,18 +306,21 @@ class BrowserSignInScreen(OnboardingScreen):
api_key,
entrypoint_metadata=self._entrypoint_metadata,
)
self._cancel_sign_in_url_help_timer()
if result != "completed":
self._active_attempt_number = None
self._worker = None
self.app.exit(result)
return
self.state = BrowserSignInViewState(
self.state = replace(
self.state,
step=BrowserSignInStep.FINISH,
message="Sign-in complete",
hint=SUCCESS_HINT,
variant="success",
running=True,
show_sign_in_url_help=False,
reveal_sign_in_url=False,
)
if self._success_exit_delay > 0:
await asyncio.sleep(self._success_exit_delay)
@ -274,32 +328,55 @@ class BrowserSignInScreen(OnboardingScreen):
self._worker = None
self.app.exit(result)
def _on_event(self, attempt_number: int, event: BrowserSignInEvent) -> None:
if isinstance(event, BrowserSignInAttemptStarted):
self._on_attempt_started(attempt_number, event)
return
if isinstance(event, BrowserSignInStatusChanged):
self._on_status(attempt_number, event.status)
def _on_attempt_started(
self, attempt_number: int, event: BrowserSignInAttemptStarted
) -> None:
if not self._is_attempt_active(attempt_number):
return
self._cancel_sign_in_url_help_timer()
self.state = replace(
self.state,
sign_in_url=event.sign_in_url,
show_sign_in_url_help=False,
reveal_sign_in_url=False,
)
self._schedule_sign_in_url_help(attempt_number, event.sign_in_url)
def _on_status(self, attempt_number: int, status: BrowserSignInStatus) -> None:
if not self._is_attempt_active(attempt_number):
return
match status:
case BrowserSignInStatus.OPENING_BROWSER:
state = BrowserSignInViewState(
state = replace(
self.state,
step=BrowserSignInStep.OPEN,
message="Opening your browser...",
hint=PENDING_HINT,
variant="pending",
running=True,
)
case BrowserSignInStatus.WAITING_FOR_BROWSER_SIGN_IN:
state = BrowserSignInViewState(
state = replace(
self.state,
step=BrowserSignInStep.CONFIRM,
message="Waiting for you to finish signing in...",
hint=PENDING_HINT,
variant="pending",
running=True,
)
case BrowserSignInStatus.EXCHANGING | BrowserSignInStatus.COMPLETED:
state = BrowserSignInViewState(
state = replace(
self.state,
step=BrowserSignInStep.FINISH,
message="Finishing setup...",
hint=PENDING_HINT,
variant="pending",
running=True,
)
@ -313,6 +390,7 @@ class BrowserSignInScreen(OnboardingScreen):
return
self._hint_widget.update(state.hint)
self._url_widget.update(self._build_url_text(state))
for index, (widgets, (title, pending_detail, done_detail)) in enumerate(
zip(self._step_widgets, STEP_DESCRIPTIONS, strict=True)
@ -369,7 +447,8 @@ class BrowserSignInScreen(OnboardingScreen):
self.state.variant == "pending"
and self.state.step == BrowserSignInStep.CONFIRM
):
self.watch_state(self.state)
widgets = self._step_widgets[self.state.step]
self._update_active_step_detail(widgets.detail, self.state)
async def _close_browser_sign_in(
self, browser_sign_in: BrowserSignInService | None
@ -387,23 +466,66 @@ class BrowserSignInScreen(OnboardingScreen):
def _show_error(self, message: str) -> None:
self._active_attempt_number = None
self._worker = None
self.state = BrowserSignInViewState(
step=self.state.step,
self._cancel_sign_in_url_help_timer()
self.state = replace(
self.state,
message=message,
hint=ERROR_HINT,
variant="error",
running=False,
show_sign_in_url_help=self.state.sign_in_url is not None,
reveal_sign_in_url=False,
)
def _build_url_text(self, state: BrowserSignInViewState) -> str:
if (
state.variant == "success"
or state.sign_in_url is None
or not state.show_sign_in_url_help
):
return ""
help_text = (
f"{escape(SIGN_IN_URL_HELP_PREFIX)}"
f"[@click='screen.copy_url']{escape(SIGN_IN_URL_COPY_LABEL)}[/]"
f"{escape(SIGN_IN_URL_HELP_SUFFIX)}"
)
if not state.reveal_sign_in_url:
return help_text
return (
f"{help_text} {escape(SIGN_IN_URL_REVEAL_PREFIX)} "
f"{escape(state.sign_in_url)}"
)
def _schedule_sign_in_url_help(self, attempt_number: int, sign_in_url: str) -> None:
if self._sign_in_url_help_delay <= 0:
self._show_sign_in_url_help(attempt_number, sign_in_url)
return
self._sign_in_url_help_timer = self.set_timer(
self._sign_in_url_help_delay,
lambda: self._show_sign_in_url_help(attempt_number, sign_in_url),
)
def _show_sign_in_url_help(self, attempt_number: int, sign_in_url: str) -> None:
self._sign_in_url_help_timer = None
if not self._is_attempt_active(attempt_number):
return
if self.state.sign_in_url != sign_in_url:
return
self.state = replace(self.state, show_sign_in_url_help=True)
def _cancel_sign_in_url_help_timer(self) -> None:
if self._sign_in_url_help_timer is not None:
self._sign_in_url_help_timer.stop()
self._sign_in_url_help_timer = None
def _cancel_current_attempt(self) -> None:
self._active_attempt_number = None
self.state = BrowserSignInViewState(
step=self.state.step,
message=self.state.message,
hint=self.state.hint,
variant=self.state.variant,
running=False,
)
self._cancel_sign_in_url_help_timer()
self.state = replace(self.state, running=False)
if self._worker is not None:
self._worker.cancel()
self._worker = None

View file

@ -1,12 +1,19 @@
from __future__ import annotations
from enum import Enum
from pathlib import Path
from typing import Any, ClassVar
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Center, CenterMiddle, Horizontal, VerticalScroll
from textual.containers import (
Center,
CenterMiddle,
Horizontal,
Vertical,
VerticalScroll,
)
from textual.message import Message
from textual.widgets import Static
@ -18,35 +25,72 @@ class TrustDialogQuitException(Exception):
pass
class TrustDecision(Enum):
"""User's choice from the trust dialog."""
TRUST_REPO = "trust_repo"
TRUST_CWD = "trust_cwd"
DECLINE = "decline"
class TrustFolderDialog(CenterMiddle):
can_focus = True
can_focus_children = True
# Number keys 1-3 cover up to three options; extras no-op.
BINDINGS: ClassVar[list[BindingType]] = [
Binding("left", "move_left", "Left", show=False),
Binding("right", "move_right", "Right", show=False),
Binding("enter", "select", "Select", show=False),
Binding("1", "select_1", "Yes", show=False),
Binding("y", "select_1", "Yes", show=False),
Binding("2", "select_2", "No", show=False),
Binding("n", "select_2", "No", show=False),
Binding("1", "select_index(0)", show=False),
Binding("2", "select_index(1)", show=False),
Binding("3", "select_index(2)", show=False),
]
class Trusted(Message):
pass
class Untrusted(Message):
pass
class Decided(Message):
def __init__(self, decision: TrustDecision) -> None:
super().__init__()
self.decision = decision
def __init__(
self, folder_path: Path, detected_files: list[str], **kwargs: Any
self,
cwd: Path,
repo_root: Path | None,
detected_files: list[str],
repo_detected_files: list[str] | None = None,
offer_repo_trust: bool = False,
repo_explicitly_untrusted: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.folder_path = folder_path
self.cwd = cwd
# Hide the repo line when it would duplicate cwd.
self.repo_root = repo_root if repo_root and repo_root != cwd else None
self.offer_repo_trust = offer_repo_trust and self.repo_root is not None
self.repo_explicitly_untrusted = (
repo_explicitly_untrusted and self.repo_root is not None
)
self.detected_files = detected_files
self.selected_option = 0
self.repo_detected_files = repo_detected_files or []
self._options: list[tuple[TrustDecision, str]] = self._build_options()
# Default to the safest option (rightmost: Decline).
self.selected_option = len(self._options) - 1
self.option_widgets: list[Static] = []
@property
def _title(self) -> str:
if self.offer_repo_trust:
return "Trust folder or repository?"
return "Trust this folder?"
def _build_options(self) -> list[tuple[TrustDecision, str]]:
options: list[tuple[TrustDecision, str]] = []
if self.offer_repo_trust:
options.append((TrustDecision.TRUST_REPO, "Trust full repo"))
options.append((TrustDecision.TRUST_CWD, "Trust folder"))
options.append((TrustDecision.DECLINE, "Don't trust"))
return options
def _compose_scroll_content(self) -> ComposeResult:
why_content = (
"Files here can modify AI behavior. Malicious "
@ -61,26 +105,53 @@ class TrustFolderDialog(CenterMiddle):
)
if self.detected_files:
yield NoMarkupStatic(
"Detected configuration files\n", classes="trust-dialog-section-header"
)
file_list = "\n".join(f"\u2022 {f}" for f in self.detected_files)
with Center(classes="trust-dialog-section-center"):
yield NoMarkupStatic(
file_list,
id="trust-dialog-files",
classes="trust-dialog-section-content",
)
with Vertical(classes="trust-dialog-section-stack"):
yield NoMarkupStatic(
"Detected in current folder:",
classes="trust-dialog-section-title",
)
yield NoMarkupStatic(
"\n".join(f"\u2022 {f}" for f in self.detected_files),
id="trust-dialog-files",
classes="trust-dialog-section-content trust-dialog-file-list",
)
if self.repo_detected_files:
with Center(classes="trust-dialog-section-center"):
with Vertical(classes="trust-dialog-section-stack"):
yield NoMarkupStatic(
"Detected in repository context:",
classes="trust-dialog-section-title",
)
yield NoMarkupStatic(
"\n".join(f"\u2022 {f}" for f in self.repo_detected_files),
id="trust-dialog-files-repo",
classes="trust-dialog-section-content trust-dialog-file-list",
)
def compose(self) -> ComposeResult:
with CenterMiddle(id="trust-dialog-container"):
with CenterMiddle(id="trust-dialog"):
yield NoMarkupStatic("Trust this folder?", id="trust-dialog-title")
yield NoMarkupStatic(self._title, id="trust-dialog-title")
path_classes = "trust-dialog-path"
if self.repo_root is not None:
path_classes += " has-repo-root"
yield NoMarkupStatic(
str(self.folder_path),
id="trust-dialog-path",
classes="trust-dialog-path",
str(self.cwd), id="trust-dialog-path", classes=path_classes
)
if self.repo_explicitly_untrusted:
yield NoMarkupStatic(
f"\u26a0 git repository {self.repo_root} is marked untrusted",
id="trust-dialog-repo-untrusted",
classes="trust-dialog-repo-untrusted",
)
elif self.repo_root is not None:
yield NoMarkupStatic(
f"\u21b3 git repository: {self.repo_root}",
id="trust-dialog-repo-root",
classes="trust-dialog-repo-root",
)
with VerticalScroll(id="trust-dialog-content"):
yield from self._compose_scroll_content()
@ -92,10 +163,9 @@ class TrustFolderDialog(CenterMiddle):
)
with Horizontal(id="trust-options-container"):
options = ["Yes, trust this folder", "No, ignore config files"]
for idx, text in enumerate(options):
for idx, (_decision, label) in enumerate(self._options):
widget = NoMarkupStatic(
f" {idx + 1}. {text}", classes="trust-option"
f" {idx + 1}. {label}", classes="trust-option"
)
self.option_widgets.append(widget)
yield widget
@ -111,25 +181,20 @@ class TrustFolderDialog(CenterMiddle):
)
async def on_mount(self) -> None:
self.selected_option = 1 # Default to "No"
self._update_options()
self.focus()
def _update_options(self) -> None:
options = ["Yes, trust this folder", "No, ignore config files"]
if len(self.option_widgets) != len(options):
if len(self.option_widgets) != len(self._options):
return
for idx, (text, widget) in enumerate(
zip(options, self.option_widgets, strict=True)
for idx, ((_, label), widget) in enumerate(
zip(self._options, self.option_widgets, strict=True)
):
is_selected = idx == self.selected_option
cursor = " " if is_selected else " "
option_text = f"{cursor}{text}"
widget.update(option_text)
widget.update(f"{cursor}{label}")
widget.remove_class("trust-cursor-selected")
widget.remove_class("trust-option-selected")
@ -140,30 +205,25 @@ class TrustFolderDialog(CenterMiddle):
widget.add_class("trust-option-selected")
def action_move_left(self) -> None:
self.selected_option = (self.selected_option - 1) % 2
self.selected_option = (self.selected_option - 1) % len(self._options)
self._update_options()
def action_move_right(self) -> None:
self.selected_option = (self.selected_option + 1) % 2
self.selected_option = (self.selected_option + 1) % len(self._options)
self._update_options()
def action_select(self) -> None:
self._handle_selection(self.selected_option)
def action_select_1(self) -> None:
self.selected_option = 0
self._handle_selection(0)
def action_select_2(self) -> None:
self.selected_option = 1
self._handle_selection(1)
def action_select_index(self, idx: int) -> None:
if not 0 <= idx < len(self._options):
return
self.selected_option = idx
self._handle_selection(idx)
def _handle_selection(self, option: int) -> None:
match option:
case 0:
self.post_message(self.Trusted())
case 1:
self.post_message(self.Untrusted())
decision, _ = self._options[option]
self.post_message(self.Decided(decision))
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)
@ -178,39 +238,69 @@ class TrustFolderApp(App):
]
def __init__(
self, folder_path: Path, detected_files: list[str], **kwargs: Any
self,
cwd: Path,
repo_root: Path | None,
detected_files: list[str],
repo_detected_files: list[str] | None = None,
offer_repo_trust: bool = False,
repo_explicitly_untrusted: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.folder_path = folder_path
self.cwd = cwd
self.repo_root = repo_root
self.offer_repo_trust = offer_repo_trust
self.repo_explicitly_untrusted = repo_explicitly_untrusted
self.detected_files = detected_files
self._result: bool | None = None
self.repo_detected_files = repo_detected_files or []
self._result: TrustDecision | None = None
self._quit_without_saving = False
def on_mount(self) -> None:
self.theme = "ansi-dark"
def compose(self) -> ComposeResult:
yield TrustFolderDialog(self.folder_path, self.detected_files)
yield TrustFolderDialog(
self.cwd,
self.repo_root,
self.detected_files,
repo_detected_files=self.repo_detected_files,
offer_repo_trust=self.offer_repo_trust,
repo_explicitly_untrusted=self.repo_explicitly_untrusted,
)
def action_quit_without_saving(self) -> None:
self._quit_without_saving = True
self.exit()
def on_trust_folder_dialog_trusted(self, _: TrustFolderDialog.Trusted) -> None:
self._result = True
def on_trust_folder_dialog_decided(
self, message: TrustFolderDialog.Decided
) -> None:
self._result = message.decision
self.exit()
def on_trust_folder_dialog_untrusted(self, _: TrustFolderDialog.Untrusted) -> None:
self._result = False
self.exit()
def run_trust_dialog(self) -> bool | None:
def run_trust_dialog(self) -> TrustDecision | None:
self.run()
if self._quit_without_saving:
raise TrustDialogQuitException()
return self._result
def ask_trust_folder(folder_path: Path, detected_files: list[str]) -> bool | None:
app = TrustFolderApp(folder_path, detected_files)
def ask_trust_folder(
cwd: Path,
repo_root: Path | None,
detected_files: list[str],
repo_detected_files: list[str] | None = None,
offer_repo_trust: bool = False,
repo_explicitly_untrusted: bool = False,
) -> TrustDecision | None:
app = TrustFolderApp(
cwd,
repo_root,
detected_files,
repo_detected_files=repo_detected_files,
offer_repo_trust=offer_repo_trust,
repo_explicitly_untrusted=repo_explicitly_untrusted,
)
return app.run_trust_dialog()

View file

@ -3,18 +3,18 @@ Screen {
background: transparent 80%;
}
* {
scrollbar-size: 1 1;
}
#trust-dialog {
max-width: 70;
overflow-y: scroll;
overflow-y: auto;
border: round $border-blurred;
background: transparent;
height:auto;
height: auto;
max-height: 1fr;
padding: 1;
padding-left:5;
padding-right:5;
scrollbar-size: 1 1;
padding: 1 5;
}
#trust-dialog-content {
@ -42,13 +42,32 @@ Screen {
margin-bottom: 1;
}
.trust-dialog-section-header {
#trust-dialog-path.has-repo-root {
margin-bottom: 0;
}
.trust-dialog-repo-root {
width: 100%;
height: auto;
color: $foreground;
color: $text-muted;
text-align: center;
text-wrap: wrap;
padding: 0 2;
text-style: italic;
margin-bottom: 1;
&:ansi {
text-style: italic dim;
}
}
.trust-dialog-repo-untrusted {
width: 100%;
height: auto;
color: $warning;
text-align: center;
text-wrap: wrap;
text-style: italic;
margin-bottom: 1;
}
.trust-dialog-section-center {
@ -57,6 +76,11 @@ Screen {
margin-bottom: 1;
}
.trust-dialog-section-stack {
width: 100%;
height: auto;
}
.trust-dialog-section-content {
width: auto;
max-width: 100%;
@ -66,6 +90,24 @@ Screen {
text-overflow: fold;
}
.trust-dialog-section-title {
width: 100%;
height: auto;
color: $text-muted;
text-style: bold;
text-align: center;
margin-bottom: 0;
&:ansi {
text-style: bold dim;
}
}
.trust-dialog-file-list {
width: 100%;
text-align: center;
}
.trust-dialog-footer-warning {
width: 100%;
height: auto;

View file

@ -1,5 +1,4 @@
# What's new in v2.13.0
# What's new in v2.14.0
- **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.
- **Teleport Vibe Code Web**: `/teleport` now uses the new Vibe Code Web sessions.
- **Image attachments**: Drop an image into the chat input — or `@`-mention it — and send it to vision-capable models.
- **New read and edit tools**: `read` and `edit` replace `read_file` and `search_replace`. Your config has been migrated to these new tool names.