v2.14.0 (#743)
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:
parent
ad0d5c9520
commit
3f8487f761
197 changed files with 10819 additions and 2830 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
117
vibe/cli/textual_ui/widgets/chat_input/paste_path.py
Normal file
117
vibe/cli/textual_ui/widgets/chat_input/paste_path.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue