feat(server,tui): session stats pane — metrics over the WS bus + Bubble Tea overlay

Surfaces the observability metrics (observability-spec §3-tier-2) in the Go TUI:
a session-summary pane showing duration, token throughput per provider, tool
time, approval latency per tier, failure counts, and the session wall-time
accounting split. Press `S` (or palette → "session stats").

Mirrors the artifacts/config fetch pattern exactly — a WS request/response, not a
new transport. Server-side reuses the already-tested MetricsInspectionService
(one MetricsReport definition, two wires: REST `correx stats` + WS).

Server:
- ClientMessage.GetSessionStats + ServerMessage.SessionStats(@SerialName
  session.stats), reusing MetricsReport as the nested payload.
- StreamQueries.sessionStats replays via MetricsInspectionService (pure read,
  replay-neutral); routed in GlobalStreamHandler.
- ServerMessageSerializationTest golden pins the session.stats wire format.

TUI (Go/Bubble Tea):
- protocol: TypeSessionStats + StatsDto/nested structs + GetSessionStats encoder
  + golden decode test (cross-language contract).
- OverlayStats pane (statsModal), `S` key + palette entry + footer hint,
  loading/cache-by-session state, bounded breakdown rows.
- demo `stats` preview case for serverless visual verification.
This commit is contained in:
2026-06-13 10:39:52 +04:00
parent fe941408a7
commit 4f6bfa85f7
14 changed files with 387 additions and 10 deletions
+24
View File
@@ -169,6 +169,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.overlay = OverlayToolPalette
case "v":
m.openArtifacts()
case "S":
m.openStats()
case "g":
m.openConfig()
case "m":
@@ -429,6 +431,10 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case runeIs(k, "m"):
m.overlay = OverlayNone
}
case OverlayStats:
if runeIs(k, "S") {
m.overlay = OverlayNone
}
}
return m, nil
}
@@ -450,6 +456,21 @@ func (m *Model) openArtifacts() {
m.client.Send(protocol.ListArtifacts(m.selectedID))
}
// openStats opens the session-stats pane for the selected session and requests its
// metrics from the server. No-op when no session is selected.
func (m *Model) openStats() {
if m.selectedID == "" {
return
}
m.overlay = OverlayStats
// Reuse a cached report only if it's for this session; otherwise show a loading state.
if m.statsFor != m.selectedID {
m.stats = nil
m.statsLoading = true
}
m.client.Send(protocol.GetSessionStats(m.selectedID))
}
// openModelsOverlay opens the model picker, pre-selecting the resident model.
func (m *Model) openModelsOverlay() {
m.overlay = OverlayModels
@@ -502,6 +523,7 @@ func paletteCommands() []paletteCmd {
{"models", "swap model", "pick / pin the local model"},
{"events", "event inspector", "browse the event stream"},
{"artifacts", "view artifacts", "browse this session's artifacts"},
{"stats", "session stats", "metrics for the selected session"},
{"config", "edit config", "view / change correx settings"},
{"mode", "toggle mode", "switch chat / steering"},
{"cancel", "cancel session", "stop the selected session"},
@@ -539,6 +561,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
m.overlayEventIdx = 0
case "artifacts":
m.openArtifacts()
case "stats":
m.openStats()
case "config":
m.openConfig()
case "mode":