Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-by: Thiago Padilha <thiago@coplane.com>
This commit is contained in:
Mathias Gesbert 2025-12-23 19:00:46 +01:00 committed by Mathias Gesbert
parent 2e1e15120d
commit 078693fc64
67 changed files with 3959 additions and 819 deletions

View file

@ -26,6 +26,8 @@ def get_initial_mode(args: argparse.Namespace) -> AgentMode:
return AgentMode.PLAN
if args.auto_approve:
return AgentMode.AUTO_APPROVE
if args.prompt is not None:
return AgentMode.AUTO_APPROVE
return AgentMode.DEFAULT

View file

@ -1,7 +1,11 @@
from __future__ import annotations
import base64
from collections.abc import Callable
import os
import platform
import shutil
import subprocess
import pyperclip
from textual.app import App
@ -20,6 +24,33 @@ def _copy_osc52(text: str) -> None:
tty.flush()
def _copy_x11_clipboard(text: str) -> None:
subprocess.run(
["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True
)
def _copy_wayland_clipboard(text: str) -> None:
subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
def _has_cmd(cmd: str) -> bool:
return shutil.which(cmd) is not None
def _get_copy_fns(app: App) -> list[Callable[[str], None]]:
copy_fns: list[Callable[[str], None]] = [
_copy_osc52,
pyperclip.copy,
app.copy_to_clipboard,
]
if platform.system() == "Linux" and _has_cmd("wl-copy"):
copy_fns = [_copy_wayland_clipboard, *copy_fns]
if platform.system() == "Linux" and _has_cmd("xclip"):
copy_fns = [_copy_x11_clipboard, *copy_fns]
return copy_fns
def _shorten_preview(texts: list[str]) -> str:
dense_text = "".join(texts).replace("\n", "")
if len(dense_text) > _PREVIEW_MAX_LENGTH:
@ -53,18 +84,23 @@ def copy_selection_to_clipboard(app: App) -> None:
combined_text = "\n".join(selected_texts)
for copy_fn in [_copy_osc52, pyperclip.copy, app.copy_to_clipboard]:
success = False
copy_fns = _get_copy_fns(app)
for copy_fn in copy_fns:
try:
copy_fn(combined_text)
except:
pass
else:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
)
break
success = True
if success:
app.notify(
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
)
else:
app.notify(
"Failed to copy - no clipboard method available",

View file

@ -6,6 +6,7 @@ import subprocess
import time
from typing import Any, ClassVar, assert_never
from pydantic import BaseModel
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Horizontal, VerticalScroll
@ -18,6 +19,10 @@ from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.terminal_theme import (
TERMINAL_THEME_NAME,
capture_terminal_theme,
)
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.compact import CompactMessage
@ -29,6 +34,8 @@ from vibe.cli.textual_ui.widgets.messages import (
BashOutputMessage,
ErrorMessage,
InterruptMessage,
ReasoningMessage,
StreamingMessageBase,
UserCommandMessage,
UserMessage,
WarningMessage,
@ -116,13 +123,13 @@ class VibeApp(App):
self._mode_indicator: ModeIndicator | None = None
self._context_progress: ContextProgress | None = None
self._current_bottom_app: BottomApp = BottomApp.Input
self.theme = config.textual_theme
self.history_file = HISTORY_FILE.path
self._tools_collapsed = True
self._todos_collapsed = False
self._current_streaming_message: AssistantMessage | None = None
self._current_streaming_reasoning: ReasoningMessage | None = None
self._version_update_notifier = version_update_notifier
self._update_cache_repository = update_cache_repository
self._is_update_check_enabled = config.enable_update_checks
@ -138,6 +145,7 @@ class VibeApp(App):
self._agent_init_interrupted = False
self._auto_scroll = True
self._last_escape_time: float | None = None
self._terminal_theme = capture_terminal_theme()
def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"):
@ -166,6 +174,15 @@ class VibeApp(App):
yield ContextProgress()
async def on_mount(self) -> None:
if self._terminal_theme:
self.register_theme(self._terminal_theme)
if self.config.textual_theme == TERMINAL_THEME_NAME:
if self._terminal_theme:
self.theme = TERMINAL_THEME_NAME
else:
self.theme = self.config.textual_theme
self.event_handler = EventHandler(
mount_callback=self._mount_and_scroll,
scroll_callback=self._scroll_to_bottom_deferred,
@ -280,7 +297,11 @@ class VibeApp(App):
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
if message.key == "textual_theme":
self.theme = message.value
if message.value == TERMINAL_THEME_NAME:
if self._terminal_theme:
self.theme = TERMINAL_THEME_NAME
else:
self.theme = message.value
async def on_config_app_config_closed(
self, message: ConfigApp.ConfigClosed
@ -539,7 +560,7 @@ class VibeApp(App):
return self._agent_init_task
async def _approval_callback(
self, tool: str, args: dict, tool_call_id: str
self, tool: str, args: BaseModel, tool_call_id: str
) -> tuple[ApprovalResponse, str | None]:
self._pending_approval = asyncio.Future()
await self._switch_to_approval_app(tool, args)
@ -894,13 +915,17 @@ class VibeApp(App):
if self._mode_indicator:
self._mode_indicator.display = False
config_app = ConfigApp(self.config)
config_app = ConfigApp(
self.config, has_terminal_theme=self._terminal_theme is not None
)
await bottom_container.mount(config_app)
self._current_bottom_app = BottomApp.Config
self.call_after_refresh(config_app.focus)
async def _switch_to_approval_app(self, tool_name: str, tool_args: dict) -> None:
async def _switch_to_approval_app(
self, tool_name: str, tool_args: BaseModel
) -> None:
bottom_container = self.query_one("#bottom-app-container")
try:
@ -1133,12 +1158,36 @@ class VibeApp(App):
await self._mount_and_scroll(WarningMessage(warning, show_border=False))
async def _finalize_current_streaming_message(self) -> None:
if self._current_streaming_reasoning is not None:
self._current_streaming_reasoning.stop_spinning()
await self._current_streaming_reasoning.stop_stream()
self._current_streaming_reasoning = None
if self._current_streaming_message is None:
return
await self._current_streaming_message.stop_stream()
self._current_streaming_message = None
async def _handle_streaming_widget[T: StreamingMessageBase](
self,
widget: T,
current_stream: T | None,
other_stream: StreamingMessageBase | None,
messages_area: Widget,
) -> T | None:
if other_stream is not None:
await other_stream.stop_stream()
if current_stream is not None:
if widget._content:
await current_stream.append_content(widget._content)
return None
await messages_area.mount(widget)
await widget.write_initial_content()
return widget
async def _mount_and_scroll(self, widget: Widget) -> None:
messages_area = self.query_one("#messages")
chat = self.query_one("#chat", VerticalScroll)
@ -1147,15 +1196,28 @@ class VibeApp(App):
if was_at_bottom:
self._auto_scroll = True
if isinstance(widget, AssistantMessage):
if self._current_streaming_message is not None:
content = widget._content or ""
if content:
await self._current_streaming_message.append_content(content)
else:
self._current_streaming_message = widget
await messages_area.mount(widget)
await widget.write_initial_content()
if isinstance(widget, ReasoningMessage):
result = await self._handle_streaming_widget(
widget,
self._current_streaming_reasoning,
self._current_streaming_message,
messages_area,
)
if result is not None:
self._current_streaming_reasoning = result
self._current_streaming_message = None
elif isinstance(widget, AssistantMessage):
if self._current_streaming_reasoning is not None:
self._current_streaming_reasoning.stop_spinning()
result = await self._handle_streaming_widget(
widget,
self._current_streaming_message,
self._current_streaming_reasoning,
messages_area,
)
if result is not None:
self._current_streaming_message = result
self._current_streaming_reasoning = None
else:
await self._finalize_current_streaming_message()
await messages_area.mount(widget)
@ -1279,7 +1341,6 @@ def run_textual_ui(
initial_prompt: str | None = None,
loaded_messages: list[LLMMessage] | None = None,
) -> None:
"""Run the Textual UI."""
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
app = VibeApp(

View file

@ -227,6 +227,67 @@ Markdown MarkdownFence {
margin-bottom: 0;
}
.reasoning-message {
margin-top: 1;
width: 100%;
height: auto;
}
.reasoning-message-wrapper {
width: 100%;
height: auto;
}
.reasoning-message-header {
width: 100%;
height: auto;
}
.reasoning-indicator {
width: auto;
height: auto;
color: $text-muted;
margin-right: 1;
&.success {
color: $text-success;
}
}
.reasoning-collapsed-text {
width: auto;
height: auto;
color: $text-muted;
text-style: italic;
}
.reasoning-triangle {
width: auto;
height: auto;
color: $text-muted;
text-style: italic;
margin-left: 1;
}
.reasoning-message-content {
width: 100%;
height: auto;
padding: 0;
padding-left: 2;
margin: 0;
color: $text-muted;
text-style: italic;
}
.reasoning-message-content * {
color: $text-muted;
text-style: italic;
}
.reasoning-message-content > *:last-child {
margin-bottom: 0;
}
.interrupt-message {
margin-top: 0;
margin-bottom: 0;

View file

@ -6,13 +6,14 @@ from typing import TYPE_CHECKING
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.messages import AssistantMessage
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, ReasoningMessage
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.types import (
AssistantEvent,
BaseEvent,
CompactEndEvent,
CompactStartEvent,
ReasoningEvent,
ToolCallEvent,
ToolResultEvent,
)
@ -50,21 +51,18 @@ class EventHandler:
return await self._handle_tool_call(event, loading_widget)
case ToolResultEvent():
sanitized_event = self._sanitize_event(event)
await self._handle_tool_result(sanitized_event)
return None
case ReasoningEvent():
await self._handle_reasoning_message(event)
case AssistantEvent():
await self._handle_assistant_message(event)
return None
case CompactStartEvent():
await self._handle_compact_start()
return None
case CompactEndEvent():
await self._handle_compact_end(event)
return None
case _:
await self._handle_unknown_event(event)
return None
return None
def _sanitize_event(self, event: ToolResultEvent) -> ToolResultEvent:
if isinstance(event, ToolResultEvent):
@ -125,6 +123,12 @@ class EventHandler:
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
await self.mount_callback(AssistantMessage(event.content))
async def _handle_reasoning_message(self, event: ReasoningEvent) -> None:
tools_collapsed = self.get_tools_collapsed()
await self.mount_callback(
ReasoningMessage(event.content, collapsed=tools_collapsed)
)
async def _handle_compact_start(self) -> None:
compact_msg = CompactMessage()
self.current_compact = compact_msg

View file

@ -1,5 +0,0 @@
from __future__ import annotations
from vibe.cli.textual_ui.renderers.tool_renderers import get_renderer
__all__ = ["get_renderer"]

View file

@ -1,216 +0,0 @@
from __future__ import annotations
import difflib
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from vibe.core.tools.ui import ToolResultDisplay
from vibe.cli.textual_ui.widgets.tool_widgets import (
BashApprovalWidget,
BashResultWidget,
GrepApprovalWidget,
GrepResultWidget,
ReadFileApprovalWidget,
ReadFileResultWidget,
SearchReplaceApprovalWidget,
SearchReplaceResultWidget,
TodoApprovalWidget,
TodoResultWidget,
ToolApprovalWidget,
ToolResultWidget,
WriteFileApprovalWidget,
WriteFileResultWidget,
)
class ToolRenderer:
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
return ToolApprovalWidget, tool_args
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[ToolResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"details": self._clean_details(display.details),
"warnings": display.warnings,
}
return ToolResultWidget, data
def _clean_details(self, details: dict) -> dict:
clean = {}
for key, value in details.items():
if value is None or value in ("", []):
continue
value_str = str(value).strip().replace("\n", " ").replace("\r", "")
value_str = " ".join(value_str.split())
if value_str:
clean[key] = value_str
return clean
class BashRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[BashApprovalWidget], dict[str, Any]]:
data = {
"command": tool_args.get("command", ""),
"description": tool_args.get("description", ""),
}
return BashApprovalWidget, data
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[BashResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"details": self._clean_details(display.details),
"warnings": display.warnings,
}
return BashResultWidget, data
class WriteFileRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[WriteFileApprovalWidget], dict[str, Any]]:
data = {
"path": tool_args.get("path", ""),
"content": tool_args.get("content", ""),
"file_extension": tool_args.get("file_extension", "text"),
}
return WriteFileApprovalWidget, data
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[WriteFileResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"path": display.details.get("path", ""),
"bytes_written": display.details.get("bytes_written"),
"content": display.details.get("content", ""),
"file_extension": display.details.get("file_extension", "text"),
}
return WriteFileResultWidget, data
class SearchReplaceRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[SearchReplaceApprovalWidget], dict[str, Any]]:
file_path = tool_args.get("file_path", "")
content = str(tool_args.get("content", ""))
diff_lines = self._parse_search_replace_blocks(content)
data = {"file_path": file_path, "diff_lines": diff_lines}
return SearchReplaceApprovalWidget, data
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[SearchReplaceResultWidget], dict[str, Any]]:
diff_lines = self._parse_search_replace_blocks(
display.details.get("content", "")
)
data = {
"success": display.success,
"message": display.message,
"diff_lines": diff_lines if not collapsed else [],
}
return SearchReplaceResultWidget, data
def _parse_search_replace_blocks(self, content: str) -> list[str]:
if "<<<<<<< SEARCH" not in content:
return [content]
try:
sections = content.split("<<<<<<< SEARCH")
rest = sections[1].split("=======")
search_section = rest[0].strip()
replace_part = rest[1].split(">>>>>>> REPLACE")
replace_section = replace_part[0].strip()
search_lines = search_section.split("\n")
replace_lines = replace_section.split("\n")
diff = difflib.unified_diff(search_lines, replace_lines, lineterm="", n=2)
return list(diff)[2:] # Skip file headers
except (IndexError, AttributeError):
return [content[:500]]
class TodoRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[TodoApprovalWidget], dict[str, Any]]:
data = {"description": tool_args.get("description", "")}
return TodoApprovalWidget, data
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[TodoResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"todos_by_status": display.details.get("todos_by_status", {}),
}
return TodoResultWidget, data
class ReadFileRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[ReadFileApprovalWidget], dict[str, Any]]:
return ReadFileApprovalWidget, tool_args
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[ReadFileResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"path": display.details.get("path", ""),
"warnings": display.warnings,
"content": display.details.get("content", "") if not collapsed else "",
"file_extension": display.details.get("file_extension", "text"),
}
return ReadFileResultWidget, data
class GrepRenderer(ToolRenderer):
def get_approval_widget(
self, tool_args: dict
) -> tuple[type[GrepApprovalWidget], dict[str, Any]]:
return GrepApprovalWidget, tool_args
def get_result_widget(
self, display: ToolResultDisplay, collapsed: bool
) -> tuple[type[GrepResultWidget], dict[str, Any]]:
data = {
"success": display.success,
"message": display.message,
"warnings": display.warnings,
"matches": display.details.get("matches", "") if not collapsed else "",
}
return GrepResultWidget, data
_RENDERER_REGISTRY: dict[str, type[ToolRenderer]] = {
"write_file": WriteFileRenderer,
"search_replace": SearchReplaceRenderer,
"todo": TodoRenderer,
"read_file": ReadFileRenderer,
"bash": BashRenderer,
"grep": GrepRenderer,
}
def get_renderer(tool_name: str) -> ToolRenderer:
renderer_class = _RENDERER_REGISTRY.get(tool_name, ToolRenderer)
return renderer_class()

View file

@ -0,0 +1,266 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, fields
import os
import re
import sys
from typing import Any
from textual.theme import Theme
try:
import select
import termios
_UNIX_AVAILABLE = True
except ImportError:
select = None # type: ignore[assignment]
termios: Any = None
_UNIX_AVAILABLE = False
TERMINAL_THEME_NAME = "terminal"
_LUMINANCE_THRESHOLD = 0.5
_RGB_16BIT_LEN = 4
_RGB_8BIT_LEN = 2
# OSC codes for terminal colors
_OSC_FOREGROUND = "10"
_OSC_BACKGROUND = "11"
# ANSI color indices (0-15) mapped to field names
_ANSI_COLORS = (
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"bright_black",
"bright_red",
"bright_green",
"bright_yellow",
"bright_blue",
"bright_magenta",
"bright_cyan",
"bright_white",
)
# DA1 (Primary Device Attributes) query - used to detect unsupported terminals
# Terminals respond to DA1 even if they don't support OSC color queries
_DA1_QUERY = b"\x1b[c"
_DA1_RESPONSE_PREFIX = b"\x1b[?"
_OSC_RESPONSE_RE = re.compile(
rb"\x1b\](10|11|4;\d+);rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)(?:\x1b\\|\x07)"
)
@dataclass
class TerminalColors:
foreground: str | None = None
background: str | None = None
black: str | None = None
red: str | None = None
green: str | None = None
yellow: str | None = None
blue: str | None = None
magenta: str | None = None
cyan: str | None = None
white: str | None = None
bright_black: str | None = None
bright_red: str | None = None
bright_green: str | None = None
bright_yellow: str | None = None
bright_blue: str | None = None
bright_magenta: str | None = None
bright_cyan: str | None = None
bright_white: str | None = None
def is_complete(self) -> bool:
return all(getattr(self, f.name) is not None for f in fields(self))
def _build_osc_query(code: str) -> bytes:
"""Build an OSC query: ESC ] <code> ; ? ST"""
return f"\x1b]{code};?\x1b\\".encode()
def _build_color_queries() -> tuple[bytes, dict[bytes, str]]:
"""Build all OSC color queries and the mapping from OSC codes to field names."""
queries = bytearray()
osc_to_field: dict[bytes, str] = {}
# Foreground and background
queries.extend(_build_osc_query(_OSC_FOREGROUND))
queries.extend(_build_osc_query(_OSC_BACKGROUND))
osc_to_field[_OSC_FOREGROUND.encode()] = "foreground"
osc_to_field[_OSC_BACKGROUND.encode()] = "background"
# ANSI colors 0-15
for i, name in enumerate(_ANSI_COLORS):
code = f"4;{i}"
queries.extend(_build_osc_query(code))
osc_to_field[code.encode()] = name
return bytes(queries), osc_to_field
_COLOR_QUERIES, _OSC_TO_FIELD = _build_color_queries()
@contextmanager
def _raw_mode(fd: int) -> Iterator[None]:
"""Context manager to temporarily set terminal to raw mode."""
assert termios is not None # Only called on Unix so typing doesn't freak out
try:
old_settings = termios.tcgetattr(fd)
except termios.error:
yield
return
try:
new_settings = termios.tcgetattr(fd)
new_settings[3] &= ~(termios.ECHO | termios.ICANON)
termios.tcsetattr(fd, termios.TCSADRAIN, new_settings)
yield
finally:
try:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
except termios.error:
pass
def _parse_rgb(r_hex: bytes, g_hex: bytes, b_hex: bytes) -> str | None:
"""Parse RGB hex values to a #rrggbb string.
Terminals return either 16-bit (4 hex chars) or 8-bit (2 hex chars) per channel.
"""
try:
if len(r_hex) == _RGB_16BIT_LEN:
r, g, b = int(r_hex[:2], 16), int(g_hex[:2], 16), int(b_hex[:2], 16)
elif len(r_hex) == _RGB_8BIT_LEN:
r, g, b = int(r_hex, 16), int(g_hex, 16), int(b_hex, 16)
else:
return None
return f"#{r:02x}{g:02x}{b:02x}"
except ValueError:
return None
def _read_responses(fd: int, timeout: float = 1.0) -> bytes:
"""Read terminal responses until DA1 response or timeout.
Uses the DA1 trick: we send color queries followed by DA1. Since terminals
respond in order, receiving the DA1 response means all color responses
(if any) have been received.
"""
assert select is not None # Only called on Unix so typing doesn't freak out
response = bytearray()
while True:
ready, _, _ = select.select([fd], [], [], timeout)
if not ready:
break
chunk = os.read(fd, 4096)
if not chunk:
break
response.extend(chunk)
# DA1 response received - we have all the color responses
if _DA1_RESPONSE_PREFIX in response:
break
return bytes(response)
def _parse_osc_responses(response: bytes) -> TerminalColors:
colors = TerminalColors()
for match in _OSC_RESPONSE_RE.finditer(response):
osc_code, r_hex, g_hex, b_hex = match.groups()
field = _OSC_TO_FIELD.get(osc_code)
if field and (color := _parse_rgb(r_hex, g_hex, b_hex)):
setattr(colors, field, color)
return colors
def _query_terminal_colors() -> TerminalColors:
if not _UNIX_AVAILABLE:
return TerminalColors()
if not sys.stdin.isatty() or not sys.stdout.isatty():
return TerminalColors()
fd = sys.stdin.fileno()
try:
with _raw_mode(fd):
os.write(sys.stdout.fileno(), _COLOR_QUERIES + _DA1_QUERY)
response = _read_responses(fd)
return _parse_osc_responses(response)
except OSError:
return TerminalColors()
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
h = hex_color.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _rgb_to_hex(r: int, g: int, b: int) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
def _adjust_brightness(hex_color: str, factor: float) -> str:
r, g, b = _hex_to_rgb(hex_color)
return _rgb_to_hex(
min(255, max(0, int(r * factor))),
min(255, max(0, int(g * factor))),
min(255, max(0, int(b * factor))),
)
def _blend(c1: str, c2: str, ratio: float = 0.5) -> str:
r1, g1, b1 = _hex_to_rgb(c1)
r2, g2, b2 = _hex_to_rgb(c2)
return _rgb_to_hex(
int(r1 * (1 - ratio) + r2 * ratio),
int(g1 * (1 - ratio) + g2 * ratio),
int(b1 * (1 - ratio) + b2 * ratio),
)
def _luminance(hex_color: str) -> float:
"""Calculate perceived luminance (0-1) using ITU-R BT.601 coefficients."""
r, g, b = _hex_to_rgb(hex_color)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
def capture_terminal_theme() -> Theme | None:
colors = _query_terminal_colors()
if not colors.background or not colors.foreground:
return None
is_dark = _luminance(colors.background) < _LUMINANCE_THRESHOLD
fg = colors.foreground
bg = colors.background
surface = _adjust_brightness(bg, 1.15 if is_dark else 0.95)
panel = _blend(bg, surface)
return Theme(
name=TERMINAL_THEME_NAME,
primary=colors.blue or fg,
secondary=colors.cyan or fg,
warning=colors.yellow or fg,
error=colors.red or fg,
success=colors.green or fg,
accent=colors.magenta or fg,
foreground=fg,
background=bg,
surface=surface,
panel=panel,
dark=is_dark,
)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
from typing import ClassVar
from pydantic import BaseModel
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
@ -9,7 +10,7 @@ from textual.containers import Container, Vertical, VerticalScroll
from textual.message import Message
from textual.widgets import Static
from vibe.cli.textual_ui.renderers import get_renderer
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
from vibe.core.config import VibeConfig
@ -29,14 +30,14 @@ class ApprovalApp(Container):
]
class ApprovalGranted(Message):
def __init__(self, tool_name: str, tool_args: dict) -> None:
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
class ApprovalGrantedAlwaysTool(Message):
def __init__(
self, tool_name: str, tool_args: dict, save_permanently: bool
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
) -> None:
super().__init__()
self.tool_name = tool_name
@ -44,13 +45,13 @@ class ApprovalApp(Container):
self.save_permanently = save_permanently
class ApprovalRejected(Message):
def __init__(self, tool_name: str, tool_args: dict) -> None:
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
def __init__(
self, tool_name: str, tool_args: dict, workdir: str, config: VibeConfig
self, tool_name: str, tool_args: BaseModel, workdir: str, config: VibeConfig
) -> None:
super().__init__(id="approval-app")
self.tool_name = tool_name
@ -100,11 +101,8 @@ class ApprovalApp(Container):
if not self.tool_info_container:
return
renderer = get_renderer(self.tool_name)
widget_class, data = renderer.get_approval_widget(self.tool_args)
approval_widget = get_approval_widget(self.tool_name, self.tool_args)
await self.tool_info_container.remove_children()
approval_widget = widget_class(data)
await self.tool_info_container.mount(approval_widget)
def _update_options(self) -> None:

