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
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,23 +174,23 @@ The default remains the original green hacker-terminal presentation.
Press `s` to cycle:

1. **Bars** — chunky quota bars, with one full-width rate-limit window per row.
2. **Pie** — clockwise-filled circles rendered on a 2×4 sub-cell Braille canvas
2. **Stopwatch** — establishes a zero baseline across locally active Codex
sessions when you press Go. A large readout follows newly appended token
telemetry once per second and shows total observed tokens, elapsed time, and
average rate; clickable Go and Stop controls sit beside it. The full-width
plot adds one thin vertical block bar every 30 seconds for tokens observed in
that interval. New bars enter on the right and push older bars left, and the
Y axis automatically expands or contracts to fit the visible samples. Stop performs
an immediate final local read instead of relying on the latest graph sample.
3. **Pie** — clockwise-filled circles rendered on a 2×4 sub-cell Braille canvas
for clean curves at any size.
3. **Consumption Pace** — a signed horizontal scale comparing elapsed window
4. **Consumption Pace** — a signed horizontal scale comparing elapsed window
time with quota consumed. Positive headroom means consumption is behind
elapsed time; a negative deficit means quota is being used too quickly.
4. **Fuel Tank** — a reverse gauge whose bright segment shows remaining range
5. **Fuel Tank** — a reverse gauge whose bright segment shows remaining range
and whose dark segment shows consumed capacity, labelled from Empty to Full;
one full-width tank appears per row. Its reset-cycle comparison also drains
backward and aligns exactly with the tank's first and last inner cells.
5. **Stopwatch** — establishes a zero baseline across locally active Codex
sessions when you press Go. A large readout follows newly appended token
telemetry once per second and shows total observed tokens, elapsed time, and
average rate; clickable Go and Stop controls sit beside it. The full-width
plot adds one thin vertical block bar every 30 seconds for tokens observed in
that interval. New bars enter on the right and push older bars left, and the Y axis
automatically expands or contracts to fit the visible samples. Stop performs
an immediate final local read instead of relying on the latest graph sample.

The layout responds to both terminal dimensions and the number of rate limits
returned by Codex. Header, status, errors, footer, and meter grid divide the
Expand Down Expand Up @@ -273,9 +273,9 @@ need Go or Codexometer's source dependencies.

## Versioning

Codexometer follows semantic versioning, beginning with `v0.1.0`. The Git tag is
Codexometer follows semantic versioning; the current source version is `v0.1.1`. The Git tag is
the release source of truth. Go automatically embeds that tag in binaries built
with `go install github.com/merefield/codexometer@v0.1.0`; direct source builds
with `go install github.com/merefield/codexometer@v0.1.1`; direct source builds
fall back to the maintained value in `internal/version/version.go`.

Both forms report the embedded version and exit without starting the interface:
Expand All @@ -288,7 +288,7 @@ codexometer --version
Release automation can override the source-build fallback without editing code:

```sh
go build -ldflags="-s -w -X github.com/merefield/codexometer/internal/version.Fallback=0.1.0" .
go build -ldflags="-s -w -X github.com/merefield/codexometer/internal/version.Fallback=0.1.1" .
```

## How refresh works
Expand Down
12 changes: 9 additions & 3 deletions internal/codex/live_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import (

const (
activeRolloutHorizon = 24 * time.Hour
fullDiscoveryEvery = 30 * time.Second
discoveryEvery = 5 * time.Second
fullDiscoveryEvery = 5 * time.Minute
)

// LiveUsageSnapshot is a process-local, monotonically increasing count built
Expand All @@ -36,6 +37,7 @@ type LiveUsageReader struct {
mu sync.Mutex
initialized bool
startedAt time.Time
lastDiscovery time.Time
lastFullDiscovery time.Time
files map[string]*rolloutCursor
totalTokens int64
Expand Down Expand Up @@ -112,8 +114,11 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
if err := r.initialize(ctx, now); err != nil {
return LiveUsageSnapshot{}, err
}
} else if err := r.discover(ctx, now, forceFullDiscovery || now.Sub(r.lastFullDiscovery) >= fullDiscoveryEvery); err != nil {
return LiveUsageSnapshot{}, err
} else if forceFullDiscovery || now.Sub(r.lastDiscovery) >= discoveryEvery {
full := forceFullDiscovery || now.Sub(r.lastFullDiscovery) >= fullDiscoveryEvery
if err := r.discover(ctx, now, full); err != nil {
return LiveUsageSnapshot{}, err
}
}

var firstReadErr error
Expand Down Expand Up @@ -217,6 +222,7 @@ func (r *LiveUsageReader) discover(ctx context.Context, now time.Time, full bool
if firstFileErr != nil && len(r.files) == 0 {
return fmt.Errorf("read local Codex session telemetry: %w", firstFileErr)
}
r.lastDiscovery = now
if full {
r.lastFullDiscovery = now
}
Expand Down
33 changes: 33 additions & 0 deletions internal/codex/live_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,15 @@ func TestLiveUsageReaderHandlesNewOldAndResetSessions(t *testing.T) {

newPath := testRolloutPath(t, home, now.Add(2*time.Second), "new")
writeRollout(t, newPath, tokenCountLine(now.Add(2*time.Second), 40)+"\n")
reader.lastDiscovery = time.Time{}
usage, err := reader.FetchTokenUsage(context.Background())
if err != nil || usage.TotalTokens != 40 {
t.Fatalf("new session usage = %#v, %v; want 40", usage, err)
}

oldPath := testRolloutPath(t, home, now.Add(-2*time.Hour), "resumed")
writeRollout(t, oldPath, tokenCountLine(now.Add(-2*time.Hour), 900)+"\n")
reader.lastDiscovery = time.Time{}
usage, err = reader.FetchTokenUsage(context.Background())
if err != nil || usage.TotalTokens != 40 {
t.Fatalf("late-discovered old baseline counted historical usage: %#v, %v", usage, err)
Expand All @@ -98,6 +100,37 @@ func TestLiveUsageReaderHandlesNewOldAndResetSessions(t *testing.T) {
}
}

func TestLiveUsageReaderThrottlesDiscoveryButConsumesKnownFiles(t *testing.T) {
home := t.TempDir()
now := time.Now()
knownPath := testRolloutPath(t, home, now.Add(-time.Hour), "known-throttle")
writeRollout(t, knownPath, tokenCountLine(now.Add(-time.Hour), 100)+"\n")
reader, err := NewLiveUsageReader(home)
if err != nil {
t.Fatal(err)
}
if _, err := reader.FetchTokenUsage(context.Background()); err != nil {
t.Fatal(err)
}

appendRollout(t, knownPath, tokenCountLine(now, 150)+"\n")
newPath := testRolloutPath(t, home, now.Add(time.Second), "new-throttle")
writeRollout(t, newPath, tokenCountLine(now.Add(time.Second), 40)+"\n")
usage, err := reader.FetchTokenUsage(context.Background())
if err != nil || usage.TotalTokens != 50 {
t.Fatalf("known cursor was not consumed independently of discovery: %#v, %v", usage, err)
}
if _, exists := reader.files[newPath]; exists {
t.Fatal("new rollout was discovered before the discovery interval elapsed")
}

reader.lastDiscovery = time.Now().Add(-discoveryEvery)
usage, err = reader.FetchTokenUsage(context.Background())
if err != nil || usage.TotalTokens != 90 {
t.Fatalf("due discovery did not add new rollout usage: %#v, %v", usage, err)
}
}

func TestFreshLiveUsageReadNoticesAResumedDormantSession(t *testing.T) {
home := t.TempDir()
now := time.Now()
Expand Down
7 changes: 6 additions & 1 deletion internal/ui/meters.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import (
)

func usesMeterGrid(style meterStyleID) bool {
return style >= styleBars && style < styleStopwatch
switch style {
case styleBars, stylePie, styleConsumptionPace, styleFuel:
return true
default:
return false
}
}

func renderMeterGrid(width, height int, meters []codex.Meter, style meterStyleID, colors palette) string {
Expand Down
6 changes: 4 additions & 2 deletions internal/ui/meters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"github.com/merefield/codexometer/internal/codex"
)

var quotaMeterStyles = []meterStyleID{styleBars, stylePie, styleConsumptionPace, styleFuel}

func TestEveryMeterStyleHasDistinctiveOutput(t *testing.T) {
colors := paletteFor(themeHacker)
tests := []struct {
Expand All @@ -33,7 +35,7 @@ func TestEveryMeterStyleHasDistinctiveOutput(t *testing.T) {

func TestEveryMeterStyleUsesAllocatedRectangle(t *testing.T) {
colors := paletteFor(themeHacker)
for style := styleBars; style < styleStopwatch; style++ {
for _, style := range quotaMeterStyles {
output := renderVisualizationSized(50, 12, 62, style, colors.primary, colors)
if got := lipgloss.Width(output); got > 50 {
t.Errorf("%s width = %d, exceeds allocation 50", style.name(), got)
Expand All @@ -53,7 +55,7 @@ func TestEveryStyleIncludesResetCycleGauge(t *testing.T) {
Name: "1 HOUR",
Window: codex.Window{UsedPercent: 40, WindowDurationMins: &duration, ResetsAt: &reset},
}
for style := styleBars; style < styleStopwatch; style++ {
for _, style := range quotaMeterStyles {
output := ansi.Strip(renderMeterArea(80, 18, meter, style, colors))
if !strings.Contains(output, "RESET CYCLE 50%") {
t.Errorf("%s missing reset-cycle comparison gauge:\n%s", style.name(), output)
Expand Down
62 changes: 56 additions & 6 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"

"github.com/merefield/codexometer/internal/codex"
)
Expand Down Expand Up @@ -282,25 +281,76 @@ func (m Model) footerButtonAt(x, y int) footerButtonID {
if x < 0 || y < 0 {
return footerButtonNone
}
lines := strings.Split(ansi.Strip(m.View()), "\n")
if y >= len(lines) {
if m.loading && len(m.snapshot.Meters()) == 0 {
return footerButtonNone
}
if m.meterStyle == styleStopwatch {
if button := m.stopwatchButtonAt(x, y); button != footerButtonNone {
return button
}
}
buttons, _ := footerButtonLayout(m.contentWidth())
layout := m.dashboardLayout()
if y != layout.footerY+1 {
return footerButtonNone
}
localX := x - 2
buttons, separator := footerButtonLayout(layout.contentWidth)
buttonX := 0
for _, button := range buttons {
start := strings.Index(lines[y], button.label)
if start >= 0 && x >= start && x < start+len(button.label) {
if localX >= buttonX && localX < buttonX+len(button.label) {
return button.id
}
buttonX += len(button.label) + len(separator)
}
return footerButtonNone
}

type dashboardGeometry struct {
contentWidth int
meterHeight int
meterY int
footerY int
}

func (m Model) dashboardLayout() dashboardGeometry {
width := m.width
if width == 0 {
width = 80
}
height := m.height
if height == 0 {
height = 24
}
contentWidth := max(width-4, 1)
contentHeight := max(height-2, 1)
headerHeight := 3
if contentWidth < 64 {
headerHeight = 2
}
const statusHeight = 1
const framedErrorHeight = 4
const footerHeight = 2
extraHeight := 0
if m.err != nil {
extraHeight += framedErrorHeight
}
if len(m.snapshot.Meters()) == 0 {
extraHeight += framedErrorHeight
}
meterY := 1 + headerHeight + statusHeight + extraHeight
meterHeight := max(contentHeight-headerHeight-statusHeight-extraHeight-footerHeight, 1)
footerY := meterY
if m.meterStyle == styleStopwatch || len(m.snapshot.Meters()) > 0 {
footerY += meterHeight
}
return dashboardGeometry{
contentWidth: contentWidth,
meterHeight: meterHeight,
meterY: meterY,
footerY: footerY,
}
}

func (m Model) contentWidth() int {
width := m.width
if width == 0 {
Expand Down
46 changes: 43 additions & 3 deletions internal/ui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestThemeHotkeyCyclesAndWraps(t *testing.T) {

func TestStyleHotkeyCyclesAndWraps(t *testing.T) {
model := New(nil, time.Minute)
for want := stylePie; want < styleCount; want++ {
for want := styleStopwatch; want < styleCount; want++ {
updated, _ := model.Update(key('s'))
model = updated.(Model)
if model.meterStyle != want {
Expand Down Expand Up @@ -175,8 +175,8 @@ func TestFooterButtonsSupportHoverAndMouseClicks(t *testing.T) {

updated, _ = model.Update(footerMouseMessage(t, model, footerButtonStyle, tea.MouseActionPress))
model = updated.(Model)
if model.meterStyle != stylePie {
t.Fatalf("style click selected %d, want %d", model.meterStyle, stylePie)
if model.meterStyle != styleStopwatch {
t.Fatalf("style click selected %d, want %d", model.meterStyle, styleStopwatch)
}

updated, command = model.Update(footerMouseMessage(t, model, footerButtonRefresh, tea.MouseActionPress))
Expand All @@ -192,6 +192,46 @@ func TestFooterButtonsSupportHoverAndMouseClicks(t *testing.T) {
}
}

func TestFooterHitGeometryMatchesRenderedButtonsAcrossSizes(t *testing.T) {
for _, test := range []struct {
name string
width, height int
empty bool
withError bool
style meterStyleID
}{
{name: "compact", width: 40, height: 24},
{name: "standard", width: 80, height: 24},
{name: "large error", width: 120, height: 40, withError: true},
{name: "empty quota", width: 80, height: 24, empty: true},
{name: "empty stopwatch", width: 80, height: 24, empty: true, style: styleStopwatch},
} {
t.Run(test.name, func(t *testing.T) {
model := New(stubFetcher{snapshot: codex.DemoSnapshot()}, time.Minute)
if !test.empty {
model.snapshot = codex.DemoSnapshot()
}
model.loading = false
model.width = test.width
model.height = test.height
model.meterStyle = test.style
if test.withError {
model.err = errors.New("stale quota signal")
}
layout := model.dashboardLayout()
for _, id := range []footerButtonID{footerButtonTheme, footerButtonStyle, footerButtonRefresh, footerButtonQuit} {
mouse := footerMouseMessage(t, model, id, tea.MouseActionMotion)
if mouse.Y != layout.footerY+1 {
t.Errorf("rendered footer y=%d, calculated y=%d", mouse.Y, layout.footerY+1)
}
if got := model.footerButtonAt(mouse.X, mouse.Y); got != id {
t.Errorf("hit button %d, want %d", got, id)
}
}
})
}
}

func TestFooterButtonHotkeyFlashesAndLatestPulseWins(t *testing.T) {
model := New(stubFetcher{snapshot: codex.DemoSnapshot()}, time.Minute)
model.snapshot = codex.DemoSnapshot()
Expand Down
Loading
Loading