Files
mistral-vibe/vibe/cli/textual_ui/widgets/messages.py
Mathias Gesbert 6a50d1d521 v2.7.0 (#534)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-03-26 10:11:34 +01:00

315 lines
10 KiB
Python

from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from vibe.cli.textual_ui.app import ChatScroll
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Static
from textual.widgets._markdown import MarkdownStream
from vibe.cli.textual_ui.ansi_markdown import AnsiMarkdown as Markdown
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
class NonSelectableStatic(NoMarkupStatic):
@property
def text_selection(self) -> None:
return None
@text_selection.setter
def text_selection(self, value: Any) -> None:
pass
def get_selection(self, selection: Any) -> None:
return None
class ExpandingBorder(NonSelectableStatic):
def render(self) -> str:
height = self.size.height
return "\n".join([""] * (height - 1) + [""])
def on_resize(self) -> None:
self.refresh()
class UserMessage(Static):
def __init__(
self, content: str, pending: bool = False, message_index: int | None = None
) -> None:
super().__init__()
self.add_class("user-message")
self._content = content
self._pending = pending
self.message_index: int | None = message_index
def get_content(self) -> str:
return self._content
def compose(self) -> ComposeResult:
with Horizontal(classes="user-message-container"):
yield NoMarkupStatic(self._content, classes="user-message-content")
if self._pending:
self.add_class("pending")
async def set_pending(self, pending: bool) -> None:
if pending == self._pending:
return
self._pending = pending
if pending:
self.add_class("pending")
return
self.remove_class("pending")
class StreamingMessageBase(Static):
def __init__(self, content: str) -> None:
super().__init__()
self._content = content
self._markdown: Markdown | None = None
self._stream: MarkdownStream | None = None
self._content_initialized = False
self._to_write_buffer = ""
def _get_markdown(self) -> Markdown:
if self._markdown is None:
raise RuntimeError(
"Markdown widget not initialized. compose() must be called first."
)
return self._markdown
def _ensure_stream(self) -> MarkdownStream:
if self._stream is None:
self._stream = Markdown.get_stream(self._get_markdown())
return self._stream
def _is_chat_at_bottom(self) -> bool:
try:
chat = cast("ChatScroll", self.app.query_one("#chat"))
return chat.is_at_bottom
except Exception:
return True
async def append_content(self, content: str) -> None:
if not content:
return
self._content += content
if not self._should_write_content():
return
if self._is_chat_at_bottom():
to_write = self._to_write_buffer + content
self._to_write_buffer = ""
stream = self._ensure_stream()
await stream.write(to_write)
return
self._to_write_buffer += content
async def write_initial_content(self) -> None:
if self._content_initialized:
return
self._content_initialized = True
if self._content and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._content)
self._to_write_buffer = ""
async def stop_stream(self) -> None:
if self._to_write_buffer and self._should_write_content():
stream = self._ensure_stream()
await stream.write(self._to_write_buffer)
self._to_write_buffer = ""
if self._stream is None:
return
await self._stream.stop()
self._stream = None
def _should_write_content(self) -> bool:
return True
def is_stripped_content_empty(self) -> bool:
return self._content.strip() == ""
class AssistantMessage(StreamingMessageBase):
def __init__(self, content: str) -> None:
super().__init__(content)
self.add_class("assistant-message")
def compose(self) -> ComposeResult:
if self._content:
self._content_initialized = True
markdown = Markdown(self._content)
self._markdown = markdown
yield markdown
class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
SPINNER_TYPE = SpinnerType.PULSE
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 = NoMarkupStatic(
self.SPINNING_TEXT, 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()
def on_resize(self) -> None:
self.refresh_spinner()
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)
self._to_write_buffer = ""
class UserCommandMessage(Static):
def __init__(self, content: str) -> None:
super().__init__()
self.add_class("user-command-message")
self._content = content
def compose(self) -> ComposeResult:
with Horizontal(classes="user-command-container"):
yield ExpandingBorder(classes="user-command-border")
with Vertical(classes="user-command-content"):
yield Markdown(self._content)
class WhatsNewMessage(Static):
def __init__(self, content: str) -> None:
super().__init__()
self.add_class("whats-new-message")
self._content = content
def compose(self) -> ComposeResult:
yield Markdown(self._content)
class InterruptMessage(Static):
def __init__(self) -> None:
super().__init__()
self.add_class("interrupt-message")
def compose(self) -> ComposeResult:
with Horizontal(classes="interrupt-container"):
yield ExpandingBorder(classes="interrupt-border")
yield NoMarkupStatic(
"Interrupted · What should Vibe do instead?",
classes="interrupt-content",
)
class BashOutputMessage(Static):
def __init__(self, command: str, cwd: str, output: str, exit_code: int) -> None:
super().__init__()
self.add_class("bash-output-message")
self._command = command
self._cwd = cwd
self._output = output.rstrip("\n")
self._exit_code = exit_code
def compose(self) -> ComposeResult:
status_class = "bash-success" if self._exit_code == 0 else "bash-error"
self.add_class(status_class)
with Horizontal(classes="bash-command-line"):
yield NonSelectableStatic("$ ", classes=f"bash-prompt {status_class}")
yield NoMarkupStatic(self._command, classes="bash-command")
with Horizontal(classes="bash-output-container"):
yield ExpandingBorder(classes="bash-output-border")
yield NoMarkupStatic(self._output, classes="bash-output")
class ErrorMessage(Static):
def __init__(self, error: str, collapsed: bool = False) -> None:
super().__init__()
self.add_class("error-message")
self._error = error
self.collapsed = collapsed
self._content_widget: Static | None = None
def compose(self) -> ComposeResult:
with Horizontal(classes="error-container"):
yield ExpandingBorder(classes="error-border")
self._content_widget = NoMarkupStatic(
f"Error: {self._error}", classes="error-content"
)
yield self._content_widget
def set_collapsed(self, collapsed: bool) -> None:
pass
class WarningMessage(Static):
def __init__(self, message: str, show_border: bool = True) -> None:
super().__init__()
self.add_class("warning-message")
self._message = message
self._show_border = show_border
def compose(self) -> ComposeResult:
with Horizontal(classes="warning-container"):
if self._show_border:
yield ExpandingBorder(classes="warning-border")
yield NoMarkupStatic(self._message, classes="warning-content")