View file

@ -158,7 +158,11 @@ class ChatInputContainer(Vertical):
def set_safety(self, safety: ModeSafety) -> None:
self._safety = safety
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
try:
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
except Exception:
return
for border_class in SAFETY_BORDER_CLASSES.values():
input_box.remove_class(border_class)

View file

@ -10,10 +10,14 @@ from textual.message import Message
from textual.theme import BUILTIN_THEMES
from textual.widgets import Static
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
if TYPE_CHECKING:
from vibe.core.config import VibeConfig
THEMES = sorted(k for k in BUILTIN_THEMES if k != "textual-ansi")
_ALL_THEMES = [TERMINAL_THEME_NAME] + sorted(
k for k in BUILTIN_THEMES if k != "textual-ansi"
)
class SettingDefinition(TypedDict):
@ -46,12 +50,18 @@ class ConfigApp(Container):
super().__init__()
self.changes = changes
def __init__(self, config: VibeConfig) -> None:
def __init__(self, config: VibeConfig, *, has_terminal_theme: bool = False) -> None:
super().__init__(id="config-app")
self.config = config
self.selected_index = 0
self.changes: dict[str, str] = {}
themes = (
_ALL_THEMES
if has_terminal_theme
else [t for t in _ALL_THEMES if t != TERMINAL_THEME_NAME]
)
self.settings: list[SettingDefinition] = [
{
"key": "active_model",
@ -64,7 +74,7 @@ class ConfigApp(Container):
"key": "textual_theme",
"label": "Theme",
"type": "cycle",
"options": THEMES,
"options": themes,
"value": self.config.textual_theme,
},
]

