Files
6krrt/tui_screens.py
adlee-was-taken f0ebd83a09 feat(tui): live routing-decisions panel with SSE, detail popup, breakdown
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.
2026-08-23 23:38:07 -04:00

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)