Skip to content

Commit 5c7d972

Browse files
committed
Address stopwatch performance review
1 parent 8bef7fd commit 5c7d972

12 files changed

Lines changed: 224 additions & 105 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -174,23 +174,23 @@ The default remains the original green hacker-terminal presentation.
174174
Press `s` to cycle:
175175

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

195195
The layout responds to both terminal dimensions and the number of rate limits
196196
returned by Codex. Header, status, errors, footer, and meter grid divide the

‎internal/codex/live_usage.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import (
1717

1818
const (
1919
activeRolloutHorizon = 24 * time.Hour
20-
fullDiscoveryEvery = 30 * time.Second
20+
discoveryEvery = 5 * time.Second
21+
fullDiscoveryEvery = 5 * time.Minute
2122
)
2223

2324
// LiveUsageSnapshot is a process-local, monotonically increasing count built
@@ -36,6 +37,7 @@ type LiveUsageReader struct {
3637
mu sync.Mutex
3738
initialized bool
3839
startedAt time.Time
40+
lastDiscovery time.Time
3941
lastFullDiscovery time.Time
4042
files map[string]*rolloutCursor
4143
totalTokens int64
@@ -112,8 +114,11 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover
112114
if err := r.initialize(ctx, now); err != nil {
113115
return LiveUsageSnapshot{}, err
114116
}
115-
} else if err := r.discover(ctx, now, forceFullDiscovery || now.Sub(r.lastFullDiscovery) >= fullDiscoveryEvery); err != nil {
116-
return LiveUsageSnapshot{}, err
117+
} else if forceFullDiscovery || now.Sub(r.lastDiscovery) >= discoveryEvery {
118+
full := forceFullDiscovery || now.Sub(r.lastFullDiscovery) >= fullDiscoveryEvery
119+
if err := r.discover(ctx, now, full); err != nil {
120+
return LiveUsageSnapshot{}, err
121+
}
117122
}
118123

119124
var firstReadErr error
@@ -217,6 +222,7 @@ func (r *LiveUsageReader) discover(ctx context.Context, now time.Time, full bool
217222
if firstFileErr != nil && len(r.files) == 0 {
218223
return fmt.Errorf("read local Codex session telemetry: %w", firstFileErr)
219224
}
225+
r.lastDiscovery = now
220226
if full {
221227
r.lastFullDiscovery = now
222228
}

‎internal/codex/live_usage_test.go‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,15 @@ func TestLiveUsageReaderHandlesNewOldAndResetSessions(t *testing.T) {
7373

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

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

103+
func TestLiveUsageReaderThrottlesDiscoveryButConsumesKnownFiles(t *testing.T) {
104+
home := t.TempDir()
105+
now := time.Now()
106+
knownPath := testRolloutPath(t, home, now.Add(-time.Hour), "known-throttle")
107+
writeRollout(t, knownPath, tokenCountLine(now.Add(-time.Hour), 100)+"\n")
108+
reader, err := NewLiveUsageReader(home)
109+
if err != nil {
110+
t.Fatal(err)
111+
}
112+
if _, err := reader.FetchTokenUsage(context.Background()); err != nil {
113+
t.Fatal(err)
114+
}
115+
116+
appendRollout(t, knownPath, tokenCountLine(now, 150)+"\n")
117+
newPath := testRolloutPath(t, home, now.Add(time.Second), "new-throttle")
118+
writeRollout(t, newPath, tokenCountLine(now.Add(time.Second), 40)+"\n")
119+
usage, err := reader.FetchTokenUsage(context.Background())
120+
if err != nil || usage.TotalTokens != 50 {
121+
t.Fatalf("known cursor was not consumed independently of discovery: %#v, %v", usage, err)
122+
}
123+
if _, exists := reader.files[newPath]; exists {
124+
t.Fatal("new rollout was discovered before the discovery interval elapsed")
125+
}
126+
127+
reader.lastDiscovery = time.Now().Add(-discoveryEvery)
128+
usage, err = reader.FetchTokenUsage(context.Background())
129+
if err != nil || usage.TotalTokens != 90 {
130+
t.Fatalf("due discovery did not add new rollout usage: %#v, %v", usage, err)
131+
}
132+
}
133+
101134
func TestFreshLiveUsageReadNoticesAResumedDormantSession(t *testing.T) {
102135
home := t.TempDir()
103136
now := time.Now()

‎internal/ui/meters.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import (
1313
)
1414

1515
func usesMeterGrid(style meterStyleID) bool {
16-
return style >= styleBars && style < styleStopwatch
16+
switch style {
17+
case styleBars, stylePie, styleConsumptionPace, styleFuel:
18+
return true
19+
default:
20+
return false
21+
}
1722
}
1823

1924
func renderMeterGrid(width, height int, meters []codex.Meter, style meterStyleID, colors palette) string {

‎internal/ui/meters_test.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"github.com/merefield/codexometer/internal/codex"
1313
)
1414

15+
var quotaMeterStyles = []meterStyleID{styleBars, stylePie, styleConsumptionPace, styleFuel}
16+
1517
func TestEveryMeterStyleHasDistinctiveOutput(t *testing.T) {
1618
colors := paletteFor(themeHacker)
1719
tests := []struct {
@@ -33,7 +35,7 @@ func TestEveryMeterStyleHasDistinctiveOutput(t *testing.T) {
3335

3436
func TestEveryMeterStyleUsesAllocatedRectangle(t *testing.T) {
3537
colors := paletteFor(themeHacker)
36-
for style := styleBars; style < styleStopwatch; style++ {
38+
for _, style := range quotaMeterStyles {
3739
output := renderVisualizationSized(50, 12, 62, style, colors.primary, colors)
3840
if got := lipgloss.Width(output); got > 50 {
3941
t.Errorf("%s width = %d, exceeds allocation 50", style.name(), got)
@@ -53,7 +55,7 @@ func TestEveryStyleIncludesResetCycleGauge(t *testing.T) {
5355
Name: "1 HOUR",
5456
Window: codex.Window{UsedPercent: 40, WindowDurationMins: &duration, ResetsAt: &reset},
5557
}
56-
for style := styleBars; style < styleStopwatch; style++ {
58+
for _, style := range quotaMeterStyles {
5759
output := ansi.Strip(renderMeterArea(80, 18, meter, style, colors))
5860
if !strings.Contains(output, "RESET CYCLE 50%") {
5961
t.Errorf("%s missing reset-cycle comparison gauge:\n%s", style.name(), output)

‎internal/ui/model.go‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"time"
88

99
tea "github.com/charmbracelet/bubbletea"
10-
"github.com/charmbracelet/x/ansi"
1110

1211
"github.com/merefield/codexometer/internal/codex"
1312
)
@@ -282,25 +281,76 @@ func (m Model) footerButtonAt(x, y int) footerButtonID {
282281
if x < 0 || y < 0 {
283282
return footerButtonNone
284283
}
285-
lines := strings.Split(ansi.Strip(m.View()), "\n")
286-
if y >= len(lines) {
284+
if m.loading && len(m.snapshot.Meters()) == 0 {
287285
return footerButtonNone
288286
}
289287
if m.meterStyle == styleStopwatch {
290288
if button := m.stopwatchButtonAt(x, y); button != footerButtonNone {
291289
return button
292290
}
293291
}
294-
buttons, _ := footerButtonLayout(m.contentWidth())
292+
layout := m.dashboardLayout()
293+
if y != layout.footerY+1 {
294+
return footerButtonNone
295+
}
296+
localX := x - 2
297+
buttons, separator := footerButtonLayout(layout.contentWidth)
298+
buttonX := 0
295299
for _, button := range buttons {
296-
start := strings.Index(lines[y], button.label)
297-
if start >= 0 && x >= start && x < start+len(button.label) {
300+
if localX >= buttonX && localX < buttonX+len(button.label) {
298301
return button.id
299302
}
303+
buttonX += len(button.label) + len(separator)
300304
}
301305
return footerButtonNone
302306
}
303307

308+
type dashboardGeometry struct {
309+
contentWidth int
310+
meterHeight int
311+
meterY int
312+
footerY int
313+
}
314+
315+
func (m Model) dashboardLayout() dashboardGeometry {
316+
width := m.width
317+
if width == 0 {
318+
width = 80
319+
}
320+
height := m.height
321+
if height == 0 {
322+
height = 24
323+
}
324+
contentWidth := max(width-4, 1)
325+
contentHeight := max(height-2, 1)
326+
headerHeight := 3
327+
if contentWidth < 64 {
328+
headerHeight = 2
329+
}
330+
const statusHeight = 1
331+
const framedErrorHeight = 4
332+
const footerHeight = 2
333+
extraHeight := 0
334+
if m.err != nil {
335+
extraHeight += framedErrorHeight
336+
}
337+
if len(m.snapshot.Meters()) == 0 {
338+
extraHeight += framedErrorHeight
339+
}
340+
meterY := 1 + headerHeight + statusHeight + extraHeight
341+
meterHeight := max(contentHeight-headerHeight-statusHeight-extraHeight-footerHeight, 1)
342+
footerY := meterY
343+
if m.meterStyle == styleStopwatch || len(m.snapshot.Meters()) > 0 {
344+
footerY += meterHeight
345+
}
346+
return dashboardGeometry{
347+
contentWidth: contentWidth,
348+
meterHeight: meterHeight,
349+
meterY: meterY,
350+
footerY: footerY,
351+
}
352+
}
353+
304354
func (m Model) contentWidth() int {
305355
width := m.width
306356
if width == 0 {

‎internal/ui/model_test.go‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func TestThemeHotkeyCyclesAndWraps(t *testing.T) {
4141

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

176176
updated, _ = model.Update(footerMouseMessage(t, model, footerButtonStyle, tea.MouseActionPress))
177177
model = updated.(Model)
178-
if model.meterStyle != stylePie {
179-
t.Fatalf("style click selected %d, want %d", model.meterStyle, stylePie)
178+
if model.meterStyle != styleStopwatch {
179+
t.Fatalf("style click selected %d, want %d", model.meterStyle, styleStopwatch)
180180
}
181181

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

195+
func TestFooterHitGeometryMatchesRenderedButtonsAcrossSizes(t *testing.T) {
196+
for _, test := range []struct {
197+
name string
198+
width, height int
199+
empty bool
200+
withError bool
201+
style meterStyleID
202+
}{
203+
{name: "compact", width: 40, height: 24},
204+
{name: "standard", width: 80, height: 24},
205+
{name: "large error", width: 120, height: 40, withError: true},
206+
{name: "empty quota", width: 80, height: 24, empty: true},
207+
{name: "empty stopwatch", width: 80, height: 24, empty: true, style: styleStopwatch},
208+
} {
209+
t.Run(test.name, func(t *testing.T) {
210+
model := New(stubFetcher{snapshot: codex.DemoSnapshot()}, time.Minute)
211+
if !test.empty {
212+
model.snapshot = codex.DemoSnapshot()
213+
}
214+
model.loading = false
215+
model.width = test.width
216+
model.height = test.height
217+
model.meterStyle = test.style
218+
if test.withError {
219+
model.err = errors.New("stale quota signal")
220+
}
221+
layout := model.dashboardLayout()
222+
for _, id := range []footerButtonID{footerButtonTheme, footerButtonStyle, footerButtonRefresh, footerButtonQuit} {
223+
mouse := footerMouseMessage(t, model, id, tea.MouseActionMotion)
224+
if mouse.Y != layout.footerY+1 {
225+
t.Errorf("rendered footer y=%d, calculated y=%d", mouse.Y, layout.footerY+1)
226+
}
227+
if got := model.footerButtonAt(mouse.X, mouse.Y); got != id {
228+
t.Errorf("hit button %d, want %d", got, id)
229+
}
230+
}
231+
})
232+
}
233+
}
234+
195235
func TestFooterButtonHotkeyFlashesAndLatestPulseWins(t *testing.T) {
196236
model := New(stubFetcher{snapshot: codex.DemoSnapshot()}, time.Minute)
197237
model.snapshot = codex.DemoSnapshot()

0 commit comments

Comments
 (0)