View file

@ -78,7 +78,7 @@ class LoadingWidget(Static):
return None
def _get_default_status(self) -> str:
return self._get_easter_egg() or "Thinking"
return self._get_easter_egg() or "Generating"
def _apply_easter_egg(self, status: str) -> str:
return self._get_easter_egg() or status

View file

@ -7,6 +7,8 @@ from textual.containers import Horizontal, Vertical
from textual.widgets import Markdown, Static
from textual.widgets._markdown import MarkdownStream
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
class NonSelectableStatic(Static):
@property
@ -57,25 +59,18 @@ class UserMessage(Static):
self.remove_class("pending")
class AssistantMessage(Static):
class StreamingMessageBase(Static):
def __init__(self, content: str) -> None:
super().__init__()
self.add_class("assistant-message")
self._content = content
self._markdown: Markdown | None = None
self._stream: MarkdownStream | None = None
def compose(self) -> ComposeResult:
with Horizontal(classes="assistant-message-container"):
yield NonSelectableStatic("", classes="assistant-message-dot")
with Vertical(classes="assistant-message-content"):
markdown = Markdown("")
self._markdown = markdown
yield markdown
def _get_markdown(self) -> Markdown:
if self._markdown is None:
self._markdown = self.query_one(Markdown)
raise RuntimeError(
"Markdown widget not initialized. compose() must be called first."
)
return self._markdown
def _ensure_stream(self) -> MarkdownStream:
@ -88,11 +83,12 @@ class AssistantMessage(Static):
return
self._content += content
stream = self._ensure_stream()
await stream.write(content)
if self._should_write_content():
stream = self._ensure_stream()
await stream.write(content)
async def write_initial_content(self) -> None:
if self._content:
if self._content and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._content)
@ -103,6 +99,86 @@ class AssistantMessage(Static):
await self._stream.stop()
self._stream = None
def _should_write_content(self) -> bool:
return True
class AssistantMessage(StreamingMessageBase):
def __init__(self, content: str) -> None:
super().__init__(content)
self.add_class("assistant-message")
def compose(self) -> ComposeResult:
with Horizontal(classes="assistant-message-container"):
yield NonSelectableStatic("", classes="assistant-message-dot")
with Vertical(classes="assistant-message-content"):
markdown = Markdown("")
self._markdown = markdown
yield markdown
class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
SPINNER_TYPE = SpinnerType.LINE
SPINNING_TEXT = "Thinking"
COMPLETED_TEXT = "Thought"
def __init__(self, content: str, collapsed: bool = True) -> None:
super().__init__(content)
self.add_class("reasoning-message")
self.collapsed = collapsed
self._indicator_widget: Static | None = None
self._triangle_widget: Static | None = None
self.init_spinner()
def compose(self) -> ComposeResult:
with Vertical(classes="reasoning-message-wrapper"):
with Horizontal(classes="reasoning-message-header"):
self._indicator_widget = NonSelectableStatic(
self._spinner.current_frame(), classes="reasoning-indicator"
)
yield self._indicator_widget
self._status_text_widget = Static(
self.SPINNING_TEXT, markup=False, classes="reasoning-collapsed-text"
)
yield self._status_text_widget
self._triangle_widget = NonSelectableStatic(
"" if self.collapsed else "", classes="reasoning-triangle"
)
yield self._triangle_widget
markdown = Markdown("", classes="reasoning-message-content")
markdown.display = not self.collapsed
self._markdown = markdown
yield markdown
def on_mount(self) -> None:
self.start_spinner_timer()
async def on_click(self) -> None:
await self._toggle_collapsed()
async def _toggle_collapsed(self) -> None:
await self.set_collapsed(not self.collapsed)
def _should_write_content(self) -> bool:
return not self.collapsed
async def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed == collapsed:
return
self.collapsed = collapsed
if self._triangle_widget:
self._triangle_widget.update("" if collapsed else "")
if self._markdown:
self._markdown.display = not collapsed
if not collapsed and self._content:
if self._stream is not None:
await self._stream.stop()
self._stream = None
await self._markdown.update("")
stream = self._ensure_stream()
await stream.write(self._content)
class UserCommandMessage(Static):
def __init__(self, content: str) -> None:

