Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 95 additions & 36 deletions pkg/tui/dialog/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -311,35 +311,39 @@ 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)),
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, "")
}

// 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, "")
Expand All @@ -365,44 +369,75 @@ 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 styledStat(s stat, labelWidth int) string {
return fmt.Sprintf("%s %s", labelStyle().Render(padToWidth(s.label, labelWidth)), valueStyle().Render(s.value))
}

func (d *costDialog) renderInputLine(u totalUsage, showBreakdown bool) string {
line := styledStat("input:", formatTokenCount(u.totalInput()))
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, byMessage bool) string {
percentage := ""
percentageValue := 0.0
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)
percentageValue = pct
}
}

showCacheStatus := u.CachedInputTokens > 0 || (byMessage && u.model != "")
var extras []string
if percentage != "" || (totalCost > 0 && showCacheStatus) {
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, 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))),
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)
}

Expand Down Expand Up @@ -435,17 +470,17 @@ 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),
formatTokenCount(data.total.CachedInputTokens),
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, "output: "+formatTokenCount(data.total.OutputTokens))
inputLine, "out: "+formatTokenCount(data.total.OutputTokens))

for _, s := range data.totalStats() {
lines = append(lines, s.label+" "+s.value)
Expand Down Expand Up @@ -498,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)
}
Expand Down Expand Up @@ -560,9 +616,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
}
71 changes: 71 additions & 0 deletions pkg/tui/dialog/cost_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +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) {
Expand Down Expand Up @@ -194,6 +198,73 @@ 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)

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) {
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, 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) {
t.Parallel()

Expand Down
Loading