""" Chronicle Tab — persistent structured log viewer with filtering. Maintains a rolling buffer of all events and allows the user to filter by topic prefix and search by keyword. """ from __future__ import annotations import json from datetime import datetime, timezone from typing import Any, List, Optional from textual.app import ComposeResult from textual.binding import Binding from textual.widget import Widget from textual.widgets import Input, Label, RichLog, Static MAX_BUFFER = 2000 # maximum events retained class ChronicleTab(Widget): """ Historical event log with topic filtering and keyword search. Events are buffered internally. Press 'f' to focus the filter input. """ DEFAULT_CSS = """ ChronicleTab { layout: vertical; height: 1fr; padding: 1 2; } ChronicleTab .tab-title { color: $primary; text-style: bold; height: 1; margin-bottom: 1; } ChronicleTab .filter-row { layout: horizontal; height: 3; margin-bottom: 1; } ChronicleTab .filter-label { width: 10; height: 3; content-align: left middle; color: $text-muted; } ChronicleTab Input { width: 1fr; margin-right: 1; } ChronicleTab RichLog { height: 1fr; border: round $primary; background: $surface; } ChronicleTab .hint { height: 1; color: $text-muted; margin-top: 1; } """ BINDINGS = [ Binding("f", "focus_filter", "Filter"), Binding("c", "clear_filter", "Clear filter"), Binding("escape", "blur_filter", "Done"), ] def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self._buffer: List[dict] = [] self._sub_id: Optional[str] = None self._filter_text: str = "" def compose(self) -> ComposeResult: yield Static("// CHRONICLE — Event History", classes="tab-title") with Widget(classes="filter-row"): yield Static("Filter:", classes="filter-label") yield Input(placeholder="topic prefix or keyword…", id="chronicle-filter") yield RichLog(id="chronicle-log", highlight=True, markup=True, max_lines=MAX_BUFFER) yield Static( "Press [bold]f[/bold] to filter | [bold]c[/bold] to clear filter", classes="hint", ) def on_mount(self) -> None: app = self.app if hasattr(app, "bus"): self._sub_id = app.bus.subscribe("**", self._on_any_event) def on_unmount(self) -> None: app = self.app if hasattr(app, "bus") and self._sub_id: app.bus.unsubscribe(self._sub_id) def _on_any_event(self, topic: str, data: Any) -> None: now = datetime.now(timezone.utc) entry = { "ts": now.isoformat(), "topic": topic, "data": data, } self._buffer.append(entry) if len(self._buffer) > MAX_BUFFER: self._buffer = self._buffer[-MAX_BUFFER:] if self._passes_filter(entry): self.call_from_thread(self._append_entry, entry) def _passes_filter(self, entry: dict) -> bool: if not self._filter_text: return True text = self._filter_text.lower() if text in entry["topic"].lower(): return True try: if text in json.dumps(entry["data"], default=str).lower(): return True except Exception: pass return False def _append_entry(self, entry: dict) -> None: log_widget = self.query_one("#chronicle-log", RichLog) ts_short = entry["ts"][11:23] # HH:MM:SS.mmm try: data_str = json.dumps(entry["data"], default=str) except Exception: data_str = str(entry["data"]) if len(data_str) > 100: data_str = data_str[:100] + "…" line = f"[dim]{ts_short}[/dim] [cyan]{entry['topic']:<35}[/cyan] [dim]{data_str}[/dim]" log_widget.write(line) def on_input_changed(self, event: Input.Changed) -> None: if event.input.id == "chronicle-filter": self._filter_text = event.value self._rebuild_log() def _rebuild_log(self) -> None: """Repopulate log from buffer according to current filter.""" log_widget = self.query_one("#chronicle-log", RichLog) log_widget.clear() for entry in self._buffer: if self._passes_filter(entry): self._append_entry(entry) def action_focus_filter(self) -> None: self.query_one("#chronicle-filter", Input).focus() def action_clear_filter(self) -> None: inp = self.query_one("#chronicle-filter", Input) inp.value = "" self._filter_text = "" self._rebuild_log() def action_blur_filter(self) -> None: self.query_one("#chronicle-filter", Input).blur()