From edc91a93d1a8560d1c96cdd12d90ab0b6f24794c Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 25 Jul 2026 17:55:26 +0200 Subject: [PATCH] fix(tui): gauge shows live window fill, not cumulative session tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header gauge and /stats divided sessionContextTokens by maxContext, but odek reports that field as a cumulative sum (serve.go accumulates sessionInputTokens every turn). The gauge could only grow, pinning at 100%+ (e.g. 158.9k/131k) even though odek trims history to stay within budget. Track the last turn's contextTokens as winCtxTok — the live window fill — and drive the gauge and /stats ctx row from it, so the number drops back down after a trim. The ∑ ⌂ footer summary keeps the cumulative session total, which is what the sigma denotes. --- internal/client/client.go | 4 +++- internal/tui/commands.go | 6 +++--- internal/tui/last_gaps_test.go | 6 +++--- internal/tui/model.go | 3 +++ internal/tui/panels.go | 1 + internal/tui/stats_test.go | 35 ++++++++++++++++++++++++++++++++++ internal/tui/view.go | 11 +++++++---- 7 files changed, 55 insertions(+), 11 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index d7fc7ad..b28f97b 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -38,7 +38,9 @@ type Event struct { Model string `json:"model"` Sandbox bool `json:"sandbox"` - // done — token economics for the turn and the session + // done — token economics for the turn and the session. ContextTokens is the + // live window fill for the turn (drops again after history trims); the + // Session* pair are cumulative totals that only grow. Latency float64 `json:"latency"` ContextTokens int `json:"contextTokens"` OutputTokens int `json:"outputTokens"` diff --git a/internal/tui/commands.go b/internal/tui/commands.go index dac50e4..3950f14 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -221,13 +221,13 @@ func (m *Model) showStats() { } mean := sumLat / float64(len(m.turnStats)) - ctxVal := th.statsValue.Render(human(m.sessCtxTok)) + ctxVal := th.statsValue.Render(human(m.winCtxTok)) if m.maxContext > 0 { - ratio := float64(m.sessCtxTok) / float64(m.maxContext) + ratio := float64(m.winCtxTok) / float64(m.maxContext) if ratio > 1 { ratio = 1 } - ctxVal = th.statsValue.Render(human(m.sessCtxTok)+"/"+humanCtx(m.maxContext)) + + ctxVal = th.statsValue.Render(human(m.winCtxTok)+"/"+humanCtx(m.maxContext)) + " " + m.gaugeColor(ratio).Render(gaugeGlyph(ratio)) + " " + th.statsDim.Render(fmt.Sprintf("%d%%", int(ratio*100+0.5))) } diff --git a/internal/tui/last_gaps_test.go b/internal/tui/last_gaps_test.go index d1851e0..46bab43 100644 --- a/internal/tui/last_gaps_test.go +++ b/internal/tui/last_gaps_test.go @@ -84,7 +84,7 @@ func TestShowStatsBranches(t *testing.T) { m.thinkOn = true m.sessionID = "sess-123" m.maxContext = 100 - m.sessCtxTok = 250 // 250% → clamped to 100% + m.winCtxTok = 250 // 250% → clamped to 100% m.sessionStart = time.Now().Add(-time.Minute) m.turnStats = []turnStats{ {latency: 1.0, thought: true}, @@ -304,12 +304,12 @@ func TestCtxGaugeClamps(t *testing.T) { m := newTestModel() m.maxContext = 100 - m.sessCtxTok = -5 // corrupt/negative accounting → clamp to 0% + m.winCtxTok = -5 // corrupt/negative accounting → clamp to 0% if out := plain(m.ctxGauge(true)); !strings.Contains(out, "0%") { t.Errorf("negative ratio gauge = %q, want 0%%", out) } - m.sessCtxTok = 250 // overrun → clamp to 100% + m.winCtxTok = 250 // overrun → clamp to 100% if out := plain(m.ctxGauge(false)); !strings.Contains(out, "100%") { t.Errorf("overrun ratio gauge = %q, want 100%%", out) } diff --git a/internal/tui/model.go b/internal/tui/model.go index 115ad69..1516c0f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -112,6 +112,7 @@ type Model struct { sessCtxTok int sessOutTok int + winCtxTok int // live context-window fill: last turn's contextTokens lastLatency float64 maxContext int // active model's context window (0 = unknown → gauge hidden) @@ -494,6 +495,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.status = "ready" m.sessCtxTok = ev.SessionContextTokens m.sessOutTok = ev.SessionOutputTokens + m.winCtxTok = ev.ContextTokens m.lastLatency = ev.Latency case "error": @@ -564,6 +566,7 @@ func (m *Model) clearConversation() { m.sessionStart = time.Time{} m.sessCtxTok = 0 m.sessOutTok = 0 + m.winCtxTok = 0 m.lastLatency = 0 m.refresh() } diff --git a/internal/tui/panels.go b/internal/tui/panels.go index b680941..1a78d5a 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -238,6 +238,7 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) { m.sessionStart = time.Time{} m.sessCtxTok = 0 m.sessOutTok = 0 + m.winCtxTok = 0 m.lastLatency = 0 m.msgs = m.msgs[:0] m.convCount = -1 // transcript swapped for the resumed one — drop the cache diff --git a/internal/tui/stats_test.go b/internal/tui/stats_test.go index b67d04b..fe2a696 100644 --- a/internal/tui/stats_test.go +++ b/internal/tui/stats_test.go @@ -78,6 +78,40 @@ func TestNoStatLineWhileStreaming(t *testing.T) { } } +// TestGaugeFollowsTurnFill is a regression test: the header gauge must track +// the last turn's contextTokens (the live window fill, which drops after odek +// trims history), not sessionContextTokens, which is cumulative and only grows. +func TestGaugeFollowsTurnFill(t *testing.T) { + m := driveTurn(t, client.Event{ + Type: "done", Latency: 1, + ContextTokens: 900, OutputTokens: 100, + SessionContextTokens: 50000, SessionOutputTokens: 300, + }) + m.model = "big" + m.models = []client.ModelInfo{{ID: "big", MaxContext: 1000}} + m.resolveMaxContext() + if out := plain(m.header()); !strings.Contains(out, "90%") { + t.Errorf("gauge should reflect the turn fill (90%%), got:\n%s", out) + } + + // Next turn comes back smaller after a history trim — the gauge drops, + // even though the cumulative session total kept growing. + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = len(m.msgs) - 1 + m.busy = true + m.handleEvent(client.Event{ + Type: "done", Latency: 1, + ContextTokens: 300, OutputTokens: 100, + SessionContextTokens: 51200, SessionOutputTokens: 400, + }) + if m.winCtxTok != 300 { + t.Fatalf("winCtxTok = %d, want 300", m.winCtxTok) + } + if out := plain(m.header()); !strings.Contains(out, "30%") { + t.Errorf("gauge should drop after a trim (30%%), got:\n%s", out) + } +} + func TestContextGauge(t *testing.T) { m := newTestModel() m.model = "big" @@ -87,6 +121,7 @@ func TestContextGauge(t *testing.T) { t.Fatalf("maxContext = %d, want 1000", m.maxContext) } m.sessCtxTok = 380 + m.winCtxTok = 380 out := plain(m.header()) for _, want := range []string{"○", "38%", "380/1k"} { diff --git a/internal/tui/view.go b/internal/tui/view.go index 99cab4c..d69c0d3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -87,13 +87,16 @@ func (m *Model) header() string { // ctxGauge renders the context-window usage indicator for the header right // cluster: a pressure-tinted fill glyph, a percentage, and (when not compact) -// the used/max fraction. Returns "" when the model's budget is unknown so the -// header silently keeps its prior shape rather than guessing. +// the used/max fraction. Usage is the last turn's contextTokens — the live +// window fill, which drops again after odek trims history — never the +// cumulative session total, which only grows. Returns "" when the model's +// budget is unknown so the header silently keeps its prior shape rather than +// guessing. func (m *Model) ctxGauge(compact bool) string { if m.maxContext <= 0 { return "" } - ratio := float64(m.sessCtxTok) / float64(m.maxContext) + ratio := float64(m.winCtxTok) / float64(m.maxContext) if ratio < 0 { ratio = 0 } @@ -105,7 +108,7 @@ func (m *Model) ctxGauge(compact bool) string { if !compact { // used via human() so it matches the adjacent "∑ ⌂ …" summary; max via // humanCtx() for a tidy whole-k budget. - g += " " + m.th.headerMeta.Render(human(m.sessCtxTok)+"/"+humanCtx(m.maxContext)) + g += " " + m.th.headerMeta.Render(human(m.winCtxTok)+"/"+humanCtx(m.maxContext)) } return g }