feat(tui): show router inference metrics beside router lines
This commit is contained in:
@@ -37,9 +37,9 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
m.sessionEntered = true
|
m.sessionEntered = true
|
||||||
m.routerConnected = true
|
m.routerConnected = true
|
||||||
m.routerMessages["04a546aa"] = []RouterEntry{
|
m.routerMessages["04a546aa"] = []RouterEntry{
|
||||||
{"user", "write a healthcheck script for the api"},
|
{Role: "user", Content: "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."},
|
{Role: "router", Content: "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: "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 {
|
if s := m.session("04a546aa"); s != nil {
|
||||||
s.CurrentStage = "write_script"
|
s.CurrentStage = "write_script"
|
||||||
@@ -127,7 +127,7 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
m.selectedID = "04a546aa"
|
m.selectedID = "04a546aa"
|
||||||
m.sessionEntered = true
|
m.sessionEntered = true
|
||||||
m.routerMessages["04a546aa"] = []RouterEntry{
|
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
|
m.overlay = OverlayDiff
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,15 @@ const (
|
|||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// RouterEntry is one line in a session's conversation transcript.
|
||||||
type RouterEntry struct {
|
type RouterEntry struct {
|
||||||
Role string // user | router | tool
|
Role string // user | router | tool | narration | narration_llm
|
||||||
Content string
|
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.
|
// EventEntry is a row in the event stream.
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
if msg.Role == "USER" {
|
if msg.Role == "USER" {
|
||||||
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 {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.LastEventAt = nowMillis()
|
s.LastEventAt = nowMillis()
|
||||||
}
|
}
|
||||||
@@ -182,7 +186,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
|
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
|
||||||
}
|
}
|
||||||
if msg.Diff != nil && *msg.Diff != "" {
|
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:
|
case protocol.TypeToolAssessed:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -352,6 +353,9 @@ func (m Model) routerRows(w, h int) []string {
|
|||||||
for _, ln := range wrap(e.Content, w) {
|
for _, ln := range wrap(e.Content, w) {
|
||||||
rows = append(rows, t.span(ln, t.P.Fg))
|
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":
|
case "tool":
|
||||||
rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim))
|
rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim))
|
||||||
case "narration", "stage":
|
case "narration", "stage":
|
||||||
@@ -570,6 +574,14 @@ func itoa(n int) string {
|
|||||||
|
|
||||||
func itoaLen(s string) string { return itoa(len(s)) }
|
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).
|
// wrap splits s into lines no wider than w (rune-based, whitespace-greedy).
|
||||||
func wrap(s string, w int) []string {
|
func wrap(s string, w int) []string {
|
||||||
if w < 1 {
|
if w < 1 {
|
||||||
|
|||||||
@@ -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)",
|
name: "snapshot_complete (non-event control frame)",
|
||||||
json: `{"type":"snapshot_complete"}`,
|
json: `{"type":"snapshot_complete"}`,
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ type ServerMessage struct {
|
|||||||
Models []string `json:"models"`
|
Models []string `json:"models"`
|
||||||
Current string `json:"current"`
|
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)
|
// resource.status (nil = unavailable)
|
||||||
GpuMemoryUsedMb *int64 `json:"gpuMemoryUsedMb"`
|
GpuMemoryUsedMb *int64 `json:"gpuMemoryUsedMb"`
|
||||||
GpuMemoryTotalMb *int64 `json:"gpuMemoryTotalMb"`
|
GpuMemoryTotalMb *int64 `json:"gpuMemoryTotalMb"`
|
||||||
|
|||||||
Reference in New Issue
Block a user