v1.3.0
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:
parent
2e1e15120d
commit
078693fc64
67 changed files with 3959 additions and 819 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.renderers.tool_renderers import get_renderer
|
||||
|
||||
__all__ = ["get_renderer"]
|
||||
|
|
@ -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()
|
||||
266
vibe/cli/textual_ui/terminal_theme.py
Normal file
266
vibe/cli/textual_ui/terminal_theme.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue