v1.3.4
Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai> Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-Authored-By: Luis Cardoso <luis.cardoso@mistral.ai>
This commit is contained in:
parent
4d449be276
commit
add3ab5245
36 changed files with 399 additions and 259 deletions
|
|
@ -8,7 +8,7 @@ from rich import print as rprint
|
|||
|
||||
from vibe import __version__
|
||||
from vibe.core.paths.config_paths import unlock_config_paths
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager
|
||||
from vibe.setup.trusted_folders.trust_folder_dialog import (
|
||||
TrustDialogQuitException,
|
||||
ask_trust_folder,
|
||||
|
|
@ -105,7 +105,7 @@ def parse_arguments() -> argparse.Namespace:
|
|||
|
||||
def check_and_resolve_trusted_folder() -> None:
|
||||
cwd = Path.cwd()
|
||||
if not (cwd / ".vibe").exists() or cwd.resolve() == Path.home().resolve():
|
||||
if not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve():
|
||||
return
|
||||
|
||||
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from vibe.cli.textual_ui.widgets.messages import (
|
|||
WarningMessage,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.mode_indicator import ModeIndicator
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.cli.textual_ui.widgets.welcome import WelcomeBanner
|
||||
|
|
@ -174,7 +175,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
yield PathDisplay(
|
||||
self.config.displayed_workdir or self.config.effective_workdir
|
||||
)
|
||||
yield Static(id="spacer")
|
||||
yield NoMarkupStatic(id="spacer")
|
||||
yield ContextProgress()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
|
|
@ -320,6 +321,24 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_compact_message_completed(
|
||||
self, message: CompactMessage.Completed
|
||||
) -> None:
|
||||
messages_area = self.query_one("#messages")
|
||||
children = list(messages_area.children)
|
||||
|
||||
try:
|
||||
compact_index = children.index(message.compact_widget)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if compact_index == 0:
|
||||
return
|
||||
|
||||
with self.batch_update():
|
||||
for widget in children[:compact_index]:
|
||||
await widget.remove()
|
||||
|
||||
def _set_tool_permission_always(
|
||||
self, tool_name: str, save_permanently: bool = False
|
||||
) -> None:
|
||||
|
|
@ -502,31 +521,32 @@ class VibeApp(App): # noqa: PLR0904
|
|||
messages_area = self.query_one("#messages")
|
||||
tool_call_map: dict[str, str] = {}
|
||||
|
||||
for msg in self._loaded_messages:
|
||||
if msg.role == Role.system:
|
||||
continue
|
||||
with self.batch_update():
|
||||
for msg in self._loaded_messages:
|
||||
if msg.role == Role.system:
|
||||
continue
|
||||
|
||||
match msg.role:
|
||||
case Role.user:
|
||||
if msg.content:
|
||||
await messages_area.mount(UserMessage(msg.content))
|
||||
match msg.role:
|
||||
case Role.user:
|
||||
if msg.content:
|
||||
await messages_area.mount(UserMessage(msg.content))
|
||||
|
||||
case Role.assistant:
|
||||
await self._mount_history_assistant_message(
|
||||
msg, messages_area, tool_call_map
|
||||
)
|
||||
|
||||
case Role.tool:
|
||||
tool_name = msg.name or tool_call_map.get(
|
||||
msg.tool_call_id or "", "tool"
|
||||
)
|
||||
await messages_area.mount(
|
||||
ToolResultMessage(
|
||||
tool_name=tool_name,
|
||||
content=msg.content,
|
||||
collapsed=self._tools_collapsed,
|
||||
case Role.assistant:
|
||||
await self._mount_history_assistant_message(
|
||||
msg, messages_area, tool_call_map
|
||||
)
|
||||
|
||||
case Role.tool:
|
||||
tool_name = msg.name or tool_call_map.get(
|
||||
msg.tool_call_id or "", "tool"
|
||||
)
|
||||
await messages_area.mount(
|
||||
ToolResultMessage(
|
||||
tool_name=tool_name,
|
||||
content=msg.content,
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def _mount_history_assistant_message(
|
||||
self, msg: LLMMessage, messages_area: Widget, tool_call_map: dict[str, str]
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ from __future__ import annotations
|
|||
from collections.abc import Callable
|
||||
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, ReasoningMessage
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.types import (
|
||||
AssistantEvent,
|
||||
|
|
@ -142,9 +141,7 @@ class EventHandler:
|
|||
self.current_compact = None
|
||||
|
||||
async def _handle_unknown_event(self, event: BaseEvent) -> None:
|
||||
await self.mount_callback(
|
||||
Static(str(event), markup=False, classes="unknown-event")
|
||||
)
|
||||
await self.mount_callback(NoMarkupStatic(str(event), classes="unknown-event"))
|
||||
|
||||
def stop_current_tool_call(self) -> None:
|
||||
if self.current_tool_call:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from textual.containers import Container, Vertical, VerticalScroll
|
|||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
|
@ -67,7 +68,7 @@ class ApprovalApp(Container):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="approval-content"):
|
||||
self.title_widget = Static(
|
||||
self.title_widget = NoMarkupStatic(
|
||||
f"⚠ {self.tool_name} command", classes="approval-title"
|
||||
)
|
||||
yield self.title_widget
|
||||
|
|
@ -78,16 +79,16 @@ class ApprovalApp(Container):
|
|||
)
|
||||
yield self.tool_info_container
|
||||
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for _ in range(3):
|
||||
widget = Static("", classes="approval-option")
|
||||
widget = NoMarkupStatic("", classes="approval-option")
|
||||
self.option_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
self.help_widget = Static(
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Enter select ESC reject", classes="approval-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ from textual.app import ComposeResult
|
|||
from textual.containers import Horizontal
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.history_manager import HistoryManager
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
|
||||
class ChatInputBody(Widget):
|
||||
|
|
@ -23,7 +23,7 @@ class ChatInputBody(Widget):
|
|||
def __init__(self, history_file: Path | None = None, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.input_widget: ChatTextArea | None = None
|
||||
self.prompt_widget: Static | None = None
|
||||
self.prompt_widget: NoMarkupStatic | None = None
|
||||
|
||||
if history_file:
|
||||
self.history = HistoryManager(history_file)
|
||||
|
|
@ -34,7 +34,7 @@ class ChatInputBody(Widget):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
self.prompt_widget = Static(">", id="prompt")
|
||||
self.prompt_widget = NoMarkupStatic(">", id="prompt")
|
||||
yield self.prompt_widget
|
||||
|
||||
self.input_widget = ChatTextArea(placeholder="Ask anything...", id="input")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
|
||||
|
||||
|
||||
class CompactMessage(StatusMessage):
|
||||
class Completed(Message):
|
||||
def __init__(self, compact_widget: CompactMessage) -> None:
|
||||
super().__init__()
|
||||
self.compact_widget = compact_widget
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.add_class("compact-message")
|
||||
|
|
@ -36,6 +43,7 @@ class CompactMessage(StatusMessage):
|
|||
self.old_tokens = old_tokens
|
||||
self.new_tokens = new_tokens
|
||||
self.stop_spinning(success=True)
|
||||
self.post_message(self.Completed(self))
|
||||
|
||||
def set_error(self, error_message: str) -> None:
|
||||
self.error_message = error_message
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from textual.theme import BUILTIN_THEMES
|
|||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.terminal_theme import TERMINAL_THEME_NAME
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
|
@ -85,19 +86,19 @@ class ConfigApp(Container):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="config-content"):
|
||||
self.title_widget = Static("Settings", classes="settings-title")
|
||||
self.title_widget = NoMarkupStatic("Settings", classes="settings-title")
|
||||
yield self.title_widget
|
||||
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for _ in self.settings:
|
||||
widget = Static("", classes="settings-option")
|
||||
widget = NoMarkupStatic("", classes="settings-option")
|
||||
self.setting_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
self.help_widget = Static(
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
|
@ -143,12 +144,13 @@ class ConfigApp(Container):
|
|||
current: str = self.changes.get(key, setting["value"])
|
||||
|
||||
options: list[str] = setting["options"]
|
||||
new_value = ""
|
||||
try:
|
||||
current_idx = options.index(current)
|
||||
next_idx = (current_idx + 1) % len(options)
|
||||
new_value: str = options[next_idx]
|
||||
new_value = options[next_idx]
|
||||
except (ValueError, IndexError):
|
||||
new_value: str = options[0] if options else current
|
||||
new_value = options[0] if options else current
|
||||
|
||||
self.changes[key] = new_value
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ from dataclasses import dataclass
|
|||
from typing import Any
|
||||
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -13,7 +14,7 @@ class TokenState:
|
|||
current_tokens: int = 0
|
||||
|
||||
|
||||
class ContextProgress(Static):
|
||||
class ContextProgress(NoMarkupStatic):
|
||||
tokens = reactive(TokenState())
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from textual.app import ComposeResult
|
|||
from textual.containers import Horizontal
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
||||
|
|
@ -55,10 +56,10 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
self.status = status or self._get_default_status()
|
||||
self.current_color_index = 0
|
||||
self.transition_progress = 0
|
||||
self.char_widgets: list[Static] = []
|
||||
self.ellipsis_widget: Static | None = None
|
||||
self._status_widget: Static | None = None
|
||||
self.hint_widget: Static | None = None
|
||||
self.start_time: float | None = None
|
||||
self._last_elapsed: int = -1
|
||||
|
||||
def _get_easter_egg(self) -> str | None:
|
||||
EASTER_EGG_PROBABILITY = 0.10
|
||||
|
|
@ -85,7 +86,7 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
|
||||
def set_status(self, status: str) -> None:
|
||||
self.status = self._apply_easter_egg(status)
|
||||
self._rebuild_chars()
|
||||
self._update_animation()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="loading-container"):
|
||||
|
|
@ -94,34 +95,14 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
)
|
||||
yield self._indicator_widget
|
||||
|
||||
with Horizontal(classes="loading-status"):
|
||||
for char in self.status:
|
||||
widget = Static(char, classes="loading-char")
|
||||
self.char_widgets.append(widget)
|
||||
yield widget
|
||||
self._status_widget = Static("", classes="loading-status")
|
||||
yield self._status_widget
|
||||
|
||||
self.ellipsis_widget = Static("… ", classes="loading-ellipsis")
|
||||
yield self.ellipsis_widget
|
||||
|
||||
self.hint_widget = Static("(0s esc to interrupt)", classes="loading-hint")
|
||||
self.hint_widget = NoMarkupStatic(
|
||||
"(0s esc to interrupt)", classes="loading-hint"
|
||||
)
|
||||
yield self.hint_widget
|
||||
|
||||
def _rebuild_chars(self) -> None:
|
||||
if not self.is_mounted:
|
||||
return
|
||||
|
||||
status_container = self.query_one(".loading-status", Horizontal)
|
||||
|
||||
status_container.remove_children()
|
||||
self.char_widgets.clear()
|
||||
|
||||
for char in self.status:
|
||||
widget = Static(char, classes="loading-char")
|
||||
self.char_widgets.append(widget)
|
||||
status_container.mount(widget)
|
||||
|
||||
self._update_animation()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.start_time = time()
|
||||
self._update_animation()
|
||||
|
|
@ -144,24 +125,26 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
return next_color
|
||||
return current_color
|
||||
|
||||
def _build_status_text(self) -> str:
|
||||
parts = []
|
||||
for i, char in enumerate(self.status):
|
||||
color = self._get_color_for_position(1 + i)
|
||||
parts.append(f"[{color}]{char}[/]")
|
||||
ellipsis_start = 1 + len(self.status)
|
||||
color_ellipsis = self._get_color_for_position(ellipsis_start)
|
||||
parts.append(f"[{color_ellipsis}]… [/]")
|
||||
return "".join(parts)
|
||||
|
||||
def _update_animation(self) -> None:
|
||||
total_elements = 1 + len(self.char_widgets) + 2
|
||||
total_elements = 1 + len(self.status) + 1
|
||||
|
||||
if self._indicator_widget:
|
||||
spinner_char = self._spinner.next_frame()
|
||||
color = self._get_color_for_position(0)
|
||||
self._indicator_widget.update(f"[{color}]{spinner_char}[/]")
|
||||
|
||||
for i, widget in enumerate(self.char_widgets):
|
||||
position = 1 + i
|
||||
color = self._get_color_for_position(position)
|
||||
widget.update(f"[{color}]{self.status[i]}[/]")
|
||||
|
||||
if self.ellipsis_widget:
|
||||
ellipsis_start = 1 + len(self.status)
|
||||
color_ellipsis = self._get_color_for_position(ellipsis_start)
|
||||
color_space = self._get_color_for_position(ellipsis_start + 1)
|
||||
self.ellipsis_widget.update(f"[{color_ellipsis}]…[/][{color_space}] [/]")
|
||||
if self._status_widget:
|
||||
self._status_widget.update(self._build_status_text())
|
||||
|
||||
self.transition_progress += 1
|
||||
if self.transition_progress > total_elements:
|
||||
|
|
@ -172,4 +155,6 @@ class LoadingWidget(SpinnerMixin, Static):
|
|||
|
||||
if self.hint_widget and self.start_time is not None:
|
||||
elapsed = int(time() - self.start_time)
|
||||
self.hint_widget.update(f"({elapsed}s esc to interrupt)")
|
||||
if elapsed != self._last_elapsed:
|
||||
self._last_elapsed = elapsed
|
||||
self.hint_widget.update(f"({elapsed}s esc to interrupt)")
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ from textual.containers import Horizontal, Vertical
|
|||
from textual.widgets import Markdown, Static
|
||||
from textual.widgets._markdown import MarkdownStream
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
||||
class NonSelectableStatic(Static):
|
||||
class NonSelectableStatic(NoMarkupStatic):
|
||||
@property
|
||||
def text_selection(self) -> None:
|
||||
return None
|
||||
|
|
@ -42,7 +43,7 @@ class UserMessage(Static):
|
|||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="user-message-container"):
|
||||
yield NonSelectableStatic("> ", classes="user-message-prompt")
|
||||
yield Static(self._content, markup=False, classes="user-message-content")
|
||||
yield NoMarkupStatic(self._content, classes="user-message-content")
|
||||
if self._pending:
|
||||
self.add_class("pending")
|
||||
|
||||
|
|
@ -137,8 +138,8 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
|
|||
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"
|
||||
self._status_text_widget = NoMarkupStatic(
|
||||
self.SPINNING_TEXT, classes="reasoning-collapsed-text"
|
||||
)
|
||||
yield self._status_text_widget
|
||||
self._triangle_widget = NonSelectableStatic(
|
||||
|
|
@ -204,9 +205,8 @@ class InterruptMessage(Static):
|
|||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="interrupt-container"):
|
||||
yield ExpandingBorder(classes="interrupt-border")
|
||||
yield Static(
|
||||
yield NoMarkupStatic(
|
||||
"Interrupted · What should Vibe do instead?",
|
||||
markup=False,
|
||||
classes="interrupt-content",
|
||||
)
|
||||
|
||||
|
|
@ -223,18 +223,20 @@ class BashOutputMessage(Static):
|
|||
def compose(self) -> ComposeResult:
|
||||
with Vertical(classes="bash-output-container"):
|
||||
with Horizontal(classes="bash-cwd-line"):
|
||||
yield Static(self._cwd, markup=False, classes="bash-cwd")
|
||||
yield Static("", classes="bash-cwd-spacer")
|
||||
yield NoMarkupStatic(self._cwd, classes="bash-cwd")
|
||||
yield NoMarkupStatic("", classes="bash-cwd-spacer")
|
||||
if self._exit_code == 0:
|
||||
yield Static("✓", classes="bash-exit-success")
|
||||
yield NoMarkupStatic("✓", classes="bash-exit-success")
|
||||
else:
|
||||
yield Static("✗", classes="bash-exit-failure")
|
||||
yield Static(f" ({self._exit_code})", classes="bash-exit-code")
|
||||
yield NoMarkupStatic("✗", classes="bash-exit-failure")
|
||||
yield NoMarkupStatic(
|
||||
f" ({self._exit_code})", classes="bash-exit-code"
|
||||
)
|
||||
with Horizontal(classes="bash-command-line"):
|
||||
yield Static("> ", classes="bash-chevron")
|
||||
yield Static(self._command, markup=False, classes="bash-command")
|
||||
yield Static("", classes="bash-command-spacer")
|
||||
yield Static(self._output, markup=False, classes="bash-output")
|
||||
yield NoMarkupStatic("> ", classes="bash-chevron")
|
||||
yield NoMarkupStatic(self._command, classes="bash-command")
|
||||
yield NoMarkupStatic("", classes="bash-command-spacer")
|
||||
yield NoMarkupStatic(self._output, classes="bash-output")
|
||||
|
||||
|
||||
class ErrorMessage(Static):
|
||||
|
|
@ -248,8 +250,8 @@ class ErrorMessage(Static):
|
|||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="error-container"):
|
||||
yield ExpandingBorder(classes="error-border")
|
||||
self._content_widget = Static(
|
||||
self._get_text(), markup=False, classes="error-content"
|
||||
self._content_widget = NoMarkupStatic(
|
||||
self._get_text(), classes="error-content"
|
||||
)
|
||||
yield self._content_widget
|
||||
|
||||
|
|
@ -278,4 +280,4 @@ class WarningMessage(Static):
|
|||
with Horizontal(classes="warning-container"):
|
||||
if self._show_border:
|
||||
yield ExpandingBorder(classes="warning-border")
|
||||
yield Static(self._message, markup=False, classes="warning-content")
|
||||
yield NoMarkupStatic(self._message, classes="warning-content")
|
||||
|
|
|
|||
11
vibe/cli/textual_ui/widgets/no_markup_static.py
Normal file
11
vibe/cli/textual_ui/widgets/no_markup_static.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from textual.visual import VisualType
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class NoMarkupStatic(Static):
|
||||
def __init__(self, content: VisualType = "", **kwargs: Any) -> None:
|
||||
super().__init__(content, markup=False, **kwargs)
|
||||
|
|
@ -2,10 +2,10 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.widgets import Static
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
|
||||
class PathDisplay(Static):
|
||||
class PathDisplay(NoMarkupStatic):
|
||||
def __init__(self, path: Path | str) -> None:
|
||||
super().__init__()
|
||||
self.can_focus = False
|
||||
|
|
|
|||
|
|
@ -147,3 +147,8 @@ class SpinnerMixin:
|
|||
self._indicator_widget.add_class("error")
|
||||
if self._status_text_widget and self.COMPLETED_TEXT:
|
||||
self._status_text_widget.update(self.COMPLETED_TEXT)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
if self._spinner_timer:
|
||||
self._spinner_timer.stop()
|
||||
self._spinner_timer = None
|
||||
|
|
|
|||
|
|
@ -7,16 +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.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
||||
class StatusMessage(SpinnerMixin, Static):
|
||||
class StatusMessage(SpinnerMixin, NoMarkupStatic):
|
||||
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
|
||||
|
||||
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
|
||||
self._initial_text = initial_text
|
||||
self._indicator_widget: Static | None = None
|
||||
self._text_widget: Static | None = None
|
||||
self._text_widget: NoMarkupStatic | None = None
|
||||
self.success = True
|
||||
self.init_spinner()
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -24,14 +25,10 @@ class StatusMessage(SpinnerMixin, Static):
|
|||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
self._indicator_widget = NonSelectableStatic(
|
||||
self._spinner.current_frame(),
|
||||
markup=False,
|
||||
classes="status-indicator-icon",
|
||||
self._spinner.current_frame(), classes="status-indicator-icon"
|
||||
)
|
||||
yield self._indicator_widget
|
||||
self._text_widget = Static(
|
||||
"", markup=False, classes="status-indicator-text"
|
||||
)
|
||||
self._text_widget = NoMarkupStatic("", classes="status-indicator-text")
|
||||
yield self._text_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from textual.app import ComposeResult
|
|||
from textual.containers import Vertical
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
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
|
||||
|
|
@ -51,15 +52,15 @@ def parse_search_replace_to_diff(content: str) -> list[str]:
|
|||
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")
|
||||
return NoMarkupStatic(line, classes="diff-header")
|
||||
elif line.startswith("-"):
|
||||
return Static(line, markup=False, classes="diff-removed")
|
||||
return NoMarkupStatic(line, classes="diff-removed")
|
||||
elif line.startswith("+"):
|
||||
return Static(line, markup=False, classes="diff-added")
|
||||
return NoMarkupStatic(line, classes="diff-added")
|
||||
elif line.startswith("@@"):
|
||||
return Static(line, markup=False, classes="diff-range")
|
||||
return NoMarkupStatic(line, classes="diff-range")
|
||||
else:
|
||||
return Static(line, markup=False, classes="diff-context")
|
||||
return NoMarkupStatic(line, classes="diff-context")
|
||||
|
||||
|
||||
class ToolApprovalWidget[TArgs: BaseModel](Vertical):
|
||||
|
|
@ -80,10 +81,8 @@ class ToolApprovalWidget[TArgs: BaseModel](Vertical):
|
|||
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"{field_name}: {value_str}",
|
||||
markup=False,
|
||||
classes="approval-description",
|
||||
yield NoMarkupStatic(
|
||||
f"{field_name}: {value_str}", classes="approval-description"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -115,9 +114,9 @@ class ToolResultWidget[TResult: BaseModel](Static):
|
|||
def _header(self) -> ComposeResult:
|
||||
"""Yield the standard header. Subclasses can call this then add content."""
|
||||
if self.collapsed:
|
||||
yield Static(f"{self.message} {self._hint()}", markup=False)
|
||||
yield NoMarkupStatic(f"{self.message} {self._hint()}")
|
||||
else:
|
||||
yield Static(self.message, markup=False)
|
||||
yield NoMarkupStatic(self.message)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Default: show message and optionally result fields."""
|
||||
|
|
@ -127,10 +126,8 @@ class ToolResultWidget[TResult: BaseModel](Static):
|
|||
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"{field_name}: {value}",
|
||||
markup=False,
|
||||
classes="tool-result-detail",
|
||||
yield NoMarkupStatic(
|
||||
f"{field_name}: {value}", classes="tool-result-detail"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -144,24 +141,18 @@ class BashResultWidget(ToolResultWidget[BashResult]):
|
|||
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",
|
||||
yield NoMarkupStatic(
|
||||
f"returncode: {self.result.returncode}", 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",
|
||||
yield NoMarkupStatic(
|
||||
f"stdout:{sep}{self.result.stdout}", 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",
|
||||
yield NoMarkupStatic(
|
||||
f"stderr:{sep}{self.result.stderr}", classes="tool-result-detail"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -170,10 +161,8 @@ class WriteFileApprovalWidget(ToolApprovalWidget[WriteFileArgs]):
|
|||
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 NoMarkupStatic(f"File: {self.args.path}", classes="approval-description")
|
||||
yield NoMarkupStatic("")
|
||||
yield Markdown(f"```{file_extension}\n{self.args.content}\n```")
|
||||
|
||||
|
||||
|
|
@ -182,26 +171,22 @@ class WriteFileResultWidget(ToolResultWidget[WriteFileResult]):
|
|||
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",
|
||||
yield NoMarkupStatic(f"Path: {self.result.path}", classes="tool-result-detail")
|
||||
yield NoMarkupStatic(
|
||||
f"Bytes: {self.result.bytes_written}", classes="tool-result-detail"
|
||||
)
|
||||
if self.result.content:
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
||||
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
|
||||
|
||||
|
||||
class SearchReplaceApprovalWidget(ToolApprovalWidget[SearchReplaceArgs]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
f"File: {self.args.file_path}", markup=False, classes="approval-description"
|
||||
yield NoMarkupStatic(
|
||||
f"File: {self.args.file_path}", classes="approval-description"
|
||||
)
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
diff_lines = parse_search_replace_to_diff(self.args.content)
|
||||
for line in diff_lines:
|
||||
|
|
@ -213,37 +198,30 @@ class SearchReplaceResultWidget(ToolResultWidget[SearchReplaceResult]):
|
|||
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(
|
||||
yield NoMarkupStatic(f"File: {self.result.file}", classes="tool-result-detail")
|
||||
yield NoMarkupStatic(
|
||||
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",
|
||||
yield NoMarkupStatic(
|
||||
f"Lines changed: {self.result.lines_changed}", classes="tool-result-detail"
|
||||
)
|
||||
for warning in self.result.warnings:
|
||||
yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning")
|
||||
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
||||
if self.result.content:
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
for line in parse_search_replace_to_diff(self.result.content):
|
||||
yield render_diff_line(line)
|
||||
|
||||
|
||||
class TodoApprovalWidget(ToolApprovalWidget[TodoArgs]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
f"Action: {self.args.action}", markup=False, classes="approval-description"
|
||||
yield NoMarkupStatic(
|
||||
f"Action: {self.args.action}", classes="approval-description"
|
||||
)
|
||||
if self.args.todos:
|
||||
yield Static(
|
||||
f"Todos: {len(self.args.todos)} items",
|
||||
markup=False,
|
||||
classes="approval-description",
|
||||
yield NoMarkupStatic(
|
||||
f"Todos: {len(self.args.todos)} items", classes="approval-description"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -252,13 +230,13 @@ class TodoResultWidget(ToolResultWidget[TodoResult]):
|
|||
|
||||
def compose(self) -> ComposeResult:
|
||||
if self.collapsed:
|
||||
yield Static(f"{self.message} {self._hint()}", markup=False)
|
||||
yield NoMarkupStatic(f"{self.message} {self._hint()}")
|
||||
else:
|
||||
yield Static(f"{self.message} {self._hint()}", markup=False)
|
||||
yield Static("")
|
||||
yield NoMarkupStatic(f"{self.message} {self._hint()}")
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
if not self.result or not self.result.todos:
|
||||
yield Static("No todos", markup=False, classes="todo-empty")
|
||||
yield NoMarkupStatic("No todos", classes="todo-empty")
|
||||
return
|
||||
|
||||
# Group todos by status
|
||||
|
|
@ -280,8 +258,8 @@ class TodoResultWidget(ToolResultWidget[TodoResult]):
|
|||
for status in ["in_progress", "pending", "completed", "cancelled"]:
|
||||
for todo in by_status[status]:
|
||||
icon = self._get_status_icon(status)
|
||||
yield Static(
|
||||
f"{icon} {todo.content}", markup=False, classes=f"todo-{status}"
|
||||
yield NoMarkupStatic(
|
||||
f"{icon} {todo.content}", classes=f"todo-{status}"
|
||||
)
|
||||
|
||||
def _get_status_icon(self, status: str) -> str:
|
||||
|
|
@ -291,20 +269,14 @@ class TodoResultWidget(ToolResultWidget[TodoResult]):
|
|||
|
||||
class ReadFileApprovalWidget(ToolApprovalWidget[ReadFileArgs]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
f"path: {self.args.path}", markup=False, classes="approval-description"
|
||||
)
|
||||
yield NoMarkupStatic(f"path: {self.args.path}", classes="approval-description")
|
||||
if self.args.offset > 0:
|
||||
yield Static(
|
||||
f"offset: {self.args.offset}",
|
||||
markup=False,
|
||||
classes="approval-description",
|
||||
yield NoMarkupStatic(
|
||||
f"offset: {self.args.offset}", classes="approval-description"
|
||||
)
|
||||
if self.args.limit is not None:
|
||||
yield Static(
|
||||
f"limit: {self.args.limit}",
|
||||
markup=False,
|
||||
classes="approval-description",
|
||||
yield NoMarkupStatic(
|
||||
f"limit: {self.args.limit}", classes="approval-description"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -314,32 +286,26 @@ class ReadFileResultWidget(ToolResultWidget[ReadFileResult]):
|
|||
if self.collapsed:
|
||||
return
|
||||
if self.result:
|
||||
yield Static(
|
||||
f"Path: {self.result.path}", markup=False, classes="tool-result-detail"
|
||||
yield NoMarkupStatic(
|
||||
f"Path: {self.result.path}", classes="tool-result-detail"
|
||||
)
|
||||
for warning in self.warnings:
|
||||
yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning")
|
||||
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
||||
if self.result and self.result.content:
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
ext = Path(self.result.path).suffix.lstrip(".") or "text"
|
||||
yield Markdown(f"```{ext}\n{_truncate_lines(self.result.content, 10)}\n```")
|
||||
|
||||
|
||||
class GrepApprovalWidget(ToolApprovalWidget[GrepArgs]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
f"pattern: {self.args.pattern}",
|
||||
markup=False,
|
||||
classes="approval-description",
|
||||
)
|
||||
yield Static(
|
||||
f"path: {self.args.path}", markup=False, classes="approval-description"
|
||||
yield NoMarkupStatic(
|
||||
f"pattern: {self.args.pattern}", classes="approval-description"
|
||||
)
|
||||
yield NoMarkupStatic(f"path: {self.args.path}", 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",
|
||||
yield NoMarkupStatic(
|
||||
f"max_matches: {self.args.max_matches}", classes="approval-description"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -349,9 +315,9 @@ class GrepResultWidget(ToolResultWidget[GrepResult]):
|
|||
if self.collapsed:
|
||||
return
|
||||
for warning in self.warnings:
|
||||
yield Static(f"⚠ {warning}", markup=False, classes="tool-result-warning")
|
||||
yield NoMarkupStatic(f"⚠ {warning}", classes="tool-result-warning")
|
||||
if self.result and self.result.matches:
|
||||
yield Static("")
|
||||
yield NoMarkupStatic("")
|
||||
yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from textual.containers import Horizontal, Vertical
|
|||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
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
|
||||
|
|
@ -99,11 +100,11 @@ class ToolResultMessage(Static):
|
|||
self.add_class("error-text")
|
||||
if self.collapsed:
|
||||
await self._content_container.mount(
|
||||
Static(f"Error. {self._hint()}", markup=False)
|
||||
NoMarkupStatic(f"Error. {self._hint()}")
|
||||
)
|
||||
else:
|
||||
await self._content_container.mount(
|
||||
Static(f"Error: {self._event.error}", markup=False)
|
||||
NoMarkupStatic(f"Error: {self._event.error}")
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -112,11 +113,11 @@ class ToolResultMessage(Static):
|
|||
reason = self._event.skip_reason or "User skipped"
|
||||
if self.collapsed:
|
||||
await self._content_container.mount(
|
||||
Static(f"Skipped. {self._hint()}", markup=False)
|
||||
NoMarkupStatic(f"Skipped. {self._hint()}")
|
||||
)
|
||||
else:
|
||||
await self._content_container.mount(
|
||||
Static(f"Skipped: {reason}", markup=False)
|
||||
NoMarkupStatic(f"Skipped: {reason}")
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -146,15 +147,15 @@ class ToolResultMessage(Static):
|
|||
|
||||
if self.collapsed:
|
||||
await self._content_container.mount(
|
||||
Static(f"{self._tool_name} completed {self._hint()}", markup=False)
|
||||
NoMarkupStatic(f"{self._tool_name} completed {self._hint()}")
|
||||
)
|
||||
return
|
||||
|
||||
if self._content:
|
||||
await self._content_container.mount(Static(self._content, markup=False))
|
||||
await self._content_container.mount(NoMarkupStatic(self._content))
|
||||
else:
|
||||
await self._content_container.mount(
|
||||
Static(f"{self._tool_name} completed.", markup=False)
|
||||
NoMarkupStatic(f"{self._tool_name} completed.")
|
||||
)
|
||||
|
||||
async def set_collapsed(self, collapsed: bool) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue