Adds a real-time view of actual routing tasks to the TUI dashboard: backend: - events.py: in-memory decision-event broker (pure stdlib, thread-safe). Bounded ring buffer + fan-out queues. persist_route_decision publishes here after each write so the TUI sees decisions without polling. - dispatcher.py: GET /events/decisions SSE endpoint — replays recent decisions then streams live ones with :heartbeat keepalive. Wired into persist_route_decision's write path. tui: - tui.py: DashboardApp now consumes /events/decisions via a background thread (call_from_thread). Decisions table updates live without waiting for the 5s /metrics poll. New columns: id, kind, category, tier, ctx, selected, est $. Number keys 1-6 cycle panels. - tui_screens.py: DecisionDetailScreen modal — press Enter or e on any decision row to see the full JSON (runner-ups, rejected reason, feature flags, confidence, context size). - tui_model.py: pure data layer extracted from tui.py — build_model, build_category_breakdown, decision_row. Testable without a terminal. - tui_sse.py: background-thread SSE consumer with reconnect. 17 new tests (events broker, SSE endpoint, TUI data model, detail popup, live decision handling). 562 total, all passing. lsp_diagnostics clean.
72 lines
1.9 KiB
Python
72 lines
1.9 KiB
Python
"""Modal screens for the LLM Router TUI.
|
|
|
|
This module is allowed to import ``textual`` — it is part of the TUI and is
|
|
never imported by the service dispatch path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from textual.app import ComposeResult
|
|
from textual.containers import Vertical, VerticalScroll
|
|
from textual.screen import ModalScreen
|
|
from textual.widgets import Button, Static
|
|
|
|
|
|
class DecisionDetailScreen(ModalScreen[None]):
|
|
"""Popup showing the full route_decisions row for one decision.
|
|
|
|
Press ``enter``, ``escape`` or ``q`` to close.
|
|
"""
|
|
|
|
CSS = """
|
|
DecisionDetailScreen {
|
|
align: center middle;
|
|
}
|
|
#detail-container {
|
|
width: 80;
|
|
height: 36;
|
|
border: thick $background 80%;
|
|
padding: 1 2;
|
|
background: $surface;
|
|
}
|
|
#detail-title {
|
|
text-style: bold;
|
|
color: $accent;
|
|
height: auto;
|
|
margin: 0 0 1 0;
|
|
}
|
|
#detail-content {
|
|
height: 1fr;
|
|
width: 1fr;
|
|
}
|
|
#detail-close {
|
|
width: 100%;
|
|
margin: 1 0 0 0;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, decision: dict[str, Any]) -> None:
|
|
self.decision = decision
|
|
super().__init__()
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with Vertical(id="detail-container"):
|
|
yield Static("Decision details", id="detail-title")
|
|
with VerticalScroll(id="detail-content"):
|
|
yield Static(self._render_text())
|
|
yield Button("Close (enter/esc/q)", id="detail-close")
|
|
|
|
def _render_text(self) -> str:
|
|
return json.dumps(self.decision, indent=2, default=str)
|
|
|
|
def on_key(self, event) -> None:
|
|
if event.key in ("escape", "q", "enter"):
|
|
self.dismiss(None)
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
if event.button.id == "detail-close":
|
|
self.dismiss(None)
|