View file

@ -1,8 +1,21 @@
from __future__ import annotations
from abc import ABC
from collections.abc import Callable
from enum import Enum
from typing import ClassVar
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
from textual.timer import Timer
if TYPE_CHECKING:
from textual.widgets import Static
@runtime_checkable
class HasSetInterval(Protocol):
def set_interval(
self, interval: float, callback: Callable[[], None], *, name: str | None = None
) -> Timer: ...
class Spinner(ABC):
@ -85,3 +98,48 @@ _SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
spinner_class = _SPINNER_CLASSES.get(spinner_type, BrailleSpinner)
return spinner_class()
class SpinnerMixin:
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
SPINNING_TEXT: ClassVar[str] = ""
COMPLETED_TEXT: ClassVar[str] = ""
_spinner: Spinner
_spinner_timer: Any
_is_spinning: bool
_indicator_widget: Static | None
_status_text_widget: Static | None
def init_spinner(self) -> None:
self._spinner = create_spinner(self.SPINNER_TYPE)
self._spinner_timer = None
self._is_spinning = True
self._status_text_widget = None
def start_spinner_timer(self) -> None:
if not isinstance(self, HasSetInterval):
raise TypeError(
"SpinnerMixin requires a class that implements HasSetInterval protocol"
)
self._spinner_timer = self.set_interval(0.1, self._update_spinner_frame)
def _update_spinner_frame(self) -> None:
if not self._is_spinning or not self._indicator_widget:
return
self._indicator_widget.update(self._spinner.next_frame())
def stop_spinning(self, success: bool = True) -> None:
self._is_spinning = False
if self._spinner_timer:
self._spinner_timer.stop()
self._spinner_timer = None
if self._indicator_widget:
if success:
self._indicator_widget.update("")
self._indicator_widget.add_class("success")
else:
self._indicator_widget.update("")
self._indicator_widget.add_class("error")
if self._status_text_widget and self.COMPLETED_TEXT:
self._status_text_widget.update(self.COMPLETED_TEXT)

