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
@@ -76,7 +76,7 @@ func TestConfigSnapshotClearsStagedOnCleanReply(t *testing.T) {
m := configModel()
m.configStaged["project.enabled"] = "true"
m.applyServer(protocol.ServerMessage{
Type: protocol.TypeConfigSnapshot,
Type: protocol.TypeConfigSnapshot,
ConfigFields: []protocol.ConfigFieldDto{{Key: "project.enabled", Type: "BOOL", Value: "true"}},
})
if len(m.configStaged) != 0 {
+49
View File
@@ -1,5 +1,7 @@
package app
import "github.com/correx/tui-go/internal/protocol"
// PreviewFrame renders a single static frame for a named UI state at the given
// terminal size. Used by cmd/preview to screenshot the look without a live
// server. Not part of the runtime path.
@@ -138,10 +140,57 @@ func PreviewFrame(kind string, w, h int) string {
m.selectedID = "04a546aa"
m.overlay = OverlayPalette
m.paletteFilter = "to"
case "stats":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.overlay = OverlayStats
m.statsFor = "04a546aa"
m.stats = sampleStats()
}
return m.View()
}
func sampleStats() *protocol.StatsDto {
return &protocol.StatsDto{
SessionID: "04a546aa",
EventCount: 28,
SessionDurationMs: 204_000,
InferenceCount: 5,
InferenceMs: 38_400,
PromptTokens: 4120,
CompletionTokens: 1860,
TokensPerSecond: 48.4,
PerProvider: []protocol.ProviderStatsDto{
{Provider: "llama-cpp:qwen2.5-coder-14b", CompletedCount: 4, TotalLatencyMs: 33_200, PromptTokens: 3800, CompletionTokens: 1700, TokensPerSecond: 51.2},
{Provider: "llama-cpp:default", CompletedCount: 1, TotalLatencyMs: 5200, PromptTokens: 320, CompletionTokens: 160, TokensPerSecond: 30.8},
},
ToolCount: 6,
ToolMs: 4300,
PerTool: []protocol.ToolStatsDto{
{ToolName: "file_write", CompletedCount: 2, TotalDurationMs: 2600},
{ToolName: "read_file", CompletedCount: 3, TotalDurationMs: 1200},
{ToolName: "shell_exec", CompletedCount: 1, TotalDurationMs: 500},
},
ApprovalsRequested: 2,
ApprovalsResolved: 2,
ApprovalsPending: 0,
ApprovalWaitMs: 45_000,
AvgApprovalWaitMs: 22_500,
PerTier: []protocol.TierApprovalStatsDto{
{Tier: "T2", RequestedCount: 1, ResolvedCount: 1, TotalWaitMs: 15_000, AvgWaitMs: 15_000},
{Tier: "T3", RequestedCount: 1, ResolvedCount: 1, TotalWaitMs: 30_000, AvgWaitMs: 30_000},
},
Failures: protocol.FailureMetricsDto{ToolFailures: 1},
InferencePct: 18.8,
ToolPct: 2.1,
ApprovalWaitPct: 22.1,
}
}
func sampleWorkflows() []Workflow {
return []Workflow{
{ID: "healthcheck", Description: "ping endpoints and report status"},
+13 -7
View File
@@ -49,6 +49,7 @@ const (
OverlayModels
OverlayArtifacts
OverlayConfig
OverlayStats
)
// RouterEntry is one line in a session's conversation transcript.
@@ -143,15 +144,15 @@ type Model struct {
reconnecting bool
// sessions
sessions []Session
selectedID string
filter string
workflows []Workflow
wfIndex int // -1 = not in workflow picker
wfVisible bool
sessions []Session
selectedID string
filter string
workflows []Workflow
wfIndex int // -1 = not in workflow picker
wfVisible bool
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
wfPendingName string
bgUpdates int
bgUpdates int
// input
editMode EditMode
@@ -209,6 +210,11 @@ type Model struct {
configRestart []string // keys from the last save that need a restart
configLoading bool
// session stats (OverlayStats) — populated by the session.stats reply
stats *protocol.StatsDto
statsFor string // sessionId the current stats belong to
statsLoading bool
// command palette
paletteFilter string
paletteIndex int
+113
View File
@@ -1,6 +1,7 @@
package app
import (
"fmt"
"sort"
"strings"
@@ -43,6 +44,8 @@ func (m Model) renderOverlay(base string) string {
return m.center(m.artifactsModal())
case OverlayConfig:
return m.center(m.configModal())
case OverlayStats:
return m.center(m.statsModal())
}
return base
}
@@ -545,6 +548,116 @@ func (m Model) artifactsModal() string {
return t.Overlay.Width(w).Render(b.String())
}
// statsBreakdownRows caps how many per-provider / per-tool / per-tier rows the pane
// shows; the server already sorts each list by descending cost, so the cap keeps the
// heaviest contributors and the modal bounded.
const statsBreakdownRows = 6
// humanDurMs renders a millisecond span as a compact "1h 2m 3s" string.
func humanDurMs(ms int64) string {
if ms <= 0 {
return "0s"
}
total := ms / 1000
h := total / 3600
mn := (total / 60) % 60
s := total % 60
out := ""
if h > 0 {
out += itoa(int(h)) + "h "
}
if h > 0 || mn > 0 {
out += itoa(int(mn)) + "m "
}
return out + itoa(int(s)) + "s"
}
func (m Model) statsModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("session stats"))
if m.selectedID != "" {
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
}
b.WriteString("\n\n")
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
msg := "loading…"
if !m.statsLoading && m.stats == nil {
msg = "no stats for this session"
}
b.WriteString(mbg(t, " "+msg, t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
s := m.stats
section := func(label string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
}
line := func(s string) string { return mbg(t, s, t.P.Fg) }
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
// header row: duration + event count
b.WriteString(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
faint(" events ") + line(itoa(int(s.EventCount))) + "\n\n")
// inference
b.WriteString(section("Inference") + "\n")
b.WriteString(line(fmt.Sprintf(" %d calls · %d prompt + %d completion = %d tok · %.1f tok/s",
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.PromptTokens+s.CompletionTokens, s.TokensPerSecond)) + "\n")
for i, p := range s.PerProvider {
if i >= statsBreakdownRows {
b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerProvider)-statsBreakdownRows)) + "\n")
break
}
b.WriteString(faint(fmt.Sprintf(" %-20s %3d calls %6d tok %7d ms %.1f tok/s",
padRaw(p.Provider, 20), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TotalLatencyMs, p.TokensPerSecond)) + "\n")
}
b.WriteString("\n")
// tools
b.WriteString(section("Tools") + "\n")
b.WriteString(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)) + "\n")
for i, tl := range s.PerTool {
if i >= statsBreakdownRows {
b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerTool)-statsBreakdownRows)) + "\n")
break
}
b.WriteString(faint(fmt.Sprintf(" %-24s %3d %7d ms", padRaw(tl.ToolName, 24), tl.CompletedCount, tl.TotalDurationMs)) + "\n")
}
b.WriteString("\n")
// approvals
b.WriteString(section("Approvals") + "\n")
b.WriteString(line(fmt.Sprintf(" %d req · %d resolved · %d pending · avg wait %d ms",
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)) + "\n")
for _, tr := range s.PerTier {
b.WriteString(faint(fmt.Sprintf(" %-4s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)) + "\n")
}
b.WriteString("\n")
// failures
f := s.Failures
b.WriteString(section("Failures") + "\n")
b.WriteString(line(fmt.Sprintf(" inference %d · timeouts %d · tool %d · rejected %d · stage %d · workflow %d",
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)) + "\n\n")
// time accounting
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
if other < 0 {
other = 0
}
b.WriteString(section("Time") + " " +
line(fmt.Sprintf("inference %.1f%% · tools %.1f%% · approval-wait %.1f%% · other %.1f%%",
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
// shortID trims a long artifact id for the list column while staying identifiable.
func shortID(id string) string {
if len(id) <= 14 {
+5 -1
View File
@@ -304,6 +304,10 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if m.artifactsIndex >= len(m.artifacts) {
m.artifactsIndex = 0
}
case protocol.TypeSessionStats:
m.stats = msg.Stats
m.statsFor = msg.SessionID
m.statsLoading = false
case protocol.TypeConfigSnapshot:
m.configFields = msg.ConfigFields
m.configRestart = msg.ConfigRestartRequired
@@ -332,7 +336,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
protocol.TypeProtocolError, protocol.TypeProviderStatus,
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
protocol.TypeArtifactList, protocol.TypeConfigSnapshot:
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats:
return ""
default:
return msg.SessionID
+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":
+1 -1
View File
@@ -155,7 +155,7 @@ func (m Model) renderFooter() string {
case m.displayState() == StateIdle:
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")}
default: // StateInSession
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
hints = append(hints, hint("a", "approval (pending)"))
}