mirror of
https://github.com/mistralai/mistral-vibe
synced 2026-04-26 01:24:55 +02:00
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Peter Evers <pevers90@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
245 lines
8.3 KiB
Python
245 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
import bisect
|
|
from collections.abc import Callable
|
|
|
|
from rich.markup import escape
|
|
from rich.text import Text
|
|
from textual import events
|
|
from textual.app import ComposeResult
|
|
from textual.cache import LRUCache
|
|
from textual.containers import Vertical
|
|
from textual.geometry import Size
|
|
from textual.scroll_view import ScrollView
|
|
from textual.strip import Strip
|
|
from textual.widgets import Static
|
|
|
|
from vibe.core.log_reader import LogEntry, LogReader
|
|
from vibe.core.logger import decode_log_message
|
|
|
|
LOG_LEVEL_COLORS: dict[str, str] = {
|
|
"DEBUG": "dim",
|
|
"INFO": "cyan",
|
|
"WARNING": "yellow",
|
|
"ERROR": "red",
|
|
"CRITICAL": "bold red",
|
|
}
|
|
|
|
DEFAULT_LOG_PAGE_SIZE = 30
|
|
|
|
|
|
class _LogView(ScrollView, can_focus=True):
|
|
def __init__(
|
|
self,
|
|
load_page: Callable[[], None],
|
|
has_more: Callable[[], bool],
|
|
*,
|
|
id: str | None = None,
|
|
) -> None:
|
|
super().__init__(id=id)
|
|
self._lines: list[str] = []
|
|
self._wrap_counts: list[int] = []
|
|
self._wrap_prefix: list[int] = [0]
|
|
self._total_visual: int = 0
|
|
self._cached_width: int = 0
|
|
self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024)
|
|
self._load_page = load_page
|
|
self._has_more = has_more
|
|
|
|
def _wrap_markup(self, markup: str) -> int:
|
|
"""Return the number of visual lines this markup produces at current width."""
|
|
width = self._cached_width
|
|
if width <= 0:
|
|
return 1
|
|
|
|
text = Text.from_markup(markup, style=self.rich_style)
|
|
|
|
return len(text.wrap(self.app.console, width))
|
|
|
|
def _recompute_prefix(self) -> None:
|
|
self._wrap_prefix = [0]
|
|
for count in self._wrap_counts:
|
|
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
|
|
self._total_visual = self._wrap_prefix[-1]
|
|
|
|
def _reflow(self) -> None:
|
|
"""Re-wrap all lines at current widget width."""
|
|
width = self.size.width
|
|
if width <= 0:
|
|
return
|
|
self._cached_width = width
|
|
self._render_line_cache.clear()
|
|
self._wrap_counts = [self._wrap_markup(m) for m in self._lines]
|
|
self._recompute_prefix()
|
|
self.virtual_size = Size(width, self._total_visual)
|
|
|
|
def write_line(self, markup: str, scroll_end: bool | None = None) -> None:
|
|
at_bottom = self.is_vertical_scroll_end
|
|
width = self._cached_width or self.size.width
|
|
self._cached_width = width
|
|
|
|
self._lines.append(markup)
|
|
count = self._wrap_markup(markup)
|
|
self._wrap_counts.append(count)
|
|
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
|
|
self._total_visual += count
|
|
self.virtual_size = Size(width, self._total_visual)
|
|
|
|
if scroll_end or (scroll_end is None and at_bottom):
|
|
self.scroll_end(animate=False, immediate=True, x_axis=False)
|
|
|
|
def prepend_lines(self, markups: list[str]) -> None:
|
|
if not markups:
|
|
return
|
|
width = self._cached_width or self.size.width
|
|
self._cached_width = width
|
|
|
|
new_counts = [self._wrap_markup(m) for m in markups]
|
|
new_visual = sum(new_counts)
|
|
|
|
self._lines[0:0] = markups
|
|
self._wrap_counts[0:0] = new_counts
|
|
self._recompute_prefix()
|
|
self._render_line_cache.clear()
|
|
self.virtual_size = Size(width, self._total_visual)
|
|
self.scroll_to(y=self.scroll_y + new_visual, animate=False, immediate=True)
|
|
|
|
def render_line(self, y: int) -> Strip:
|
|
_, scroll_y = self.scroll_offset
|
|
abs_y = scroll_y + y
|
|
width = self.size.width
|
|
wrap_width = self._cached_width or width
|
|
rich_style = self.rich_style
|
|
|
|
if abs_y >= self._total_visual:
|
|
return Strip.blank(width, rich_style)
|
|
if abs_y in self._render_line_cache:
|
|
return self._render_line_cache[abs_y]
|
|
|
|
logical_idx = bisect.bisect_right(self._wrap_prefix, abs_y) - 1
|
|
text = Text.from_markup(self._lines[logical_idx], style=rich_style)
|
|
wrapped = text.wrap(self.app.console, wrap_width)
|
|
|
|
base = self._wrap_prefix[logical_idx]
|
|
for i, line_text in enumerate(wrapped):
|
|
strip = Strip(line_text.render(self.app.console), line_text.cell_len)
|
|
strip = strip.crop_extend(0, width, rich_style)
|
|
self._render_line_cache[base + i] = strip
|
|
|
|
try:
|
|
return self._render_line_cache[abs_y]
|
|
except KeyError:
|
|
return Strip.blank(width, rich_style)
|
|
|
|
def notify_style_update(self) -> None:
|
|
super().notify_style_update()
|
|
self._render_line_cache.clear()
|
|
|
|
def on_resize(self, event: events.Resize) -> None:
|
|
if event.size.width != self._cached_width:
|
|
self._reflow()
|
|
|
|
def on_click(self, event: events.Click) -> None:
|
|
_, scroll_y = self.scroll_offset
|
|
visual_y = scroll_y + event.y
|
|
logical_idx = bisect.bisect_right(self._wrap_prefix, visual_y) - 1
|
|
if 0 <= logical_idx < len(self._lines):
|
|
plain = Text.from_markup(self._lines[logical_idx]).plain
|
|
self.app.copy_to_clipboard(plain)
|
|
self.app.notify("Copied to clipboard", timeout=2.0)
|
|
|
|
def _try_load_previous(self) -> None:
|
|
if not self._has_more() or self.scroll_y > 0:
|
|
return
|
|
self._load_page()
|
|
|
|
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
|
super()._on_mouse_scroll_up(event)
|
|
self._try_load_previous()
|
|
|
|
def action_scroll_up(self) -> None:
|
|
super().action_scroll_up()
|
|
self._try_load_previous()
|
|
|
|
def action_page_up(self) -> None:
|
|
super().action_page_up()
|
|
self._try_load_previous()
|
|
|
|
def action_scroll_home(self) -> None:
|
|
super().action_scroll_home()
|
|
self._try_load_previous()
|
|
|
|
|
|
class DebugConsole(Vertical):
|
|
def __init__(
|
|
self, log_reader: LogReader, page_size: int = DEFAULT_LOG_PAGE_SIZE
|
|
) -> None:
|
|
super().__init__(id="debug-console")
|
|
self._log_reader = log_reader
|
|
self._log_view: _LogView | None = None
|
|
self._cursor: int | None = None
|
|
self._has_more: bool = True
|
|
self._page_size = page_size
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Static(
|
|
"Debug Console [dim](ctrl+\\ to close)[/dim]", id="debug-console-header"
|
|
)
|
|
self._log_view = _LogView(
|
|
load_page=self._load_page,
|
|
has_more=lambda: self._has_more and self._cursor is not None,
|
|
id="debug-console-log",
|
|
)
|
|
yield self._log_view
|
|
|
|
def on_mount(self) -> None:
|
|
self._fill_viewport()
|
|
self._log_reader.set_consumer(self._on_log_entry)
|
|
self._log_reader.start_watching()
|
|
|
|
def on_unmount(self) -> None:
|
|
self._log_reader.set_consumer(None)
|
|
self._log_reader.stop_watching()
|
|
|
|
def _load_page(self) -> None:
|
|
if self._log_view is None:
|
|
return
|
|
result = self._log_reader.get_logs(
|
|
limit=self._page_size, offset=self._cursor or 0
|
|
)
|
|
self._cursor = result.cursor
|
|
self._has_more = result.has_more
|
|
markups = [self._format_entry(e) for e in reversed(result.entries)]
|
|
self._log_view.prepend_lines(markups)
|
|
|
|
def _fill_viewport(self) -> None:
|
|
"""Load enough logs to fill the viewport, then scroll to the bottom."""
|
|
if self._log_view is None or not self._has_more:
|
|
return
|
|
self.call_after_refresh(self._check_and_fill)
|
|
|
|
def _check_and_fill(self) -> None:
|
|
if self._log_view is None or not self._has_more:
|
|
return
|
|
if self._log_view.virtual_size.height <= self._log_view.size.height:
|
|
self._load_page()
|
|
self._fill_viewport()
|
|
else:
|
|
self._log_view.scroll_end(animate=False)
|
|
|
|
def _on_log_entry(self, entry: LogEntry) -> None:
|
|
self.app.call_from_thread(self._append_log_entry, entry)
|
|
|
|
def _append_log_entry(self, entry: LogEntry) -> None:
|
|
if self._log_view is None:
|
|
return
|
|
self._log_view.write_line(self._format_entry(entry))
|
|
|
|
@staticmethod
|
|
def _format_entry(entry: LogEntry) -> str:
|
|
color = LOG_LEVEL_COLORS.get(entry.level, "dim")
|
|
ts = entry.timestamp.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
message = decode_log_message(entry.message)
|
|
safe_message = escape(message)
|
|
return f"[dim]{ts}[/dim] [{color}]{entry.level:<8}[/{color}] {safe_message}"
|