From 64398465d42dae5824530a51a302182ec0d330d6 Mon Sep 17 00:00:00 2001 From: ScarletCarpet <46567025+ScarletCarpet@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:01:46 +0800 Subject: [PATCH] feat(viewer): make human readable token usage for session (#278) * < 1,000: show the raw number (e.g. 842) * >= 1,000: show as K (e.g. 1.22K) * >= 1,000,000: show as M (e.g. 1.21M) hover shows exact number. --- internal/viewer/server.go | 47 ++++++++++++++++++++++++++ internal/viewer/server_test.go | 36 ++++++++++++++++++++ internal/viewer/templates/session.html | 22 ++++++------ 3 files changed, 94 insertions(+), 11 deletions(-) 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 @@