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

@ -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