From 7bb1b134f3ba91809072e496a3cd35f5dcf8d60b Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Wed, 15 Jul 2026 23:00:17 +0200 Subject: [PATCH 1/2] feat(tui): improve session cost details Signed-off-by: Djordje Lukic --- pkg/tui/dialog/cost.go | 95 +++++++++++++++++++++++++------------ pkg/tui/dialog/cost_test.go | 41 ++++++++++++++++ 2 files changed, 105 insertions(+), 31 deletions(-) diff --git a/pkg/tui/dialog/cost.go b/pkg/tui/dialog/cost.go index 6bb96d1084..e007f41044 100644 --- a/pkg/tui/dialog/cost.go +++ b/pkg/tui/dialog/cost.go @@ -127,7 +127,7 @@ func (u *totalUsage) isSubSessionMarker() bool { return u.marker } // plainTextLine returns a fixed-width plain-text representation used by the // clipboard-copy output. An optional suffix (e.g. model name) is appended. func (u *totalUsage) plainTextLine(suffix string) string { - line := fmt.Sprintf("%-8s input: %-8s output: %-8s %s", + line := fmt.Sprintf("%-8s in: %-8s out: %-8s %s", formatCostPadded(u.cost), formatTokenCount(u.totalInput()), formatTokenCount(u.OutputTokens), @@ -183,12 +183,12 @@ func (d *costData) totalStats() []stat { if tok := d.total.totalTokens(); tok > 0 && d.total.cost > 0 { stats = append(stats, stat{"avg cost/1K tokens:", formatCost(d.total.cost / float64(tok) * 1000)}) } - if candidateIn := d.total.CachedInputTokens + d.total.InputTokens; candidateIn > 0 && d.total.CachedInputTokens > 0 { - stats = append(stats, stat{"cache hit rate:", fmt.Sprintf("%.0f%%", float64(d.total.CachedInputTokens)/float64(candidateIn)*100)}) - } if actual := d.actualMessageCount(); actual > 1 && d.total.cost > 0 { stats = append(stats, stat{"avg cost/message:", formatCost(d.total.cost / float64(actual))}) } + if candidateIn := d.total.CachedInputTokens + d.total.InputTokens; candidateIn > 0 && d.total.CachedInputTokens > 0 { + stats = append(stats, stat{"cache hit rate:", fmt.Sprintf("%.0f%%", float64(d.total.CachedInputTokens)/float64(candidateIn)*100)}) + } return stats } @@ -314,20 +314,24 @@ func (d *costDialog) renderContent(contentWidth, maxHeight int) string { sectionStyle().Render("Total"), "", accentStyle().Render(formatCost(data.total.cost)), - styledStat("tokens:", formatTokenCount(data.total.totalTokens())), - d.renderInputLine(data.total, true), - styledStat("output:", formatTokenCount(data.total.OutputTokens)), } - for _, s := range data.totalStats() { - lines = append(lines, styledStat(s.label, s.value)) + totalStats := append([]stat{ + {label: "tokens:", value: formatTokenCount(data.total.totalTokens())}, + {label: "in:", value: inputValue(data.total, true)}, + {label: "out:", value: formatTokenCount(data.total.OutputTokens)}, + }, data.totalStats()...) + labelWidth := statLabelWidth(totalStats) + for _, s := range totalStats { + lines = append(lines, styledStat(s, labelWidth)) } lines = append(lines, "") // By Model if len(data.models) > 0 { lines = append(lines, sectionStyle().Render("By Model"), "") + labelWidth := usageLabelWidth(data.models) for _, m := range data.models { - lines = append(lines, d.renderUsageLine(m, data.total.cost)) + lines = append(lines, d.renderUsageLine(m, data.total.cost, labelWidth, false)) } lines = append(lines, "") } @@ -335,11 +339,12 @@ func (d *costDialog) renderContent(contentWidth, maxHeight int) string { // By Message if len(data.messages) > 0 { lines = append(lines, sectionStyle().Render("By Message"), "") + labelWidth := usageLabelWidth(data.messages) for _, m := range data.messages { if m.isSubSessionMarker() { lines = append(lines, styles.MutedStyle.Render(m.label)) } else { - lines = append(lines, d.renderUsageLine(m, data.total.cost)) + lines = append(lines, d.renderUsageLine(m, data.total.cost, labelWidth, true)) } } lines = append(lines, "") @@ -365,31 +370,56 @@ func (d *costDialog) headerMeta(data costData) string { return strings.Join(parts, " • ") } -// styledStat renders a single "label: value" line for the Total section. -func styledStat(label, value string) string { - return fmt.Sprintf("%s %s", labelStyle().Render(label), valueStyle().Render(value)) +func statLabelWidth(stats []stat) int { + width := 0 + for _, s := range stats { + width = max(width, lipgloss.Width(s.label)) + } + return width } -func (d *costDialog) renderInputLine(u totalUsage, showBreakdown bool) string { - line := styledStat("input:", formatTokenCount(u.totalInput())) +func styledStat(s stat, labelWidth int) string { + return fmt.Sprintf("%s %s", labelStyle().Render(padToWidth(s.label, labelWidth)), valueStyle().Render(s.value)) +} + +func inputValue(u totalUsage, showBreakdown bool) string { + value := formatTokenCount(u.totalInput()) if showBreakdown && (u.CachedInputTokens > 0 || u.CacheWriteTokens > 0) { - line += valueStyle().Render(fmt.Sprintf(" (%s new + %s cached + %s cache write)", + value += fmt.Sprintf(" (%s new + %s cached + %s cache write)", formatTokenCount(u.InputTokens), formatTokenCount(u.CachedInputTokens), - formatTokenCount(u.CacheWriteTokens))) + formatTokenCount(u.CacheWriteTokens)) } - return line + return value } -func (d *costDialog) renderUsageLine(u totalUsage, totalCost float64) string { - var extras []string +func usageLabelWidth(usages []totalUsage) int { + width := 0 + for _, usage := range usages { + if !usage.isSubSessionMarker() { + width = max(width, lipgloss.Width(usage.label)) + } + } + return width +} + +func (d *costDialog) renderUsageLine(u totalUsage, totalCost float64, labelWidth int, showCacheMiss bool) string { + percentage := "" if totalCost > 0 && u.cost > 0 { if pct := u.cost / totalCost * 100; pct >= 0.1 { - extras = append(extras, fmt.Sprintf("%.0f%%", pct)) + percentage = fmt.Sprintf("%.0f%%", pct) } } + + showCacheStatus := u.CachedInputTokens > 0 || (showCacheMiss && u.model != "") + var extras []string + if percentage != "" || (totalCost > 0 && showCacheStatus) { + extras = append(extras, fmt.Sprintf("%-4s", percentage)) + } if u.CachedInputTokens > 0 { extras = append(extras, "cached: "+formatTokenCount(u.CachedInputTokens)) + } else if showCacheStatus { + extras = append(extras, "cache miss") } suffix := "" @@ -398,11 +428,11 @@ func (d *costDialog) renderUsageLine(u totalUsage, totalCost float64) string { } return fmt.Sprintf("%s %s %s %s %s %s%s", accentStyle().Render(padRight(formatCostPadded(u.cost))), - labelStyle().Render("input:"), + labelStyle().Render("in:"), valueStyle().Render(padRight(formatTokenCount(u.totalInput()))), - labelStyle().Render("output:"), + labelStyle().Render("out:"), valueStyle().Render(padRight(formatTokenCount(u.OutputTokens))), - accentStyle().Render(u.label), + accentStyle().Render(padToWidth(u.label, labelWidth)), suffix) } @@ -435,7 +465,7 @@ func (d *costDialog) renderPlainText() string { var lines []string // Total section - inputLine := "input: " + formatTokenCount(data.total.totalInput()) + inputLine := "in: " + formatTokenCount(data.total.totalInput()) if data.total.CachedInputTokens > 0 || data.total.CacheWriteTokens > 0 { inputLine += fmt.Sprintf(" (%s new + %s cached + %s cache write)", formatTokenCount(data.total.InputTokens), @@ -445,7 +475,7 @@ func (d *costDialog) renderPlainText() string { lines = append(lines, "Session Cost Details", "", "Total", formatCost(data.total.cost), "tokens: "+formatTokenCount(data.total.totalTokens()), - inputLine, "output: "+formatTokenCount(data.total.OutputTokens)) + inputLine, "out: "+formatTokenCount(data.total.OutputTokens)) for _, s := range data.totalStats() { lines = append(lines, s.label+" "+s.value) @@ -560,9 +590,12 @@ func formatDuration(d time.Duration) string { } func padRight(s string) string { - const width = 8 - if len(s) >= width { - return s + return padToWidth(s, 8) +} + +func padToWidth(s string, width int) string { + if currentWidth := lipgloss.Width(s); currentWidth < width { + return s + strings.Repeat(" ", width-currentWidth) } - return s + strings.Repeat(" ", width-len(s)) + return s } diff --git a/pkg/tui/dialog/cost_test.go b/pkg/tui/dialog/cost_test.go index 064582b741..0f2ad019f0 100644 --- a/pkg/tui/dialog/cost_test.go +++ b/pkg/tui/dialog/cost_test.go @@ -1,6 +1,7 @@ package dialog import ( + "strings" "sync" "testing" "time" @@ -194,6 +195,46 @@ func TestCostDialogModelPercentage(t *testing.T) { assert.Contains(t, view, "25%") } +func TestCostDialogCachedCountAlignedAfterPercentage(t *testing.T) { + t.Parallel() + + dialog := &costDialog{} + usage := chat.Usage{InputTokens: 100, CachedInputTokens: 100} + rows := []totalUsage{ + {Usage: usage, label: "short", cost: 0.04}, + {Usage: usage, label: "long-model-name", cost: 0.14}, + } + labelWidth := usageLabelWidth(rows) + singleDigit := dialog.renderUsageLine(rows[0], 1, labelWidth, false) + doubleDigit := dialog.renderUsageLine(rows[1], 1, labelWidth, false) + + assert.Equal(t, strings.Index(singleDigit, "cached:"), strings.Index(doubleDigit, "cached:")) +} + +func TestCostDialogTotalStatsAligned(t *testing.T) { + t.Parallel() + + stats := []stat{{label: "in:", value: "100"}, {label: "avg cost/message:", value: "$0.01"}} + labelWidth := statLabelWidth(stats) + shortLabel := styledStat(stats[0], labelWidth) + longLabel := styledStat(stats[1], labelWidth) + + assert.Equal(t, strings.Index(shortLabel, "100"), strings.Index(longLabel, "$0.01")) +} + +func TestCostDialogMessageCacheMiss(t *testing.T) { + t.Parallel() + + line := (&costDialog{}).renderUsageLine(totalUsage{ + Usage: chat.Usage{InputTokens: 100}, + label: "#1", + model: "gpt-4o", + cost: 0.01, + }, 0.01, 2, true) + + assert.Contains(t, line, "cache miss") +} + func TestCostDialogCacheHitRate(t *testing.T) { t.Parallel() From d4a4e3a7a34de4666420b3a5ce6e9ac2a798eeea Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Wed, 15 Jul 2026 23:08:40 +0200 Subject: [PATCH 2/2] feat(tui): highlight session cost breakdown Signed-off-by: Djordje Lukic --- pkg/tui/dialog/cost.go | 44 +++++++++++++++++++++++++++++-------- pkg/tui/dialog/cost_test.go | 34 ++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/pkg/tui/dialog/cost.go b/pkg/tui/dialog/cost.go index e007f41044..140d041b68 100644 --- a/pkg/tui/dialog/cost.go +++ b/pkg/tui/dialog/cost.go @@ -311,9 +311,8 @@ func (d *costDialog) renderContent(contentWidth, maxHeight int) string { header, RenderSeparator(contentWidth), "", - sectionStyle().Render("Total"), + sectionStyle().Render("Total") + " " + accentStyle().Render(formatCost(data.total.cost)), "", - accentStyle().Render(formatCost(data.total.cost)), } totalStats := append([]stat{ {label: "tokens:", value: formatTokenCount(data.total.totalTokens())}, @@ -403,28 +402,34 @@ func usageLabelWidth(usages []totalUsage) int { return width } -func (d *costDialog) renderUsageLine(u totalUsage, totalCost float64, labelWidth int, showCacheMiss bool) string { +func (d *costDialog) renderUsageLine(u totalUsage, totalCost float64, labelWidth int, byMessage bool) string { percentage := "" + percentageValue := 0.0 if totalCost > 0 && u.cost > 0 { if pct := u.cost / totalCost * 100; pct >= 0.1 { percentage = fmt.Sprintf("%.0f%%", pct) + percentageValue = pct } } - showCacheStatus := u.CachedInputTokens > 0 || (showCacheMiss && u.model != "") + showCacheStatus := u.CachedInputTokens > 0 || (byMessage && u.model != "") var extras []string if percentage != "" || (totalCost > 0 && showCacheStatus) { - extras = append(extras, fmt.Sprintf("%-4s", percentage)) + percentageStyle := valueStyle() + if byMessage && percentage != "" { + percentageStyle = costPercentageStyle(percentageValue) + } + extras = append(extras, percentageStyle.Render(fmt.Sprintf("%-4s", percentage))) } if u.CachedInputTokens > 0 { - extras = append(extras, "cached: "+formatTokenCount(u.CachedInputTokens)) + extras = append(extras, valueStyle().Render("cached: "+formatTokenCount(u.CachedInputTokens))) } else if showCacheStatus { - extras = append(extras, "cache miss") + extras = append(extras, styles.WarningStyle.Render("cache miss")) } suffix := "" if len(extras) > 0 { - suffix = " " + valueStyle().Render(strings.Join(extras, " ")) + suffix = " " + strings.Join(extras, " ") } return fmt.Sprintf("%s %s %s %s %s %s%s", accentStyle().Render(padRight(formatCostPadded(u.cost))), @@ -473,7 +478,7 @@ func (d *costDialog) renderPlainText() string { formatTokenCount(data.total.CacheWriteTokens)) } - lines = append(lines, "Session Cost Details", "", "Total", formatCost(data.total.cost), + lines = append(lines, "Session Cost Details", "", "Total "+formatCost(data.total.cost), "", "tokens: "+formatTokenCount(data.total.totalTokens()), inputLine, "out: "+formatTokenCount(data.total.OutputTokens)) @@ -528,6 +533,27 @@ func valueStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextSecondary) } +func costPercentageStyle(percentage float64) lipgloss.Style { + const warningAt = 0.35 + + ratio := min(max(percentage/100, 0), 1) + from, to := styles.TextSecondary, styles.Warning + if ratio >= warningAt { + from, to = styles.Warning, styles.Error + ratio = (ratio - warningAt) / (1 - warningAt) + } else { + ratio /= warningAt + } + + fromR, fromG, fromB := styles.ColorToRGB(from) + toR, toG, toB := styles.ColorToRGB(to) + return lipgloss.NewStyle().Foreground(styles.RGBToColor( + fromR+(toR-fromR)*ratio, + fromG+(toG-fromG)*ratio, + fromB+(toB-fromB)*ratio, + )) +} + func accentStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Highlight) } diff --git a/pkg/tui/dialog/cost_test.go b/pkg/tui/dialog/cost_test.go index 0f2ad019f0..2d76ed3be5 100644 --- a/pkg/tui/dialog/cost_test.go +++ b/pkg/tui/dialog/cost_test.go @@ -1,17 +1,20 @@ package dialog import ( + "image/color" "strings" "sync" "testing" "time" + "charm.land/lipgloss/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tui/styles" ) func TestNewCostDialog(t *testing.T) { @@ -208,7 +211,11 @@ func TestCostDialogCachedCountAlignedAfterPercentage(t *testing.T) { singleDigit := dialog.renderUsageLine(rows[0], 1, labelWidth, false) doubleDigit := dialog.renderUsageLine(rows[1], 1, labelWidth, false) - assert.Equal(t, strings.Index(singleDigit, "cached:"), strings.Index(doubleDigit, "cached:")) + singleDigitPrefix, _, singleDigitFound := strings.Cut(singleDigit, "cached:") + doubleDigitPrefix, _, doubleDigitFound := strings.Cut(doubleDigit, "cached:") + require.True(t, singleDigitFound) + require.True(t, doubleDigitFound) + assert.Equal(t, lipgloss.Width(singleDigitPrefix), lipgloss.Width(doubleDigitPrefix)) } func TestCostDialogTotalStatsAligned(t *testing.T) { @@ -232,7 +239,30 @@ func TestCostDialogMessageCacheMiss(t *testing.T) { cost: 0.01, }, 0.01, 2, true) - assert.Contains(t, line, "cache miss") + assert.Contains(t, line, styles.WarningStyle.Render("cache miss")) +} + +func TestCostPercentageStyle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + percentage float64 + want color.Color + }{ + {name: "neutral", percentage: 0, want: styles.TextSecondary}, + {name: "warning", percentage: 35, want: styles.Warning}, + {name: "error", percentage: 100, want: styles.Error}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotR, gotG, gotB := styles.ColorToRGB(costPercentageStyle(tt.percentage).GetForeground()) + wantR, wantG, wantB := styles.ColorToRGB(tt.want) + assert.InDelta(t, wantR, gotR, 0.01) + assert.InDelta(t, wantG, gotG, 0.01) + assert.InDelta(t, wantB, gotB, 0.01) + }) + } } func TestCostDialogCacheHitRate(t *testing.T) {