From 80a72d370e17b66abdc73fcd876245d83e905f9a Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 3 Jun 2026 15:41:51 +0400 Subject: [PATCH] feat(tui): show router inference metrics beside router lines --- apps/tui-go/internal/app/demo.go | 8 ++++---- apps/tui-go/internal/app/model.go | 9 ++++++++- apps/tui-go/internal/app/server.go | 8 ++++++-- apps/tui-go/internal/app/view.go | 12 ++++++++++++ apps/tui-go/internal/protocol/golden_test.go | 11 +++++++++++ apps/tui-go/internal/protocol/protocol.go | 4 ++++ 6 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 46a335b2..e0de95ae 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -37,9 +37,9 @@ func PreviewFrame(kind string, w, h int) string { m.sessionEntered = true m.routerConnected = true m.routerMessages["04a546aa"] = []RouterEntry{ - {"user", "write a healthcheck script for the api"}, - {"router", "I'll create a script that pings /health and checks the status code, then writes the results to a timestamped file."}, - {"tool", "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo \"ok $(date)\"\n"}, + {Role: "user", Content: "write a healthcheck script for the api"}, + {Role: "router", Content: "I'll create a script that pings /health and checks the status code, then writes the results to a timestamped file."}, + {Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo \"ok $(date)\"\n"}, } if s := m.session("04a546aa"); s != nil { s.CurrentStage = "write_script" @@ -127,7 +127,7 @@ func PreviewFrame(kind string, w, h int) string { m.selectedID = "04a546aa" m.sessionEntered = true m.routerMessages["04a546aa"] = []RouterEntry{ - {"tool", "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -1,2 +1,6 @@\n #!/usr/bin/env bash\n-echo hi\n+curl -sf http://localhost:8080/health\n+if [ $? -ne 0 ]; then\n+ echo \"unhealthy\" >&2\n+ exit 1\n+fi\n+echo \"ok $(date)\"\n"}, + {Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -1,2 +1,6 @@\n #!/usr/bin/env bash\n-echo hi\n+curl -sf http://localhost:8080/health\n+if [ $? -ne 0 ]; then\n+ echo \"unhealthy\" >&2\n+ exit 1\n+fi\n+echo \"ok $(date)\"\n"}, } m.overlay = OverlayDiff diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index fcc3a03d..0f81b202 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -50,8 +50,15 @@ const ( // RouterEntry is one line in a session's conversation transcript. type RouterEntry struct { - Role string // user | router | tool + Role string // user | router | tool | narration | narration_llm Content string + Metrics *TurnMetrics +} + +// TurnMetrics carries optional latency + token cost for a ROUTER chat turn. +type TurnMetrics struct { + LatencyMs int64 + TotalTokens int } // EventEntry is a row in the event stream. diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index c5e2ad1b..26632bab 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -98,7 +98,11 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if msg.Role == "USER" { role = "user" } - m.appendRouter(msg.SessionID, RouterEntry{role, msg.Content}) + entry := RouterEntry{Role: role, Content: msg.Content} + if msg.LatencyMs != nil && msg.TotalTokens != nil { + entry.Metrics = &TurnMetrics{LatencyMs: *msg.LatencyMs, TotalTokens: *msg.TotalTokens} + } + m.appendRouter(msg.SessionID, entry) if s := m.session(msg.SessionID); s != nil { s.LastEventAt = nowMillis() } @@ -182,7 +186,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName) } if msg.Diff != nil && *msg.Diff != "" { - m.appendRouter(msg.SessionID, RouterEntry{"tool", *msg.Diff}) + m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff}) } case protocol.TypeToolAssessed: if s := m.session(msg.SessionID); s != nil { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 60d486dc..5b2c8daf 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -1,6 +1,7 @@ package app import ( + "fmt" "os" "strings" @@ -352,6 +353,9 @@ func (m Model) routerRows(w, h int) []string { for _, ln := range wrap(e.Content, w) { rows = append(rows, t.span(ln, t.P.Fg)) } + if s := metricsSuffix(e.Metrics); s != "" { + rows = append(rows, t.span(s, t.P.Faint)) + } case "tool": rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim)) case "narration", "stage": @@ -570,6 +574,14 @@ func itoa(n int) string { func itoaLen(s string) string { return itoa(len(s)) } +// metricsSuffix formats the faint latency+token annotation for a ROUTER turn. +func metricsSuffix(mtr *TurnMetrics) string { + if mtr == nil { + return "" + } + return fmt.Sprintf(" %.1fs · %d tokens", float64(mtr.LatencyMs)/1000.0, mtr.TotalTokens) +} + // wrap splits s into lines no wider than w (rune-based, whitespace-greedy). func wrap(s string, w int) []string { if w < 1 { diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index 2660eb3a..df34c382 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -88,6 +88,17 @@ func TestDecodeGoldenServerFrames(t *testing.T) { } }, }, + { + name: "chat.turn carries router metrics", + json: `{"type":"chat.turn","sessionId":"s1","turnId":"t9","role":"ROUTER","content":"hi","latencyMs":3000,"totalTokens":943,"sequence":7,"sessionSequence":2}`, + wantType: TypeChatTurn, + eventBear: true, + check: func(t *testing.T, m ServerMessage) { + if m.LatencyMs == nil || *m.LatencyMs != 3000 || m.TotalTokens == nil || *m.TotalTokens != 943 { + t.Fatalf("chat.turn metrics: %+v", m) + } + }, + }, { name: "snapshot_complete (non-event control frame)", json: `{"type":"snapshot_complete"}`, diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 35f5da9c..03d899b7 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -87,6 +87,10 @@ type ServerMessage struct { Models []string `json:"models"` Current string `json:"current"` + // chat.turn router metrics (nil when not a ROUTER turn or server omits them) + LatencyMs *int64 `json:"latencyMs"` + TotalTokens *int `json:"totalTokens"` + // resource.status (nil = unavailable) GpuMemoryUsedMb *int64 `json:"gpuMemoryUsedMb"` GpuMemoryTotalMb *int64 `json:"gpuMemoryTotalMb"`