diff --git a/internal/viewer/server.go b/internal/viewer/server.go index 3803860..3452e52 100644 --- a/internal/viewer/server.go +++ b/internal/viewer/server.go @@ -6,6 +6,7 @@ import ( "html/template" "io/fs" "net/http" + "strconv" "strings" "time" ) @@ -79,6 +80,7 @@ func parseTemplate(name string) (*template.Template, error) { "formatDuration": formatDuration, "formatTime": formatTime, "truncate": truncateText, + "formatNumber": formatNumber, "add": func(a, b int) int { return a + b }, "cardCount": func(tasks map[TaskType][]*TaskCard) int { n := 0 @@ -164,6 +166,51 @@ func staticFS() fs.FS { return sub } +func formatNumber(n int) string { + var display string + switch { + case n >= 1_000_000: + if n%1_000_000 == 0 { + display = fmt.Sprintf("%dM", n/1_000_000) + } else { + display = trimFloatSuffix(fmt.Sprintf("%.2fM", float64(n)/1_000_000)) + } + case n >= 1_000: + if n%1_000 == 0 { + display = fmt.Sprintf("%dK", n/1_000) + } else { + display = trimFloatSuffix(fmt.Sprintf("%.2fK", float64(n)/1_000)) + } + default: + display = strconv.Itoa(n) + } + return display +} + +// trimFloatSuffix removes trailing zeros and the trailing dot from a +// floating-point string like "1.10K" → "1.1K", "1.00K" → "1K". +func trimFloatSuffix(s string) string { + // Find the dot position before the suffix (K/M). + // Input is always "%d.%dX" or "%dX". + dot := strings.LastIndexByte(s, '.') + if dot < 0 { + return s + } + // Find the suffix letter (K or M) — it's always the last character. + suffix := s[len(s)-1] + mantissa := s[:len(s)-1] // strip suffix + + // Trim trailing zeros from the fractional part. + i := len(mantissa) - 1 + for i >= 0 && mantissa[i] == '0' { + i-- + } + if i >= 0 && mantissa[i] == '.' { + i-- // also trim the dot if whole fractional part was zeros + } + return mantissa[:i+1] + string(suffix) +} + func formatDuration(seconds float64) string { d := time.Duration(seconds * float64(time.Second)) if d < time.Minute { diff --git a/internal/viewer/server_test.go b/internal/viewer/server_test.go index 2c3a833..a2691fa 100644 --- a/internal/viewer/server_test.go +++ b/internal/viewer/server_test.go @@ -30,6 +30,42 @@ func TestTruncateText(t *testing.T) { } } +func TestFormatNumber(t *testing.T) { + tests := []struct { + name string + n int + want string + }{ + {"zero", 0, "0"}, + {"below 1K", 999, "999"}, + {"exactly 1K", 1000, "1K"}, + {"just above 1K rounds to 1K", 1001, "1K"}, + {"1.1K", 1100, "1.1K"}, + {"1.23K", 1234, "1.23K"}, + {"9.99K", 9995, "9.99K"}, + {"exactly 10K", 10000, "10K"}, + {"exactly 100K", 100000, "100K"}, + {"just above 100K rounds to 100K", 100001, "100K"}, + {"999.5K", 999500, "999.5K"}, + {"999999 just below 1M", 999999, "1000K"}, + {"exactly 1M", 1000000, "1M"}, + {"just above 1M rounds to 1M", 1000001, "1M"}, + {"1.1M", 1100000, "1.1M"}, + {"1.23M", 1234567, "1.23M"}, + {"1.5M", 1500000, "1.5M"}, + {"exactly 10M", 10000000, "10M"}, + {"12.35M", 12345678, "12.35M"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatNumber(tt.n) + if got != tt.want { + t.Errorf("formatNumber(%d) = %q, want %q", tt.n, got, tt.want) + } + }) + } +} + func TestFormatDuration(t *testing.T) { tests := []struct { name string diff --git a/internal/viewer/templates/session.html b/internal/viewer/templates/session.html index e2362d5..4735e11 100644 --- a/internal/viewer/templates/session.html +++ b/internal/viewer/templates/session.html @@ -32,28 +32,28 @@

Token Usage

-
{{.Session.TokenUsage.TotalPromptTokens}}
+
{{formatNumber .Session.TokenUsage.TotalPromptTokens}}
Prompt Tokens
-
{{.Session.TokenUsage.TotalCompletionTokens}}
+
{{formatNumber .Session.TokenUsage.TotalCompletionTokens}}
Completion Tokens
-
{{add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens}}
+
{{formatNumber (add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens)}}
Total Tokens
-
{{.Session.TokenUsage.RequestCount}}
+
{{formatNumber .Session.TokenUsage.RequestCount}}
LLM Requests
{{if or .Session.TokenUsage.TotalCacheReadTokens .Session.TokenUsage.TotalCacheWriteTokens}}
-
{{.Session.TokenUsage.TotalCacheReadTokens}}
+
{{formatNumber .Session.TokenUsage.TotalCacheReadTokens}}
Cache Read
-
{{.Session.TokenUsage.TotalCacheWriteTokens}}
+
{{formatNumber .Session.TokenUsage.TotalCacheWriteTokens}}
Cache Write
{{end}} @@ -71,10 +71,10 @@ {{range .}} {{.FilePath | truncate 60}} - {{.PromptTokens}} - {{.CompletionTokens}} - {{if or $.Session.TokenUsage.TotalCacheReadTokens $.Session.TokenUsage.TotalCacheWriteTokens}}{{.CacheReadTokens}}{{.CacheWriteTokens}}{{end}} - {{add .PromptTokens .CompletionTokens}} + {{formatNumber .PromptTokens}} + {{formatNumber .CompletionTokens}} + {{if or $.Session.TokenUsage.TotalCacheReadTokens $.Session.TokenUsage.TotalCacheWriteTokens}}{{formatNumber .CacheReadTokens}}{{formatNumber .CacheWriteTokens}}{{end}} + {{formatNumber (add .PromptTokens .CompletionTokens)}} {{end}} @@ -112,7 +112,7 @@
Request #{{.RequestNo}} {{if .Model}}{{.Model}}{{end}} - {{if or .PromptTokens .CompletionTokens}}P:{{.PromptTokens}} C:{{.CompletionTokens}}{{if or .CacheReadTokens .CacheWriteTokens}} CR:{{.CacheReadTokens}} CW:{{.CacheWriteTokens}}{{end}}{{end}} + {{if or .PromptTokens .CompletionTokens}}P:{{formatNumber .PromptTokens}} C:{{formatNumber .CompletionTokens}}{{if or .CacheReadTokens .CacheWriteTokens}} CR:{{formatNumber .CacheReadTokens}} CW:{{formatNumber .CacheWriteTokens}}{{end}}{{end}} {{if .DurationMs}}{{.DurationMs}}ms{{end}} {{if .Error}}{{.Error}}{{end}}