diff --git a/README.md b/README.md index d6c58ce..575970a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tokenhawk -Tokenhawk is a local, live token-usage monitor for Claude Code, Codex, Gemini CLI, Pi, and OpenCode. It indexes session metadata from the tools' normal home-directory stores and presents active sessions, history, per-model usage, and cost estimates in a Bubble Tea terminal UI. +Tokenhawk is a local, live token-usage monitor for Claude Code, Codex, Gemini CLI, Pi, and OpenCode. It indexes session metadata from the tools' normal home-directory stores and presents active sessions, history, per-model usage, spend since a chosen date, and cost estimates in a Bubble Tea terminal UI. Tokenhawk never stores or exports prompts, responses, tool arguments, credentials, or transcript content. @@ -29,7 +29,8 @@ _Screenshots use synthetic demo data._ - Live, local monitoring of Claude Code, Codex, Gemini CLI, Pi, and OpenCode sessions - Compact per-session status output for shell, JSON, ANSI, and tmux integrations - Native Claude Code status-line integration and tmux-backed wrappers for every supported client -- Separate Active, Inactive Sessions, and All Sessions views +- Separate Active, Inactive Sessions, All Sessions, and Spend views +- Spend totals and input-to-output ratios since any relative or absolute date, broken out by provider, model, and day - Per-session and per-model input, cached, output, reasoning, tool, and total tokens - Parent/subagent accounting with running-agent counts and detailed child usage - API-equivalent estimates and provider-reported costs with explicit status @@ -181,23 +182,54 @@ tokenhawk wrap opencode --session SESSION_ID | Key | Action | | --- | --- | | `1`, `2`, `3` | Active sessions, inactive sessions, all sessions | +| `4` | Spend since a date | | `i` | Toggle between active and inactive session lists | | `j`/`k`, arrows, page keys | Navigate | | `p` | Cycle provider filter | | `s` | Sort by updated time, tokens, or cost | | `/` | Filter by project or model metadata | +| `t` | Cycle the spend window (spend view) | +| `d` | Type a spend window (spend view) | | `enter` | Session detail, including a provider-specific resume command | | `e` / `x` | Export the visible set as JSON / CSV | | `q` | Quit | +## Spend since a date + +Press `4` for tokens and cost across a window instead of per session. The view totals input, cached input, output, and cost for the window, then breaks the same window out by provider, by model, and by day: + +```text +SPEND · last 7 days +2026-07-13 09:41 → now • 23 of 25 sessions • counted by last session update + +TOTAL tokens 17.30M in 16.71M cached 15.99M (96%) out 238.3k i:o 70.1:1 + $20.184584 estimated (priced) + +BY PROVIDER + codex ████████████ 12 sess tokens 10.25M in 10.14M cached 9.42M out 117.5k i:o 86.3:1 $11.8310 + claude ████████···· 11 sess tokens 7.04M in 6.57M cached 6.57M out 120.8k i:o 54.4:1 $8.3536 +``` + +`t` cycles the built-in windows: last 24 hours, 7 days, 30 days, month to date, and all time. `d` accepts a typed window, and `--since` opens Tokenhawk directly on one: + +```sh +tokenhawk --since 30d +tokenhawk --since 2026-07-01 +``` + +Windows accept RFC 3339 timestamps, `YYYY-MM-DD` dates, relative offsets (`90m`, `24h`, `7d`, `2w`, `3mo`, `1y`, or any Go duration), and the keywords `today`, `yesterday`, `wtd`, `mtd`, `ytd`, and `all`. Relative windows keep rolling while Tokenhawk stays open. The provider filter and the `/` search narrow the spend view too, and `e` and `x` export exactly the sessions the window covers. + +Provider stores record one running total per session rather than a timestamped ledger, so a session's whole usage is counted on the day it was last updated. Sessions that span days or that resume after the window opens are therefore attributed to that single day, which the view states rather than implying a per-day ledger it cannot derive. + ## Headless export ```sh tokenhawk export --format json --output usage.json tokenhawk export --format csv --output usage.csv --provider codex --since 2026-07-01 +tokenhawk export --format csv --output usage.csv --since mtd ``` -Filters include `--provider`, `--model`, `--project`, `--status`, `--since`, and `--until`. Dates accept RFC 3339 or `YYYY-MM-DD`. JSON contains nested per-model and subagent usage. CSV contains tagged session/model and subagent/model rows, including costs and running status. Source paths are excluded unless `--include-source` is set. +Filters include `--provider`, `--model`, `--project`, `--status`, `--since`, and `--until`. Both dates accept every window form the spend view accepts; a bare `YYYY-MM-DD` in `--until` includes that whole day. JSON contains nested per-model and subagent usage. CSV contains tagged session/model and subagent/model rows, including costs and running status. Source paths are excluded unless `--include-source` is set. ## Configuration diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0137c73..f753491 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -22,6 +22,7 @@ import ( "github.com/polera/tokenhawk/internal/pricing" "github.com/polera/tokenhawk/internal/statusline" "github.com/polera/tokenhawk/internal/store" + "github.com/polera/tokenhawk/internal/timerange" "github.com/polera/tokenhawk/internal/tui" "github.com/polera/tokenhawk/internal/upgrade" ) @@ -118,8 +119,8 @@ func Run(args []string, version string) error { model := fs.String("model", "", "filter model") project := fs.String("project", "", "filter project") status := fs.String("status", "", "filter active or inactive") - since := fs.String("since", "", "filter updates since RFC3339 or YYYY-MM-DD") - until := fs.String("until", "", "filter updates through RFC3339 or YYYY-MM-DD") + since := fs.String("since", "", "spend window and export filter: RFC3339, YYYY-MM-DD, 7d, 3mo, today, mtd, ytd, or all") + until := fs.String("until", "", "filter updates through RFC3339, YYYY-MM-DD, or a relative offset") formatDefault, formatHelp := "json", "export format: json or csv" if command == "status" { formatDefault, formatHelp = "plain", "status format: plain, ansi, tmux, or json" @@ -211,6 +212,16 @@ func Run(args []string, version string) error { fmt.Println(line) return nil } + now := time.Now() + sinceTime, err := timerange.Parse(*since, now, false) + if err != nil { + return fmt.Errorf("--since: %w", err) + } + // A bare --until date names a whole day, so it resolves to that day's end. + untilTime, err := timerange.Parse(*until, now, true) + if err != nil { + return fmt.Errorf("--until: %w", err) + } if command == "export" { if *output == "" { return fmt.Errorf("--output is required for export") @@ -218,14 +229,6 @@ func Run(args []string, version string) error { if err = mon.Scan(ctx); err != nil { return err } - sinceTime, err := parseTime(*since, false) - if err != nil { - return fmt.Errorf("--since: %w", err) - } - untilTime, err := parseTime(*until, true) - if err != nil { - return fmt.Errorf("--until: %w", err) - } sessions, err := mon.Sessions(ctx, core.Filter{Provider: core.Provider(*provider), Model: *model, Project: *project, Status: *status, Since: sinceTime, Until: untilTime}) if err != nil { return err @@ -237,6 +240,13 @@ func Run(args []string, version string) error { return nil } m := tui.New(mon) + if *since != "" { + // A --since on the interactive command is only meaningful as a spend + // window, so open the view it describes. + if m, err = m.WithSpendWindow(*since); err != nil { + return fmt.Errorf("--since: %w", err) + } + } p := tea.NewProgram(m) go func() { _ = mon.Run(ctx, func() { p.Send(tui.RefreshMsg{}) }) }() _, err = p.Run() @@ -351,20 +361,3 @@ func effectiveStatusFormat(format string) string { func supportedProvider(provider core.Provider) bool { return provider == core.Claude || provider == core.Codex || provider == core.Gemini || provider == core.Pi || provider == core.OpenCode } - -func parseTime(v string, endOfDay bool) (time.Time, error) { - if v == "" { - return time.Time{}, nil - } - if t, err := time.Parse(time.RFC3339, v); err == nil { - return t, nil - } - t, err := time.ParseInLocation("2006-01-02", v, time.Local) - if err != nil { - return time.Time{}, fmt.Errorf("expected RFC3339 or YYYY-MM-DD") - } - if endOfDay { - t = t.Add(24*time.Hour - time.Nanosecond) - } - return t, nil -} diff --git a/internal/core/types.go b/internal/core/types.go index 1ed472a..578abfb 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -77,6 +77,10 @@ func (s Session) RunningSubagents() int { return n } +// SumUsage totals arbitrary usage rows, including rows collected across +// sessions, with the same pricing-status rules a single session uses. +func SumUsage(usage []Usage) Usage { return usageTotals(usage) } + func usageTotals(usage []Usage) Usage { var out Usage if len(usage) == 0 { diff --git a/internal/timerange/timerange.go b/internal/timerange/timerange.go new file mode 100644 index 0000000..36b5672 --- /dev/null +++ b/internal/timerange/timerange.go @@ -0,0 +1,149 @@ +// Package timerange resolves the window bounds shared by the spend view and +// the headless export filters, so a value accepted by one is accepted by both. +package timerange + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// Preset is one relative window the spend view cycles through. +type Preset struct{ Spec, Label string } + +// Presets are the built-in spend windows, in cycle order. +var Presets = []Preset{ + {"24h", "last 24 hours"}, + {"7d", "last 7 days"}, + {"30d", "last 30 days"}, + {"mtd", "month to date"}, + {"all", "all time"}, +} + +var errNotRelative = errors.New("not a relative offset") + +// Parse resolves value against now. Accepted forms are RFC 3339 timestamps, +// YYYY-MM-DD dates, relative offsets such as 90m, 12h, 7d, 2w, 3mo, 1y, or any +// Go duration, and the keywords today, yesterday, wtd, mtd, ytd, and all. An +// empty value or "all" resolves to the zero time, meaning unbounded. Day- +// granular values resolve to the last instant of that day when endOfDay is set, +// which is what an inclusive --until bound needs. +func Parse(value string, now time.Time, endOfDay bool) (time.Time, error) { + trimmed := strings.TrimSpace(value) + lower := strings.ToLower(trimmed) + switch lower { + case "", "all": + return time.Time{}, nil + case "now": + return now, nil + case "today": + return day(startOfDay(now), endOfDay), nil + case "yesterday": + return day(startOfDay(now).AddDate(0, 0, -1), endOfDay), nil + case "wtd", "week": + return startOfWeek(now), nil + case "mtd", "month": + return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()), nil + case "ytd", "year": + return time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()), nil + } + if t, err := time.Parse(time.RFC3339, trimmed); err == nil { + return t, nil + } + if t, err := time.ParseInLocation("2006-01-02", lower, now.Location()); err == nil { + return day(t, endOfDay), nil + } + if t, err := offset(lower, now); err == nil { + return t, nil + } + return time.Time{}, fmt.Errorf("expected RFC3339, YYYY-MM-DD, a relative offset such as 7d or 3mo, or today, yesterday, wtd, mtd, ytd, all") +} + +// Label names spec for display, preferring the preset wording. +func Label(spec string) string { + lower := strings.ToLower(strings.TrimSpace(spec)) + for _, p := range Presets { + if p.Spec == lower { + return p.Label + } + } + switch lower { + case "", "all": + return "all time" + case "today", "yesterday": + return lower + case "wtd", "week": + return "week to date" + case "month": + return "month to date" + case "ytd", "year": + return "year to date" + } + if _, err := offset(lower, time.Time{}); err == nil { + return "last " + lower + } + return "since " + spec +} + +// Next returns the preset following spec in cycle order. An unrecognized spec, +// such as one the user typed, cycles back to the first preset. +func Next(spec string) string { + for i, p := range Presets { + if p.Spec == strings.ToLower(strings.TrimSpace(spec)) { + return Presets[(i+1)%len(Presets)].Spec + } + } + return Presets[0].Spec +} + +func offset(v string, now time.Time) (time.Time, error) { + v = strings.TrimPrefix(v, "-") + digits := 0 + for digits < len(v) && v[digits] >= '0' && v[digits] <= '9' { + digits++ + } + if digits == 0 { + return time.Time{}, errNotRelative + } + n, err := strconv.Atoi(v[:digits]) + if err != nil { + return time.Time{}, errNotRelative + } + switch v[digits:] { + case "m", "min", "mins", "minute", "minutes": + return now.Add(-time.Duration(n) * time.Minute), nil + case "h", "hr", "hrs", "hour", "hours": + return now.Add(-time.Duration(n) * time.Hour), nil + case "d", "day", "days": + return now.AddDate(0, 0, -n), nil + case "w", "wk", "week", "weeks": + return now.AddDate(0, 0, -7*n), nil + case "mo", "mon", "month", "months": + return now.AddDate(0, -n, 0), nil + case "y", "yr", "year", "years": + return now.AddDate(-n, 0, 0), nil + } + // Compound Go durations such as 1h30m fall through the single-unit table. + if d, parseErr := time.ParseDuration(v); parseErr == nil { + return now.Add(-d), nil + } + return time.Time{}, errNotRelative +} + +func day(t time.Time, endOfDay bool) time.Time { + if endOfDay { + return t.AddDate(0, 0, 1).Add(-time.Nanosecond) + } + return t +} + +func startOfDay(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +func startOfWeek(t time.Time) time.Time { + start := startOfDay(t) + return start.AddDate(0, 0, -((int(start.Weekday()) + 6) % 7)) +} diff --git a/internal/timerange/timerange_test.go b/internal/timerange/timerange_test.go new file mode 100644 index 0000000..2919b69 --- /dev/null +++ b/internal/timerange/timerange_test.go @@ -0,0 +1,118 @@ +package timerange + +import ( + "testing" + "time" +) + +func TestParseAbsoluteRelativeAndKeywordWindows(t *testing.T) { + now := time.Date(2026, 7, 20, 14, 30, 0, 0, time.Local) + for _, tc := range []struct { + value string + want time.Time + }{ + {"2026-07-01T08:00:00Z", time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)}, + {"2026-07-01", time.Date(2026, 7, 1, 0, 0, 0, 0, time.Local)}, + {"90m", now.Add(-90 * time.Minute)}, + {"24h", now.Add(-24 * time.Hour)}, + {"7d", time.Date(2026, 7, 13, 14, 30, 0, 0, time.Local)}, + {"2w", time.Date(2026, 7, 6, 14, 30, 0, 0, time.Local)}, + {"3mo", time.Date(2026, 4, 20, 14, 30, 0, 0, time.Local)}, + {"1y", time.Date(2025, 7, 20, 14, 30, 0, 0, time.Local)}, + {"1h30m", now.Add(-90 * time.Minute)}, + {"-7d", time.Date(2026, 7, 13, 14, 30, 0, 0, time.Local)}, + {"today", time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)}, + {"yesterday", time.Date(2026, 7, 19, 0, 0, 0, 0, time.Local)}, + {"wtd", time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)}, + {"mtd", time.Date(2026, 7, 1, 0, 0, 0, 0, time.Local)}, + {"ytd", time.Date(2026, 1, 1, 0, 0, 0, 0, time.Local)}, + {"MTD", time.Date(2026, 7, 1, 0, 0, 0, 0, time.Local)}, + {"all", time.Time{}}, + {"", time.Time{}}, + } { + got, err := Parse(tc.value, now, false) + if err != nil { + t.Fatalf("Parse(%q) failed: %v", tc.value, err) + } + if !got.Equal(tc.want) { + t.Fatalf("Parse(%q) = %s, want %s", tc.value, got, tc.want) + } + } +} + +func TestParseRejectsUnknownValues(t *testing.T) { + for _, value := range []string{"last tuesday", "7q", "2026-13-01", "d"} { + if _, err := Parse(value, time.Now(), false); err == nil { + t.Fatalf("Parse(%q) accepted an unusable window", value) + } + } +} + +func TestEndOfDayBoundIsInclusive(t *testing.T) { + now := time.Date(2026, 7, 20, 14, 30, 0, 0, time.Local) + got, err := Parse("2026-07-01", now, true) + if err != nil { + t.Fatal(err) + } + want := time.Date(2026, 7, 2, 0, 0, 0, 0, time.Local).Add(-time.Nanosecond) + if !got.Equal(want) { + t.Fatalf("--until date = %s, want the last instant of the day %s", got, want) + } + // A timestamp already carries its own precision and must not be extended. + got, err = Parse("2026-07-01T08:00:00Z", now, true) + if err != nil { + t.Fatal(err) + } + if !got.Equal(time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)) { + t.Fatalf("--until timestamp was rounded to a day: %s", got) + } +} + +func TestWeekToDateStartsMonday(t *testing.T) { + for _, tc := range []struct { + now time.Time + want time.Time + }{ + {time.Date(2026, 7, 20, 9, 0, 0, 0, time.Local), time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)}, + {time.Date(2026, 7, 19, 9, 0, 0, 0, time.Local), time.Date(2026, 7, 13, 0, 0, 0, 0, time.Local)}, + {time.Date(2026, 7, 23, 9, 0, 0, 0, time.Local), time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)}, + } { + got, err := Parse("wtd", tc.now, false) + if err != nil { + t.Fatal(err) + } + if !got.Equal(tc.want) { + t.Fatalf("wtd on %s = %s, want %s", tc.now.Weekday(), got, tc.want) + } + } +} + +func TestPresetCycleWrapsAndLabels(t *testing.T) { + spec := Presets[0].Spec + seen := []string{spec} + for range Presets[1:] { + spec = Next(spec) + seen = append(seen, spec) + } + if got := Next(spec); got != Presets[0].Spec { + t.Fatalf("cycle did not wrap: %q -> %q", spec, got) + } + if got := Next("2026-07-01"); got != Presets[0].Spec { + t.Fatalf("typed window did not rejoin the cycle: %q", got) + } + for i, s := range seen { + if s != Presets[i].Spec { + t.Fatalf("cycle order changed at %d: %v", i, seen) + } + } + for _, tc := range []struct{ spec, want string }{ + {"7d", "last 7 days"}, + {"all", "all time"}, + {"3mo", "last 3mo"}, + {"2026-07-01", "since 2026-07-01"}, + } { + if got := Label(tc.spec); got != tc.want { + t.Fatalf("Label(%q) = %q, want %q", tc.spec, got, tc.want) + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 137aa03..64d773a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -14,6 +14,7 @@ import ( "github.com/polera/tokenhawk/internal/core" exporter "github.com/polera/tokenhawk/internal/export" "github.com/polera/tokenhawk/internal/monitor" + "github.com/polera/tokenhawk/internal/timerange" ) type RefreshMsg struct{} @@ -37,6 +38,11 @@ type Model struct { detailOffset int notice string layout int + spendSpec string + spendSince time.Time + spendOffset int + sinceInput bool + sinceDraft string } var titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#05A9C7")) @@ -49,11 +55,38 @@ const ( minimumCacheRatio float64 = 0.80 tableOuterWidth int = 2 tableCellPadding int = 2 + defaultSpendSpec string = "7d" ) func New(mon *monitor.Monitor) Model { t := table.New(table.WithFocused(true), table.WithHeight(15)) - return Model{monitor: mon, table: t} + m := Model{monitor: mon, table: t} + // The built-in default is a constant this package controls, so it parses. + _ = m.setSpendSpec(defaultSpendSpec) + return m +} + +// WithSpendWindow opens Tokenhawk on the spend view for a caller-supplied +// window, so `tokenhawk --since 30d` lands where the flag is meaningful. +func (m Model) WithSpendWindow(spec string) (Model, error) { + if err := m.setSpendSpec(spec); err != nil { + return m, err + } + m.tab = spendTab + return m, nil +} + +// setSpendSpec resolves spec once so every rebuild filters against a stable +// instant instead of drifting with the clock between renders. +func (m *Model) setSpendSpec(spec string) error { + since, err := timerange.Parse(spec, time.Now(), false) + if err != nil { + return err + } + m.spendSpec = spec + m.spendSince = since + m.spendOffset = 0 + return nil } func (m Model) Init() tea.Cmd { return tea.Batch(m.load(), tea.RequestBackgroundColor) } func (m Model) load() tea.Cmd { @@ -97,9 +130,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.KeyPressMsg: + if m.sinceInput { + return m.updateSinceInput(x) + } if m.searching { return m.updateSearch(x) } + if m.tab == spendTab && !m.detail { + return m.updateSpend(x) + } if m.detail { if x.String() == "e" || x.String() == "x" { idx := m.tableCursorSession() @@ -143,6 +182,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "3": m.tab = 2 m.rebuild() + case "4": + m.tab = spendTab + m.spendOffset = 0 + m.rebuild() case "i": m.toggleActiveInactive() case "p": @@ -170,6 +213,85 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// updateSpend drives the spend view. It never falls through to the session +// table, whose cursor is meaningless here. +func (m Model) updateSpend(k tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "1", "2", "3": + m.tab = int(k.String()[0] - '1') + m.spendOffset = 0 + m.rebuild() + case "i": + m.tab = 0 + m.spendOffset = 0 + m.rebuild() + case "p": + m.cycleProvider() + m.rebuild() + case "t": + m.cycleSpendWindow() + case "d": + m.sinceInput = true + m.sinceDraft = m.spendSpec + m.notice = "since: RFC3339, YYYY-MM-DD, 7d, 3mo, today, mtd, ytd, or all; enter applies, esc cancels" + case "/": + m.searching = true + m.notice = "type to filter projects/models; enter applies, esc cancels" + case "e": + return m, m.export("json") + case "x": + return m, m.export("csv") + case "j", "down": + m.scrollSpend(1) + case "k", "up": + m.scrollSpend(-1) + case "pgdown", "ctrl+f": + m.scrollSpend(max(1, m.height-4)) + case "pgup", "ctrl+b": + m.scrollSpend(-max(1, m.height-4)) + case "g", "home": + m.spendOffset = 0 + case "G", "end": + m.spendOffset = m.spendMaxOffset() + } + return m, nil +} + +func (m Model) updateSinceInput(k tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "enter": + if err := m.setSpendSpec(strings.TrimSpace(m.sinceDraft)); err != nil { + m.notice = "since: " + err.Error() + return m, nil + } + m.sinceInput = false + m.tab = spendTab + m.notice = "" + m.rebuild() + case "esc": + m.sinceInput = false + m.sinceDraft = "" + m.notice = "" + case "backspace": + r := []rune(m.sinceDraft) + if len(r) > 0 { + m.sinceDraft = string(r[:len(r)-1]) + } + default: + if k.Key().Text != "" { + m.sinceDraft += k.Key().Text + } + } + return m, nil +} + +func (m *Model) cycleSpendWindow() { + _ = m.setSpendSpec(timerange.Next(m.spendSpec)) + m.rebuild() +} + func (m Model) updateSearch(k tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch k.String() { case "enter": @@ -263,13 +385,18 @@ func flexibleColumnWidth(terminalWidth, fixedWidth, columnCount, minimum int) in return max(minimum, available) } func (m *Model) rebuild() { + m.refreshSpendWindow() q := strings.ToLower(m.search) m.shown = nil for _, s := range m.sessions { if m.provider != "" && s.Provider != m.provider { continue } - if m.tab == 0 && !s.Active || m.tab == 1 && s.Active { + if m.tab == spendTab { + if !m.spendSince.IsZero() && s.UpdatedAt.Before(m.spendSince) { + continue + } + } else if m.tab == 0 && !s.Active || m.tab == 1 && s.Active { continue } models := modelNames(s) @@ -318,6 +445,29 @@ func (m *Model) rebuild() { m.table.SetCursor(0) } } +// refreshSpendWindow re-resolves a relative spec on every rebuild so a window +// such as "last 24 hours" keeps rolling while Tokenhawk stays open. A spec that +// stops parsing keeps the last resolved bound rather than silently widening. +func (m *Model) refreshSpendWindow() { + if since, err := timerange.Parse(m.spendSpec, time.Now(), false); err == nil { + m.spendSince = since + } +} + +func (m *Model) scrollSpend(delta int) { + m.spendOffset = min(max(0, m.spendOffset+delta), m.spendMaxOffset()) +} + +func (m Model) spendMaxOffset() int { + return max(0, len(strings.Split(m.spendContent(), "\n"))-max(1, m.spendViewport())) +} + +// spendViewport is the line budget left for the spend body once the dashboard +// header, prompt, and footer have claimed their rows. +func (m Model) spendViewport() int { + return max(3, m.height-m.chromeHeight()) +} + func (m Model) export(format string) tea.Cmd { return m.exportSessions(format, append([]core.Session(nil), m.shown...)) } @@ -344,6 +494,17 @@ func (m Model) View() tea.View { return v } func (m Model) dashboard() string { + header, footer := m.chrome() + body := m.table.View() + if m.tab == spendTab { + body = m.spendBody() + } + return header + "\n\n" + body + "\n" + footer +} + +// chrome renders the parts that frame every dashboard body, so the spend view +// and the session table agree on how many rows are left for content. +func (m Model) chrome() (string, string) { active := 0 runningAgents := 0 for _, s := range m.sessions { @@ -353,23 +514,31 @@ func (m Model) dashboard() string { runningAgents += s.RunningSubagents() } cacheAlarms := activeCacheAlarms(m.sessions) - tabs := []string{"1 Active", "2 Inactive Sessions", "3 All Sessions"} + tabs := []string{"1 Active", "2 Inactive Sessions", "3 All Sessions", "4 Spend"} tabs[m.tab] = titleStyle.Render(tabs[m.tab]) provider := "all" if m.provider != "" { provider = string(m.provider) } sortName := []string{"updated", "tokens", "cost"}[m.sortMode] - header := hawkBrand(m.width) + "\n" + strings.Join(tabs, " ") + "\n" + fmt.Sprintf("%s active • %d inactive • %s subagents running • showing %d of %d sessions\nprovider: %s • sort: %s", good.Render(fmt.Sprint(active)), len(m.sessions)-active, good.Render(fmt.Sprint(runningAgents)), len(m.shown), len(m.sessions), provider, sortName) + header := hawkBrand(m.width) + "\n" + strings.Join(tabs, " ") + "\n" + fmt.Sprintf("%s active • %d inactive • %s subagents running • showing %d of %d sessions\nprovider: %s • sort: %s • spend window: %s", good.Render(fmt.Sprint(active)), len(m.sessions)-active, good.Render(fmt.Sprint(runningAgents)), len(m.shown), len(m.sessions), provider, sortName, timerange.Label(m.spendSpec)) if cacheAlarms > 0 { header += "\n" + alarmStyle.Render(fmt.Sprintf("⚠ %d high-input session(s) below 80%% cache ratio", cacheAlarms)) } - search := "" - if m.searching || m.search != "" { - search = "\n/ " + m.search + "▌" + if m.sinceInput { + header += "\nsince " + m.sinceDraft + "▌" + } else if m.searching || m.search != "" { + header += "\n/ " + m.search + "▌" + } + var status monitor.Status + if m.monitor != nil { + status = m.monitor.Status() + } + keys := "i active/inactive p provider s sort / filter enter details e JSON x CSV q quit" + if m.tab == spendTab { + keys = "t range d since 1-3 sessions p provider / filter ↑/↓ scroll e JSON x CSV q quit" } - status := m.monitor.Status() - footer := fmt.Sprintf("i active/inactive p provider s sort / filter enter details e JSON x CSV q quit • indexed %d files", status.Files) + footer := fmt.Sprintf("%s • indexed %d files", keys, status.Files) if status.Scanning { footer += " • scanning…" } @@ -379,7 +548,26 @@ func (m Model) dashboard() string { if m.notice != "" { footer += "\n" + m.notice } - return header + search + "\n\n" + m.table.View() + "\n" + muted.Render(footer) + return header, muted.Render(footer) +} + +func (m Model) chromeHeight() int { + header, footer := m.chrome() + return lipgloss.Height(header) + lipgloss.Height(footer) + 1 +} + +// spendBody renders the spend report clipped to the available rows, with the +// same scroll affordance the session detail uses. +func (m Model) spendBody() string { + content := m.spendContent() + lines := strings.Split(content, "\n") + visible := m.spendViewport() + if m.height <= 0 || len(lines) <= visible { + return content + } + offset := min(max(0, m.spendOffset), max(0, len(lines)-visible)) + end := min(len(lines), offset+visible-1) + return strings.Join(lines[offset:end], "\n") + "\n" + muted.Render(fmt.Sprintf("↑/↓ scroll %d–%d/%d", offset+1, end, len(lines))) } func (m Model) detailView() string { content := m.detailContent() diff --git a/internal/tui/spend.go b/internal/tui/spend.go new file mode 100644 index 0000000..2714482 --- /dev/null +++ b/internal/tui/spend.go @@ -0,0 +1,235 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/polera/tokenhawk/internal/core" + "github.com/polera/tokenhawk/internal/timerange" +) + +const spendTab = 3 +const spendDayRows = 14 + +// spendGroup is one aggregation bucket: a provider, a model, or a day. +type spendGroup struct { + name string + sessions int + usage []core.Usage +} + +type spendGroups struct { + order []string + byName map[string]*spendGroup +} + +func newSpendGroups() *spendGroups { + return &spendGroups{byName: map[string]*spendGroup{}} +} + +func (g *spendGroups) add(name string, sessions int, usage ...core.Usage) { + entry, ok := g.byName[name] + if !ok { + entry = &spendGroup{name: name} + g.byName[name] = entry + g.order = append(g.order, name) + } + entry.sessions += sessions + entry.usage = append(entry.usage, usage...) +} + +func (g *spendGroups) list() []spendGroup { + out := make([]spendGroup, 0, len(g.order)) + for _, name := range g.order { + out = append(out, *g.byName[name]) + } + return out +} + +// sessionUsage flattens a session's own rows and its subagents' rows, which is +// the same set Session.Totals sums. +func sessionUsage(s core.Session) []core.Usage { + out := append([]core.Usage(nil), s.Usage...) + for _, a := range s.Subagents { + out = append(out, a.Usage...) + } + return out +} + +func groupByProvider(sessions []core.Session) []spendGroup { + g := newSpendGroups() + for _, s := range sessions { + g.add(string(s.Provider), 1, sessionUsage(s)...) + } + out := g.list() + sortByCost(out) + return out +} + +func groupByModel(sessions []core.Session) []spendGroup { + g := newSpendGroups() + for _, s := range sessions { + seen := map[string]bool{} + for _, u := range sessionUsage(s) { + name := u.Model + if name == "" { + name = "unknown" + } + sessions := 0 + if !seen[name] { + seen[name] = true + sessions = 1 + } + g.add(name, sessions, u) + } + } + out := g.list() + sortByCost(out) + return out +} + +// groupByDay buckets each session by the day it was last updated. Provider +// stores keep only per-session running totals, so a session's whole usage lands +// on that one day rather than being spread across the days it ran. +func groupByDay(sessions []core.Session) []spendGroup { + g := newSpendGroups() + for _, s := range sessions { + g.add(s.UpdatedAt.Local().Format("2006-01-02"), 1, sessionUsage(s)...) + } + out := g.list() + sort.SliceStable(out, func(i, j int) bool { return out[i].name > out[j].name }) + return out +} + +func sortByCost(groups []spendGroup) { + sort.SliceStable(groups, func(i, j int) bool { + a, b := core.SumUsage(groups[i].usage), core.SumUsage(groups[j].usage) + if a.CostUSD != b.CostUSD { + return a.CostUSD > b.CostUSD + } + return a.Total > b.Total + }) +} + +// spendWindow resolves the model's window spec, tolerating a spec that no +// longer parses so the view can report it instead of rendering nothing. +func (m Model) spendWindow() (time.Time, string) { + return m.spendSince, timerange.Label(m.spendSpec) +} + +func (m Model) spendContent() string { + since, label := m.spendWindow() + var b strings.Builder + window := "all recorded sessions" + if !since.IsZero() { + window = since.Format("2006-01-02 15:04") + " → now" + } + b.WriteString(titleStyle.Render("SPEND · "+label) + "\n") + fmt.Fprintf(&b, "%s\n\n", muted.Render(fmt.Sprintf("%s • %d of %d sessions • counted by last session update", window, len(m.shown), len(m.sessions)))) + if len(m.shown) == 0 { + b.WriteString(muted.Render("No sessions were updated in this window. Press t for another range or d to set one.") + "\n") + return b.String() + } + total := core.SumUsage(spendUsage(m.shown)) + fmt.Fprintf(&b, "%s tokens %s in %s cached %s out %s i:o %s\n", titleStyle.Render("TOTAL"), human(total.Total), human(total.Input), cachedText(total), human(total.Output), ratioText(total.Input, total.Output)) + fmt.Fprintf(&b, " %s\n", costDetail(total)) + if cacheAlarm(total) { + fmt.Fprintf(&b, "%s\n", cacheAlarmText("this window", total)) + } + b.WriteString("\n") + b.WriteString(m.spendSection("BY PROVIDER", groupByProvider(m.shown), 0, "provider")) + b.WriteString(m.spendSection("BY MODEL", groupByModel(m.shown), 0, "model")) + b.WriteString(m.spendSection("BY DAY", groupByDay(m.shown), spendDayRows, "earlier day")) + return b.String() +} + +func spendUsage(sessions []core.Session) []core.Usage { + var out []core.Usage + for _, s := range sessions { + out = append(out, sessionUsage(s)...) + } + return out +} + +// spendSection renders one aggregation with a cost bar scaled to the largest +// row. limit caps the rows and reports the remainder; 0 means no cap. +func (m Model) spendSection(title string, groups []spendGroup, limit int, noun string) string { + if len(groups) == 0 { + return "" + } + var b strings.Builder + b.WriteString(titleStyle.Render(title) + "\n") + peak := 0.0 + for _, g := range groups { + if weight := spendWeight(core.SumUsage(g.usage)); weight > peak { + peak = weight + } + } + nameWidth := 0 + for _, g := range groups { + if n := len([]rune(g.name)); n > nameWidth { + nameWidth = n + } + } + nameWidth = min(nameWidth, 28) + shown := groups + if limit > 0 && len(shown) > limit { + shown = shown[:limit] + } + for _, g := range shown { + u := core.SumUsage(g.usage) + name := g.name + if r := []rune(name); len(r) > nameWidth { + name = string(r[:nameWidth-1]) + "…" + } + // The bar and the muted styles inside it carry their own resets, so an + // alarm is marked with a leading glyph rather than by styling the line. + prefix := " " + if cacheAlarm(u) { + prefix = alarmStyle.Render("⚠") + " " + } + line := prefix + fmt.Sprintf("%-*s %s %5d sess tokens %8s", nameWidth, name, spendBar(spendWeight(u), peak, m.width), g.sessions, human(u.Total)) + if m.width >= 96 { + line += fmt.Sprintf(" in %8s cached %6s out %8s", human(u.Input), human(u.CachedInput), human(u.Output)) + } + line += fmt.Sprintf(" i:o %s", ratioText(u.Input, u.Output)) + line += " " + costText(u) + b.WriteString(line + "\n") + } + if rest := len(groups) - len(shown); rest > 0 { + b.WriteString(muted.Render(fmt.Sprintf(" … %d more %s(s) not shown", rest, noun)) + "\n") + } + b.WriteString("\n") + return b.String() +} + +// spendWeight ranks a bucket by cost, falling back to tokens so unpriced +// providers still produce a readable bar. +func spendWeight(u core.Usage) float64 { + if u.CostUSD > 0 { + return u.CostUSD + } + return float64(u.Total) +} + +func spendBar(weight, peak float64, width int) string { + size := 12 + if width < 96 { + size = 6 + } + if peak <= 0 || weight <= 0 { + return muted.Render(strings.Repeat("·", size)) + } + filled := int(weight / peak * float64(size)) + filled = min(max(filled, 1), size) + return strings.Repeat("█", filled) + muted.Render(strings.Repeat("·", size-filled)) +} + +func cachedText(u core.Usage) string { + if u.Input <= 0 { + return human(u.CachedInput) + } + return fmt.Sprintf("%s (%.0f%%)", human(u.CachedInput), float64(u.CachedInput)/float64(u.Input)*100) +} diff --git a/internal/tui/spend_test.go b/internal/tui/spend_test.go new file mode 100644 index 0000000..ef17f51 --- /dev/null +++ b/internal/tui/spend_test.go @@ -0,0 +1,197 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/polera/tokenhawk/internal/core" +) + +func spendModel(t *testing.T) Model { + t.Helper() + now := time.Now() + m := New(nil) + m.sessions = []core.Session{ + {Provider: core.Claude, ID: "today-a", Project: "/work/a", Active: true, UpdatedAt: now.Add(-2 * time.Hour), + Usage: []core.Usage{{Model: "claude-opus-4-8", Input: 500_000, CachedInput: 450_000, Output: 20_000, Total: 520_000, CostUSD: 4, PricingStatus: "priced"}}, + Subagents: []core.Subagent{{ID: "child", ParentID: "today-a", Usage: []core.Usage{{Model: "claude-haiku-4-5", Input: 10_000, CachedInput: 5_000, Output: 1_000, Total: 11_000, CostUSD: 0.5, PricingStatus: "priced"}}}}}, + {Provider: core.Codex, ID: "recent-b", Project: "/work/b", UpdatedAt: now.Add(-3 * 24 * time.Hour), + Usage: []core.Usage{{Model: "gpt-5", Input: 100_000, CachedInput: 90_000, Output: 5_000, Total: 105_000, CostUSD: 1, PricingStatus: "priced"}}}, + {Provider: core.Gemini, ID: "old-c", Project: "/work/c", UpdatedAt: now.Add(-40 * 24 * time.Hour), + Usage: []core.Usage{{Model: "gemini-3-pro", Input: 900_000, CachedInput: 10_000, Output: 90_000, Total: 990_000, CostUSD: 9, PricingStatus: "priced"}}}, + } + m.width, m.height = 140, 40 + m.resize() + return m +} + +func TestSpendWindowExcludesSessionsUpdatedBeforeSince(t *testing.T) { + m := spendModel(t) + m.tab = spendTab + m.rebuild() + if len(m.shown) != 2 { + t.Fatalf("default 7d window kept %d sessions, want the two recent ones", len(m.shown)) + } + view := m.spendContent() + if strings.Contains(view, "gemini") { + t.Fatalf("40-day-old session leaked into the 7d window:\n%s", view) + } + // Totals must include subagent usage, matching Session.Totals. + for _, want := range []string{"SPEND · last 7 days", "TOTAL", "636.0k", "i:o 23.5:1", "$5.500000 estimated (priced)", "claude-haiku-4-5"} { + if !strings.Contains(view, want) { + t.Fatalf("spend view missing %q:\n%s", want, view) + } + } + if err := m.setSpendSpec("all"); err != nil { + t.Fatal(err) + } + m.rebuild() + if len(m.shown) != 3 || !strings.Contains(m.spendContent(), "all time") { + t.Fatalf("all-time window did not include every session: %d shown", len(m.shown)) + } +} + +func TestSpendGroupsShowInputOutputRatiosAtEveryWidth(t *testing.T) { + for _, width := range []int{80, 140} { + m := spendModel(t) + m.tab = spendTab + m.width = width + m.resize() + view := m.spendContent() + for _, want := range []string{"i:o 24.3:1", "i:o 20.0:1"} { + if !strings.Contains(view, want) { + t.Fatalf("width %d spend view missing %q:\n%s", width, want, view) + } + } + } +} + +func TestSpendGroupsRankProvidersModelsAndDays(t *testing.T) { + m := spendModel(t) + m.tab = spendTab + if err := m.setSpendSpec("all"); err != nil { + t.Fatal(err) + } + m.rebuild() + providers := groupByProvider(m.shown) + if len(providers) != 3 || providers[0].name != "gemini" || providers[1].name != "claude" { + t.Fatalf("providers were not ranked by cost: %#v", providers) + } + models := groupByModel(m.shown) + if models[0].name != "gemini-3-pro" || len(models) != 4 { + t.Fatalf("models were not broken out per model: %#v", models) + } + days := groupByDay(m.shown) + if len(days) != 3 || !(days[0].name > days[1].name && days[1].name > days[2].name) { + t.Fatalf("days were not ordered newest first: %#v", days) + } + if total := core.SumUsage(spendUsage(m.shown)); total.CostUSD != 14.5 || total.Total != 1_626_000 { + t.Fatalf("window totals do not sum every session and subagent: %#v", total) + } +} + +func TestSpendKeysCycleWindowsAndAcceptTypedDates(t *testing.T) { + m := spendModel(t) + updated, _ := m.Update(key("4")) + m = updated.(Model) + if m.tab != spendTab { + t.Fatalf("4 did not open the spend view: tab=%d", m.tab) + } + updated, _ = m.Update(key("t")) + m = updated.(Model) + if m.spendSpec != "30d" { + t.Fatalf("t did not advance the window from 7d: %q", m.spendSpec) + } + updated, _ = m.Update(key("d")) + m = updated.(Model) + if !m.sinceInput { + t.Fatal("d did not open the since prompt") + } + for _, k := range []string{"ctrl+a", "backspace", "backspace", "backspace", "2", "0", "2", "6", "-", "0", "7", "-", "0", "1", "enter"} { + updated, _ = m.Update(key(k)) + m = updated.(Model) + } + if m.sinceInput { + t.Fatalf("enter did not apply the typed window: draft=%q", m.sinceDraft) + } + if m.spendSpec != "2026-07-01" { + t.Fatalf("typed window was not applied: %q", m.spendSpec) + } + // An unusable window keeps the prompt open with the last good bound intact. + before := m.spendSince + updated, _ = m.Update(key("d")) + m = updated.(Model) + for _, k := range []string{"backspace", "backspace", "backspace", "backspace", "backspace", "backspace", "backspace", "backspace", "backspace", "backspace", "z", "enter"} { + updated, _ = m.Update(key(k)) + m = updated.(Model) + } + if !m.sinceInput || !m.spendSince.Equal(before) { + t.Fatalf("bad window was accepted: sinceInput=%v since=%s", m.sinceInput, m.spendSince) + } + if !strings.Contains(m.notice, "since:") { + t.Fatalf("bad window reported no reason: %q", m.notice) + } + updated, _ = m.Update(key("esc")) + m = updated.(Model) + updated, _ = m.Update(key("1")) + m = updated.(Model) + if m.sinceInput || m.tab != 0 { + t.Fatalf("spend view did not return to the session list: sinceInput=%v tab=%d", m.sinceInput, m.tab) + } +} + +func TestSpendBodyFitsTheViewportAndScrolls(t *testing.T) { + m := spendModel(t) + m.tab = spendTab + if err := m.setSpendSpec("all"); err != nil { + t.Fatal(err) + } + m.height = 20 + m.resize() + body := m.spendBody() + if got, limit := len(strings.Split(body, "\n")), m.spendViewport(); got > limit { + t.Fatalf("spend body rendered %d lines into a %d-line viewport", got, limit) + } + if !strings.Contains(body, "SPEND") { + t.Fatalf("clipped body lost its heading:\n%s", body) + } + if m.spendMaxOffset() == 0 { + t.Fatal("a clipped report reported nothing to scroll") + } + m.scrollSpend(1000) + if m.spendOffset != m.spendMaxOffset() { + t.Fatalf("scroll ran past the end: %d of %d", m.spendOffset, m.spendMaxOffset()) + } + if lines := strings.Split(m.spendBody(), "\n"); len(lines) > m.spendViewport() { + t.Fatalf("scrolled body overflowed the viewport: %d lines", len(lines)) + } + m.scrollSpend(-1000) + if m.spendOffset != 0 { + t.Fatalf("scroll ran past the start: %d", m.spendOffset) + } +} + +func TestSpendViewReportsAnEmptyWindow(t *testing.T) { + m := spendModel(t) + m.tab = spendTab + if err := m.setSpendSpec("1h"); err != nil { + t.Fatal(err) + } + m.rebuild() + if got := m.spendContent(); !strings.Contains(got, "No sessions were updated in this window") { + t.Fatalf("empty window rendered a misleading report:\n%s", got) + } +} + +func key(s string) tea.KeyPressMsg { + if len(s) == 1 { + return tea.KeyPressMsg{Code: rune(s[0]), Text: s} + } + codes := map[string]rune{"enter": tea.KeyEnter, "esc": tea.KeyEscape, "backspace": tea.KeyBackspace} + if code, ok := codes[s]; ok { + return tea.KeyPressMsg{Code: code} + } + return tea.KeyPressMsg{Code: rune(s[len(s)-1]), Mod: tea.ModCtrl} +}