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
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
47 changes: 20 additions & 27 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -211,21 +212,23 @@ 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")
}
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
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
149 changes: 149 additions & 0 deletions internal/timerange/timerange.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading