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.
This commit is contained in:
ScarletCarpet 2026-07-03 12:01:46 +08:00 committed by GitHub
parent 022ed75682
commit 64398465d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 94 additions and 11 deletions

View file

@ -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 {

View file

@ -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

View file

@ -32,28 +32,28 @@
<h3>Token Usage</h3>
<div class="token-stats">
<div class="token-item">
<div class="token-value">{{.Session.TokenUsage.TotalPromptTokens}}</div>
<div class="token-value" title="{{.Session.TokenUsage.TotalPromptTokens}}">{{formatNumber .Session.TokenUsage.TotalPromptTokens}}</div>
<div class="token-label">Prompt Tokens</div>
</div>
<div class="token-item">
<div class="token-value">{{.Session.TokenUsage.TotalCompletionTokens}}</div>
<div class="token-value" title="{{.Session.TokenUsage.TotalCompletionTokens}}">{{formatNumber .Session.TokenUsage.TotalCompletionTokens}}</div>
<div class="token-label">Completion Tokens</div>
</div>
<div class="token-item">
<div class="token-value">{{add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens}}</div>
<div class="token-value" title="{{add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens}}">{{formatNumber (add .Session.TokenUsage.TotalPromptTokens .Session.TokenUsage.TotalCompletionTokens)}}</div>
<div class="token-label">Total Tokens</div>
</div>
<div class="token-item">
<div class="token-value">{{.Session.TokenUsage.RequestCount}}</div>
<div class="token-value" title="{{.Session.TokenUsage.RequestCount}}">{{formatNumber .Session.TokenUsage.RequestCount}}</div>
<div class="token-label">LLM Requests</div>
</div>
{{if or .Session.TokenUsage.TotalCacheReadTokens .Session.TokenUsage.TotalCacheWriteTokens}}
<div class="token-item">
<div class="token-value">{{.Session.TokenUsage.TotalCacheReadTokens}}</div>
<div class="token-value" title="{{.Session.TokenUsage.TotalCacheReadTokens}}">{{formatNumber .Session.TokenUsage.TotalCacheReadTokens}}</div>
<div class="token-label">Cache Read</div>
</div>
<div class="token-item">
<div class="token-value">{{.Session.TokenUsage.TotalCacheWriteTokens}}</div>
<div class="token-value" title="{{.Session.TokenUsage.TotalCacheWriteTokens}}">{{formatNumber .Session.TokenUsage.TotalCacheWriteTokens}}</div>
<div class="token-label">Cache Write</div>
</div>
{{end}}
@ -71,10 +71,10 @@
{{range .}}
<tr>
<td title="{{.FilePath}}">{{.FilePath | truncate 60}}</td>
<td>{{.PromptTokens}}</td>
<td>{{.CompletionTokens}}</td>
{{if or $.Session.TokenUsage.TotalCacheReadTokens $.Session.TokenUsage.TotalCacheWriteTokens}}<td>{{.CacheReadTokens}}</td><td>{{.CacheWriteTokens}}</td>{{end}}
<td><strong>{{add .PromptTokens .CompletionTokens}}</strong></td>
<td title="{{.PromptTokens}}">{{formatNumber .PromptTokens}}</td>
<td title="{{.CompletionTokens}}">{{formatNumber .CompletionTokens}}</td>
{{if or $.Session.TokenUsage.TotalCacheReadTokens $.Session.TokenUsage.TotalCacheWriteTokens}}<td title="{{.CacheReadTokens}}">{{formatNumber .CacheReadTokens}}</td><td title="{{.CacheWriteTokens}}">{{formatNumber .CacheWriteTokens}}</td>{{end}}
<td title="{{add .PromptTokens .CompletionTokens}}"><strong>{{formatNumber (add .PromptTokens .CompletionTokens)}}</strong></td>
</tr>
{{end}}
</tbody>
@ -112,7 +112,7 @@
<div class="card-header">
<span class="request-label">Request #{{.RequestNo}}</span>
{{if .Model}}<span class="badge badge-model">{{.Model}}</span>{{end}}
{{if or .PromptTokens .CompletionTokens}}<span class="badge badge-tokens">P:{{.PromptTokens}} C:{{.CompletionTokens}}{{if or .CacheReadTokens .CacheWriteTokens}} CR:{{.CacheReadTokens}} CW:{{.CacheWriteTokens}}{{end}}</span>{{end}}
{{if or .PromptTokens .CompletionTokens}}<span class="badge badge-tokens">P:<span title="{{.PromptTokens}}">{{formatNumber .PromptTokens}}</span> C:<span title="{{.CompletionTokens}}">{{formatNumber .CompletionTokens}}</span>{{if or .CacheReadTokens .CacheWriteTokens}} CR:<span title="{{.CacheReadTokens}}">{{formatNumber .CacheReadTokens}}</span> CW:<span title="{{.CacheWriteTokens}}">{{formatNumber .CacheWriteTokens}}</span>{{end}}</span>{{end}}
{{if .DurationMs}}<span class="badge badge-duration">{{.DurationMs}}ms</span>{{end}}
{{if .Error}}<span class="badge badge-error">{{.Error}}</span>{{end}}
</div>