diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go index 2964a6c6..f6a33ca4 100644 --- a/internal/tui/i18n.go +++ b/internal/tui/i18n.go @@ -201,6 +201,7 @@ var zhStrings = map[string]string{ "usage_output": "输出", "usage_cached": "缓存", "usage_reasoning": "思考", + "usage_time": "时间", // ── Logs ── "logs_title": "📋 日志", @@ -352,6 +353,7 @@ var enStrings = map[string]string{ "usage_output": "Output", "usage_cached": "Cached", "usage_reasoning": "Reasoning", + "usage_time": "Time", // ── Logs ── "logs_title": "📋 Logs", diff --git a/internal/tui/usage_tab.go b/internal/tui/usage_tab.go index 9e6da7f8..6b9fef5e 100644 --- a/internal/tui/usage_tab.go +++ b/internal/tui/usage_tab.go @@ -248,6 +248,9 @@ func (m usageTabModel) renderContent() string { // Token type breakdown from details sb.WriteString(m.renderTokenBreakdown(stats)) + + // Latency breakdown from details + sb.WriteString(m.renderLatencyBreakdown(stats)) } } } @@ -308,6 +311,57 @@ func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string { lipgloss.NewStyle().Foreground(colorMuted).Render(strings.Join(parts, " "))) } +// renderLatencyBreakdown aggregates latency_ms from model details and displays avg/min/max. +func (m usageTabModel) renderLatencyBreakdown(modelStats map[string]any) string { + details, ok := modelStats["details"] + if !ok { + return "" + } + detailList, ok := details.([]any) + if !ok || len(detailList) == 0 { + return "" + } + + var totalLatency int64 + var count int + var minLatency, maxLatency int64 + first := true + + for _, d := range detailList { + dm, ok := d.(map[string]any) + if !ok { + continue + } + latencyMs := int64(getFloat(dm, "latency_ms")) + if latencyMs <= 0 { + continue + } + totalLatency += latencyMs + count++ + if first { + minLatency = latencyMs + maxLatency = latencyMs + first = false + } else { + if latencyMs < minLatency { + minLatency = latencyMs + } + if latencyMs > maxLatency { + maxLatency = latencyMs + } + } + } + + if count == 0 { + return "" + } + + avgLatency := totalLatency / int64(count) + return fmt.Sprintf(" │ %s: avg %dms min %dms max %dms\n", + lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_time")), + avgLatency, minLatency, maxLatency) +} + // renderBarChart renders a simple ASCII horizontal bar chart. func renderBarChart(data map[string]any, maxBarWidth int, barColor lipgloss.Color) string { if maxBarWidth < 10 { diff --git a/internal/tui/usage_tab_test.go b/internal/tui/usage_tab_test.go new file mode 100644 index 00000000..4fffcd98 --- /dev/null +++ b/internal/tui/usage_tab_test.go @@ -0,0 +1,134 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestRenderLatencyBreakdown(t *testing.T) { + tests := []struct { + name string + modelStats map[string]any + wantEmpty bool + wantContains string + }{ + { + name: "no details", + modelStats: map[string]any{}, + wantEmpty: true, + }, + { + name: "empty details", + modelStats: map[string]any{ + "details": []any{}, + }, + wantEmpty: true, + }, + { + name: "details with zero latency", + modelStats: map[string]any{ + "details": []any{ + map[string]any{ + "latency_ms": float64(0), + }, + }, + }, + wantEmpty: true, + }, + { + name: "single request with latency", + modelStats: map[string]any{ + "details": []any{ + map[string]any{ + "latency_ms": float64(1500), + }, + }, + }, + wantEmpty: false, + wantContains: "avg 1500ms min 1500ms max 1500ms", + }, + { + name: "multiple requests with varying latency", + modelStats: map[string]any{ + "details": []any{ + map[string]any{ + "latency_ms": float64(100), + }, + map[string]any{ + "latency_ms": float64(200), + }, + map[string]any{ + "latency_ms": float64(300), + }, + }, + }, + wantEmpty: false, + wantContains: "avg 200ms min 100ms max 300ms", + }, + { + name: "mixed valid and invalid latency values", + modelStats: map[string]any{ + "details": []any{ + map[string]any{ + "latency_ms": float64(500), + }, + map[string]any{ + "latency_ms": float64(0), + }, + map[string]any{ + "latency_ms": float64(1500), + }, + }, + }, + wantEmpty: false, + wantContains: "avg 1000ms min 500ms max 1500ms", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := usageTabModel{} + result := m.renderLatencyBreakdown(tt.modelStats) + + if tt.wantEmpty { + if result != "" { + t.Errorf("renderLatencyBreakdown() = %q, want empty string", result) + } + return + } + + if result == "" { + t.Errorf("renderLatencyBreakdown() = empty, want non-empty string") + return + } + + if tt.wantContains != "" && !strings.Contains(result, tt.wantContains) { + t.Errorf("renderLatencyBreakdown() = %q, want to contain %q", result, tt.wantContains) + } + }) + } +} + +func TestUsageTimeTranslations(t *testing.T) { + prevLocale := CurrentLocale() + t.Cleanup(func() { + SetLocale(prevLocale) + }) + + tests := []struct { + locale string + want string + }{ + {locale: "en", want: "Time"}, + {locale: "zh", want: "时间"}, + } + + for _, tt := range tests { + t.Run(tt.locale, func(t *testing.T) { + SetLocale(tt.locale) + if got := T("usage_time"); got != tt.want { + t.Fatalf("T(usage_time) = %q, want %q", got, tt.want) + } + }) + } +}