View file

@ -7,20 +7,17 @@ from textual.containers import Horizontal
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic
from vibe.cli.textual_ui.widgets.spinner import Spinner, SpinnerType, create_spinner
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
class StatusMessage(Static):
class StatusMessage(SpinnerMixin, Static):
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
self._spinner: Spinner = create_spinner(self.SPINNER_TYPE)
self._spinner_timer = None
self._is_spinning = True
self.success = True
self._initial_text = initial_text
self._indicator_widget: Static | None = None
self._text_widget: Static | None = None
self.init_spinner()
super().__init__(**kwargs)
def compose(self) -> ComposeResult:
@ -38,9 +35,9 @@ class StatusMessage(Static):
def on_mount(self) -> None:
self.update_display()
self._spinner_timer = self.set_interval(0.1, self._update_spinner)
self.start_spinner_timer()
def _update_spinner(self) -> None:
def _update_spinner_frame(self) -> None:
if not self._is_spinning:
return
self.update_display()
@ -72,4 +69,7 @@ class StatusMessage(Static):
def stop_spinning(self, success: bool = True) -> None:
self._is_spinning = False
self.success = success
if self._spinner_timer:
self._spinner_timer.stop()
self._spinner_timer = None
self.update_display()

View file

@ -1,221 +1,287 @@
from __future__ import annotations
import difflib
from pathlib import Path
from pydantic import BaseModel
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.builtins.bash import BashArgs, BashResult
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.todo import TodoArgs, TodoResult
from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
class ToolApprovalWidget(Vertical):
def __init__(self, data: dict) -> None:
def _truncate_lines(content: str, max_lines: int) -> str:
"""Truncate content to max_lines, adding indicator if truncated."""
lines = content.split("\n")
if len(lines) <= max_lines:
return content
remaining = len(lines) - max_lines
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().split("\n")
replace_lines = replace_text.strip().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("+++"):
return Static(line, markup=False, classes="diff-header")
elif line.startswith("-"):
return Static(line, markup=False, classes="diff-removed")
elif line.startswith("+"):
return Static(line, markup=False, classes="diff-added")
elif line.startswith("@@"):
return Static(line, markup=False, classes="diff-range")
else:
return Static(line, markup=False, classes="diff-context")
class ToolApprovalWidget[TArgs: BaseModel](Vertical):
"""Base class for approval widgets with typed args."""
def __init__(self, args: TArgs) -> None:
super().__init__()
self.data = data
self.args = args
self.add_class("tool-approval-widget")
def compose(self) -> ComposeResult:
MAX_APPROVAL_MSG_SIZE = 150
for key, value in self.data.items():
MAX_MSG_SIZE = 150
for field_name in type(self.args).model_fields:
value = getattr(self.args, field_name)
if value is None or value in ("", []):
continue
value_str = str(value)
if len(value_str) > MAX_APPROVAL_MSG_SIZE:
hidden = len(value_str) - MAX_APPROVAL_MSG_SIZE
value_str = (
value_str[:MAX_APPROVAL_MSG_SIZE] + f"… ({hidden} more characters)"
)
if len(value_str) > MAX_MSG_SIZE:
hidden = len(value_str) - MAX_MSG_SIZE
value_str = value_str[:MAX_MSG_SIZE] + f"… ({hidden} more characters)"
yield Static(
f"{key}: {value_str}", markup=False, classes="approval-description"
f"{field_name}: {value_str}",
markup=False,
classes="approval-description",
)
class ToolResultWidget(Static):
class ToolResultWidget[TResult: BaseModel](Static):
"""Base class for result widgets with typed result."""
SHORTCUT = DEFAULT_TOOL_SHORTCUT
def __init__(self, data: dict, collapsed: bool = True) -> None:
def __init__(
self,
result: TResult | None,
success: bool,
message: str,
collapsed: bool = True,
warnings: list[str] | None = None,
) -> None:
super().__init__()
self.data = data
self.result = result
self.success = success
self.message = message
self.collapsed = collapsed
self.warnings = warnings or []
self.add_class("tool-result-widget")
def _hint(self) -> str:
action = "expand" if self.collapsed else "collapse"
return f"({self.SHORTCUT} to {action})"
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
def _header(self) -> ComposeResult:
"""Yield the standard header. Subclasses can call this then add content."""
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
yield Static(f"{self.message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
yield Static(self.message, markup=False)
if not self.collapsed and (details := self.data.get("details")):
for key, value in details.items():
if value:
def compose(self) -> ComposeResult:
"""Default: show message and optionally result fields."""
yield from self._header()
if not self.collapsed and self.result:
for field_name in type(self.result).model_fields:
value = getattr(self.result, field_name)
if value is not None and value not in ("", []):
yield Static(
f"{key}: {value}", markup=False, classes="tool-result-detail"
f"{field_name}: {value}",
markup=False,
classes="tool-result-detail",
)
class BashApprovalWidget(ToolApprovalWidget):
class BashApprovalWidget(ToolApprovalWidget[BashArgs]):
def compose(self) -> ComposeResult:
command = self.data.get("command", "")
description = self.data.get("description", "")
yield Markdown(f"```bash\n{self.args.command}\n```")
if description:
yield Static(description, markup=False, classes="approval-description")
class BashResultWidget(ToolResultWidget[BashResult]):
def compose(self) -> ComposeResult:
yield from self._header()
if self.collapsed or not self.result:
return
yield Static(
f"returncode: {self.result.returncode}",
markup=False,
classes="tool-result-detail",
)
if self.result.stdout:
sep = "\n" if "\n" in self.result.stdout else " "
yield Static(
f"stdout:{sep}{self.result.stdout}",
markup=False,
classes="tool-result-detail",
)
if self.result.stderr:
sep = "\n" if "\n" in self.result.stderr else " "
yield Static(
f"stderr:{sep}{self.result.stderr}",
markup=False,
classes="tool-result-detail",
)
class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]):
def compose(self) -> ComposeResult:
path = Path(self.args.path)
file_extension = path.suffix.lstrip(".") or "text"
yield Static(
f"File: {self.args.path}", markup=False, classes="approval-description"
)
yield Static("")
yield Markdown(f"```{file_extension}\n{self.args.content}\n```")
class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
def compose(self) -> ComposeResult:
yield from self._header()
if self.collapsed or not self.result:
return
yield Static(
f"Path: {self.result.path}", markup=False, classes="tool-result-detail"
)
yield Static(
f"Bytes: {self.result.bytes_written}",
markup=False,
classes="tool-result-detail",
)
if self.result.content:
yield Static("")
yield Markdown(f"```bash\n{command}\n```")
ext = Path(self.result.path).suffix.lstrip(".") or "text"
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
class BashResultWidget(ToolResultWidget):
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
if not self.collapsed and (details := self.data.get("details")):
for key, value in details.items():
if value:
yield Static(
f"{key}: {value}", markup=False, classes="tool-result-detail"
)
class WriteFileApprovalWidget(ToolApprovalWidget):
def compose(self) -> ComposeResult:
path = self.data.get("path", "")
content = self.data.get("content", "")
file_extension = self.data.get("file_extension", "text")
yield Static(f"File: {path}", markup=False, classes="approval-description")
yield Static(
f"File: {self.args.file_path}", markup=False, classes="approval-description"
)
yield Static("")
yield Markdown(f"```{file_extension}\n{content}\n```")
diff_lines = parse_search_replace_to_diff(self.args.content)
for line in diff_lines:
yield render_diff_line(line)
class WriteFileResultWidget(ToolResultWidget):
class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]):
def compose(self) -> ComposeResult:
MAX_LINES = 10
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
if not self.collapsed:
if path := self.data.get("path"):
yield Static(
f"Path: {path}", markup=False, classes="tool-result-detail"
)
if bytes_written := self.data.get("bytes_written"):
yield Static(
f"Bytes: {bytes_written}",
markup=False,
classes="tool-result-detail",
)
if content := self.data.get("content"):
yield Static("")
file_extension = self.data.get("file_extension", "text")
lines = content.split("\n")
total_lines = len(lines)
if total_lines > MAX_LINES:
shown_lines = lines[:MAX_LINES]
remaining = total_lines - MAX_LINES
truncated_content = "\n".join(
shown_lines + [f"… ({remaining} more lines)"]
)
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
else:
yield Markdown(f"```{file_extension}\n{content}\n```")
class SearchReplaceApprovalWidget(ToolApprovalWidget):
def compose(self) -> ComposeResult:
file_path = self.data.get("file_path", "")
diff_lines = self.data.get("diff_lines", [])
yield Static(f"File: {file_path}", markup=False, classes="approval-description")
yield Static("")
if diff_lines:
for line in diff_lines:
if line.startswith("---") or line.startswith("+++"):
yield Static(line, markup=False, classes="diff-header")
elif line.startswith("-"):
yield Static(line, markup=False, classes="diff-removed")
elif line.startswith("+"):
yield Static(line, markup=False, classes="diff-added")
elif line.startswith("@@"):
yield Static(line, markup=False, classes="diff-range")
else:
yield Static(line, markup=False, classes="diff-context")
class SearchReplaceResultWidget(ToolResultWidget):
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
if not self.collapsed and (diff_lines := self.data.get("diff_lines")):
yield from self._header()
if self.collapsed or not self.result:
return
yield Static(
f"File: {self.result.file}", markup=False, classes="tool-result-detail"
)
yield Static(
f"Blocks applied: {self.result.blocks_applied}",
markup=False,
classes="tool-result-detail",
)
yield Static(
f"Lines changed: {self.result.lines_changed}",
markup=False,
classes="tool-result-detail",
)
for warning in self.result.warnings:
yield Static(f"{warning}", markup=False, classes="tool-result-warning")
if self.result.content:
yield Static("")
for line in diff_lines:
if line.startswith("---") or line.startswith("+++"):
yield Static(line, markup=False, classes="diff-header")
elif line.startswith("-"):
yield Static(line, markup=False, classes="diff-removed")
elif line.startswith("+"):
yield Static(line, markup=False, classes="diff-added")
elif line.startswith("@@"):
yield Static(line, markup=False, classes="diff-range")
else:
yield Static(line, markup=False, classes="diff-context")
for line in parse_search_replace_to_diff(self.result.content):
yield render_diff_line(line)
class TodoApprovalWidget(ToolApprovalWidget):
class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
def compose(self) -> ComposeResult:
description = self.data.get("description", "")
if description:
yield Static(description, markup=False, classes="approval-description")
yield Static(
f"Action: {self.args.action}", markup=False, classes="approval-description"
)
if self.args.todos:
yield Static(
f"Todos: {len(self.args.todos)} items",
markup=False,
classes="approval-description",
)
class TodoResultWidget(ToolResultWidget):
class TodoResultWidget(ToolResultWidget[TodoResult]):
SHORTCUT = TOOL_SHORTCUTS["todo"]
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
yield Static(f"{self.message} {self._hint()}", markup=False)
else:
yield Static(f"{message} {self._hint()}", markup=False)
yield Static(f"{self.message} {self._hint()}", markup=False)
yield Static("")
by_status = self.data.get("todos_by_status", {})
if not any(by_status.values()):
if not self.result or not self.result.todos:
yield Static("No todos", markup=False, classes="todo-empty")
return
# Group todos by status
by_status: dict[str, list] = {
"in_progress": [],
"pending": [],
"completed": [],
"cancelled": [],
}
for todo in self.result.todos:
status = (
todo.status.value
if hasattr(todo.status, "value")
else str(todo.status)
)
if status in by_status:
by_status[status].append(todo)
for status in ["in_progress", "pending", "completed", "cancelled"]:
todos = by_status.get(status, [])
for todo in todos:
content = todo.get("content", "")
for todo in by_status[status]:
icon = self._get_status_icon(status)
yield Static(
f"{icon} {content}", markup=False, classes=f"todo-{status}"
f"{icon} {todo.content}", markup=False, classes=f"todo-{status}"
)
def _get_status_icon(self, status: str) -> str:
@ -223,95 +289,103 @@ class TodoResultWidget(ToolResultWidget):
return icons.get(status, "")
class ReadFileApprovalWidget(ToolApprovalWidget):
class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
def compose(self) -> ComposeResult:
for key, value in self.data.items():
if value:
yield Static(
f"{key}: {value}", markup=False, classes="approval-description"
)
yield Static(
f"path: {self.args.path}", markup=False, classes="approval-description"
)
if self.args.offset > 0:
yield Static(
f"offset: {self.args.offset}",
markup=False,
classes="approval-description",
)
if self.args.limit is not None:
yield Static(
f"limit: {self.args.limit}",
markup=False,
classes="approval-description",
)
class ReadFileResultWidget(ToolResultWidget):
class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
def compose(self) -> ComposeResult:
MAX_LINES = 10
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
yield from self._header()
if self.collapsed:
return
if path := self.data.get("path"):
yield Static(f"Path: {path}", markup=False, classes="tool-result-detail")
if warnings := self.data.get("warnings"):
for warning in warnings:
yield Static(
f"{warning}", markup=False, classes="tool-result-warning"
)
if content := self.data.get("content"):
if self.result:
yield Static(
f"Path: {self.result.path}", markup=False, classes="tool-result-detail"
)
for warning in self.warnings:
yield Static(f"{warning}", markup=False, classes="tool-result-warning")
if self.result and self.result.content:
yield Static("")
file_extension = self.data.get("file_extension", "text")
lines = content.split("\n")
total_lines = len(lines)
if total_lines > MAX_LINES:
shown_lines = lines[:MAX_LINES]
remaining = total_lines - MAX_LINES
truncated_content = "\n".join(
shown_lines + [f"… ({remaining} more lines)"]
)
yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
else:
yield Markdown(f"```{file_extension}\n{content}\n```")
ext = Path(self.result.path).suffix.lstrip(".") or "text"
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
class GrepApprovalWidget(ToolApprovalWidget):
class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
def compose(self) -> ComposeResult:
for key, value in self.data.items():
if value:
yield Static(
f"{key}: {value!s}", classes="approval-description", markup=False
)
yield Static(
f"pattern: {self.args.pattern}",
markup=False,
classes="approval-description",
)
yield Static(
f"path: {self.args.path}", markup=False, classes="approval-description"
)
if self.args.max_matches is not None:
yield Static(
f"max_matches: {self.args.max_matches}",
markup=False,
classes="approval-description",
)
class GrepResultWidget(ToolResultWidget):
class GrepResultWidget(ToolResultWidget[GrepResult]):
def compose(self) -> ComposeResult:
MAX_LINES = 30
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
yield from self._header()
if self.collapsed:
return
if warnings := self.data.get("warnings"):
for warning in warnings:
yield Static(
f"{warning}", classes="tool-result-warning", markup=False
)
if matches := self.data.get("matches"):
for warning in self.warnings:
yield Static(f"{warning}", markup=False, classes="tool-result-warning")
if self.result and self.result.matches:
yield Static("")
yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```")
lines = matches.split("\n")
total_lines = len(lines)
if total_lines > MAX_LINES:
shown_lines = lines[:MAX_LINES]
remaining = total_lines - MAX_LINES
truncated_content = "\n".join(
shown_lines + [f"… ({remaining} more lines)"]
)
yield Markdown(f"```\n{truncated_content}\n```")
else:
yield Markdown(f"```\n{matches}\n```")
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
"bash": BashApprovalWidget,
"read_file": ReadFileApprovalWidget,
"write_file": WriteFileApprovalWidget,
"search_replace": SearchReplaceApprovalWidget,
"grep": GrepApprovalWidget,
"todo": TodoApprovalWidget,
}
RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = {
"bash": BashResultWidget,
"read_file": ReadFileResultWidget,
"write_file": WriteFileResultWidget,
"search_replace": SearchReplaceResultWidget,
"grep": GrepResultWidget,
"todo": TodoResultWidget,
}
def get_approval_widget(tool_name: str, args: BaseModel) -> ToolApprovalWidget:
widget_class = APPROVAL_WIDGETS.get(tool_name, ToolApprovalWidget)
return widget_class(args)
def get_result_widget(
tool_name: str,
result: BaseModel | None,
success: bool,
message: str,
collapsed: bool = True,
warnings: list[str] | None = None,
) -> ToolResultWidget:
widget_class = RESULT_WIDGETS.get(tool_name, ToolResultWidget)
return widget_class(result, success, message, collapsed, warnings)

View file

@ -4,9 +4,9 @@ from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Static
from vibe.cli.textual_ui.renderers import get_renderer
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
@ -129,11 +129,16 @@ class ToolResultMessage(Static):
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_result_display(self._event)
renderer = get_renderer(self._event.tool_name)
widget_class, data = renderer.get_result_widget(display, self.collapsed)
await self._content_container.mount(
widget_class(data, collapsed=self.collapsed)
widget = get_result_widget(
self._event.tool_name,
self._event.result,
success=display.success,
message=display.message,
collapsed=self.collapsed,
warnings=display.warnings,
)
await self._content_container.mount(widget)
async def _render_simple(self) -> None:
if self._content_container is None: