Skip to content

Commit d656a48

Browse files
committed
feat: improve Stats tab with view cycling, Y-axis labels, and help bar
- Add `f` key to cycle breakdown view (project → model → both) - Add Y-axis cost labels to daily trend chart (top, mid, $0) - Add help footer showing available keybindings - Add terminal_other.go stub for freebsd/other platforms (fixes release) Closes #29
1 parent b24373c commit d656a48

3 files changed

Lines changed: 86 additions & 36 deletions

File tree

internal/session/terminal_other.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//go:build !darwin && !linux && !windows
2+
3+
package session
4+
5+
import (
6+
"fmt"
7+
8+
"codes/internal/config"
9+
)
10+
11+
func openInTerminal(sessionID, dir string, args []string, env map[string]string, terminal string) (int, error) {
12+
return 0, fmt.Errorf("terminal sessions not supported on this platform")
13+
}
14+
15+
func focusTerminalWindow(terminal string) {}
16+
17+
func openRemoteInTerminal(sessionID string, host *config.RemoteHost, project string, terminal string) (int, error) {
18+
return 0, fmt.Errorf("terminal sessions not supported on this platform")
19+
}

internal/tui/model.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,11 @@ type Model struct {
6969
version string // 当前版本
7070
latestVersion string // 缓存的最新版本(空 = 未知或已是最新)
7171
// Stats tab
72-
statsDaily []stats.DailyStat
73-
statsRecords []stats.SessionRecord
74-
statsRange string // "week" | "month" | "all"
75-
statsLoading bool
72+
statsDaily []stats.DailyStat
73+
statsRecords []stats.SessionRecord
74+
statsRange string // "week" | "month" | "all"
75+
statsBreakdown string // "both" | "project" | "model"
76+
statsLoading bool
7677
// Task Queue
7778
taskQueueTeams []string
7879
taskQueueTasks []agent.Task
@@ -1165,7 +1166,7 @@ func (m Model) View() string {
11651166
} else if m.state == viewStats {
11661167
// Stats uses full width, no left/right split
11671168
contentHeight := m.height - 7
1168-
b.WriteString(renderStatsView(m.statsDaily, m.statsRecords, m.statsRange, m.statsLoading, innerWidth, contentHeight))
1169+
b.WriteString(renderStatsView(m.statsDaily, m.statsRecords, m.statsRange, m.statsBreakdown, m.statsLoading, innerWidth, contentHeight))
11691170
} else if m.state == viewTaskQueue {
11701171
// Task Queue uses full width
11711172
contentHeight := m.height - 7

internal/tui/stats_view.go

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ func (m Model) updateStats(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
8585
m.statsRange = "all"
8686
m.statsLoading = true
8787
return m, loadStatsCmd("all")
88+
case "f":
89+
switch m.statsBreakdown {
90+
case "", "both":
91+
m.statsBreakdown = "project"
92+
case "project":
93+
m.statsBreakdown = "model"
94+
case "model":
95+
m.statsBreakdown = "both"
96+
}
97+
return m, nil
8898
case "r":
8999
m.statsLoading = true
90100
return m, func() tea.Msg {
@@ -108,7 +118,7 @@ func (m Model) updateStats(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
108118
}
109119

110120
// renderStatsView renders the full stats panel.
111-
func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, timeRange string, loading bool, width, height int) string {
121+
func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, timeRange, breakdown string, loading bool, width, height int) string {
112122
if loading {
113123
return lipgloss.NewStyle().
114124
Width(width).
@@ -117,6 +127,10 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim
117127
Render(statsDimStyle.Render("Loading stats..."))
118128
}
119129

130+
if breakdown == "" {
131+
breakdown = "both"
132+
}
133+
120134
var b strings.Builder
121135

122136
// Title with time range
@@ -149,30 +163,31 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim
149163
b.WriteString(statsDimStyle.Render(tokens))
150164
b.WriteString("\n\n")
151165

166+
barWidth := min(width-40, 30)
167+
if barWidth < 10 {
168+
barWidth = 10
169+
}
170+
152171
// By Project breakdown
153-
projectCosts := aggregateByProject(daily)
154-
if len(projectCosts) > 0 {
155-
b.WriteString(statsHeaderStyle.Render(" By Project:"))
156-
b.WriteString("\n")
157-
barWidth := min(width-40, 30)
158-
if barWidth < 10 {
159-
barWidth = 10
172+
if breakdown == "both" || breakdown == "project" {
173+
projectCosts := aggregateByProject(daily)
174+
if len(projectCosts) > 0 {
175+
b.WriteString(statsHeaderStyle.Render(" By Project:"))
176+
b.WriteString("\n")
177+
renderBreakdown(&b, projectCosts, totalCost, barWidth, 8)
178+
b.WriteString("\n")
160179
}
161-
renderBreakdown(&b, projectCosts, totalCost, barWidth, 8)
162-
b.WriteString("\n")
163180
}
164181

165182
// By Model breakdown
166-
modelCosts := aggregateByModel(daily)
167-
if len(modelCosts) > 0 {
168-
b.WriteString(statsHeaderStyle.Render(" By Model:"))
169-
b.WriteString("\n")
170-
barWidth := min(width-40, 30)
171-
if barWidth < 10 {
172-
barWidth = 10
183+
if breakdown == "both" || breakdown == "model" {
184+
modelCosts := aggregateByModel(daily)
185+
if len(modelCosts) > 0 {
186+
b.WriteString(statsHeaderStyle.Render(" By Model:"))
187+
b.WriteString("\n")
188+
renderBreakdown(&b, modelCosts, totalCost, barWidth, 8)
189+
b.WriteString("\n")
173190
}
174-
renderBreakdown(&b, modelCosts, totalCost, barWidth, 8)
175-
b.WriteString("\n")
176191
}
177192

178193
// Daily trend chart
@@ -182,6 +197,12 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim
182197
renderDailyChart(&b, daily, width-6, 6)
183198
}
184199

200+
// Help footer
201+
b.WriteString("\n")
202+
breakdownLabel := map[string]string{"both": "all", "project": "project", "model": "model"}[breakdown]
203+
help := fmt.Sprintf(" w:week m:month a:all f:group(%s) r:refresh", breakdownLabel)
204+
b.WriteString(statsDimStyle.Render(help))
205+
185206
return b.String()
186207
}
187208

@@ -295,18 +316,30 @@ func renderDailyChart(b *strings.Builder, daily []stats.DailyStat, width, maxHei
295316
}
296317

297318
// Calculate bar width
298-
barSpacing := 2 // each bar + gap
299-
maxBars := (width - 4) / barSpacing
319+
yLabelWidth := len(formatCost(maxCost)) + 1 // e.g. "$12.34 "
320+
barSpacing := 2 // each bar + gap
321+
maxBars := (width - yLabelWidth - 2) / barSpacing
300322
startIdx := 0
301323
if len(daily) > maxBars {
302324
startIdx = len(daily) - maxBars
303325
}
304326
visibleDays := daily[startIdx:]
305327

306-
// Build vertical bars (top to bottom)
328+
// Build vertical bars (top to bottom) with Y-axis labels
307329
for row := maxHeight; row >= 1; row-- {
308330
threshold := float64(row) / float64(maxHeight)
309-
line := " "
331+
332+
// Y-axis label: show at top, middle, bottom
333+
label := strings.Repeat(" ", yLabelWidth)
334+
if row == maxHeight {
335+
label = fmt.Sprintf("%*s ", yLabelWidth-1, formatCost(maxCost))
336+
} else if row == maxHeight/2 {
337+
label = fmt.Sprintf("%*s ", yLabelWidth-1, formatCost(maxCost/2))
338+
} else if row == 1 {
339+
label = fmt.Sprintf("%*s ", yLabelWidth-1, "$0")
340+
}
341+
line := statsDimStyle.Render(label)
342+
310343
for _, d := range visibleDays {
311344
ratio := d.TotalCost / maxCost
312345
if ratio >= threshold {
@@ -319,20 +352,17 @@ func renderDailyChart(b *strings.Builder, daily []stats.DailyStat, width, maxHei
319352
b.WriteString("\n")
320353
}
321354

322-
// Date labels (just show first and last)
355+
// Date labels (first, middle, last)
323356
if len(visibleDays) > 0 {
324-
first := visibleDays[0].Date
325-
last := visibleDays[len(visibleDays)-1].Date
326-
// Parse and format short
327-
firstShort := shortDate(first)
328-
lastShort := shortDate(last)
357+
first := shortDate(visibleDays[0].Date)
358+
last := shortDate(visibleDays[len(visibleDays)-1].Date)
329359

330360
labelWidth := len(visibleDays) * barSpacing
331-
gap := labelWidth - len(firstShort) - len(lastShort)
361+
gap := labelWidth - len(first) - len(last)
332362
if gap < 1 {
333363
gap = 1
334364
}
335-
b.WriteString(" " + statsDimStyle.Render(firstShort+strings.Repeat(" ", gap)+lastShort))
365+
b.WriteString(strings.Repeat(" ", yLabelWidth) + statsDimStyle.Render(first+strings.Repeat(" ", gap)+last))
336366
b.WriteString("\n")
337367
}
338368
}

0 commit comments

Comments
 (0)