Skip to content

Commit edc91a9

Browse files
committed
fix(tui): gauge shows live window fill, not cumulative session tokens
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.
1 parent cf6a867 commit edc91a9

7 files changed

Lines changed: 55 additions & 11 deletions

File tree

internal/client/client.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ type Event struct {
3838
Model string `json:"model"`
3939
Sandbox bool `json:"sandbox"`
4040

41-
// done — token economics for the turn and the session
41+
// done — token economics for the turn and the session. ContextTokens is the
42+
// live window fill for the turn (drops again after history trims); the
43+
// Session* pair are cumulative totals that only grow.
4244
Latency float64 `json:"latency"`
4345
ContextTokens int `json:"contextTokens"`
4446
OutputTokens int `json:"outputTokens"`

internal/tui/commands.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,13 @@ func (m *Model) showStats() {
221221
}
222222
mean := sumLat / float64(len(m.turnStats))
223223

224-
ctxVal := th.statsValue.Render(human(m.sessCtxTok))
224+
ctxVal := th.statsValue.Render(human(m.winCtxTok))
225225
if m.maxContext > 0 {
226-
ratio := float64(m.sessCtxTok) / float64(m.maxContext)
226+
ratio := float64(m.winCtxTok) / float64(m.maxContext)
227227
if ratio > 1 {
228228
ratio = 1
229229
}
230-
ctxVal = th.statsValue.Render(human(m.sessCtxTok)+"/"+humanCtx(m.maxContext)) +
230+
ctxVal = th.statsValue.Render(human(m.winCtxTok)+"/"+humanCtx(m.maxContext)) +
231231
" " + m.gaugeColor(ratio).Render(gaugeGlyph(ratio)) +
232232
" " + th.statsDim.Render(fmt.Sprintf("%d%%", int(ratio*100+0.5)))
233233
}

internal/tui/last_gaps_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func TestShowStatsBranches(t *testing.T) {
8484
m.thinkOn = true
8585
m.sessionID = "sess-123"
8686
m.maxContext = 100
87-
m.sessCtxTok = 250 // 250% → clamped to 100%
87+
m.winCtxTok = 250 // 250% → clamped to 100%
8888
m.sessionStart = time.Now().Add(-time.Minute)
8989
m.turnStats = []turnStats{
9090
{latency: 1.0, thought: true},
@@ -304,12 +304,12 @@ func TestCtxGaugeClamps(t *testing.T) {
304304
m := newTestModel()
305305
m.maxContext = 100
306306

307-
m.sessCtxTok = -5 // corrupt/negative accounting → clamp to 0%
307+
m.winCtxTok = -5 // corrupt/negative accounting → clamp to 0%
308308
if out := plain(m.ctxGauge(true)); !strings.Contains(out, "0%") {
309309
t.Errorf("negative ratio gauge = %q, want 0%%", out)
310310
}
311311

312-
m.sessCtxTok = 250 // overrun → clamp to 100%
312+
m.winCtxTok = 250 // overrun → clamp to 100%
313313
if out := plain(m.ctxGauge(false)); !strings.Contains(out, "100%") {
314314
t.Errorf("overrun ratio gauge = %q, want 100%%", out)
315315
}

internal/tui/model.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ type Model struct {
112112

113113
sessCtxTok int
114114
sessOutTok int
115+
winCtxTok int // live context-window fill: last turn's contextTokens
115116
lastLatency float64
116117

117118
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) {
494495
m.status = "ready"
495496
m.sessCtxTok = ev.SessionContextTokens
496497
m.sessOutTok = ev.SessionOutputTokens
498+
m.winCtxTok = ev.ContextTokens
497499
m.lastLatency = ev.Latency
498500

499501
case "error":
@@ -564,6 +566,7 @@ func (m *Model) clearConversation() {
564566
m.sessionStart = time.Time{}
565567
m.sessCtxTok = 0
566568
m.sessOutTok = 0
569+
m.winCtxTok = 0
567570
m.lastLatency = 0
568571
m.refresh()
569572
}

internal/tui/panels.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) {
238238
m.sessionStart = time.Time{}
239239
m.sessCtxTok = 0
240240
m.sessOutTok = 0
241+
m.winCtxTok = 0
241242
m.lastLatency = 0
242243
m.msgs = m.msgs[:0]
243244
m.convCount = -1 // transcript swapped for the resumed one — drop the cache

internal/tui/stats_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,40 @@ func TestNoStatLineWhileStreaming(t *testing.T) {
7878
}
7979
}
8080

81+
// TestGaugeFollowsTurnFill is a regression test: the header gauge must track
82+
// the last turn's contextTokens (the live window fill, which drops after odek
83+
// trims history), not sessionContextTokens, which is cumulative and only grows.
84+
func TestGaugeFollowsTurnFill(t *testing.T) {
85+
m := driveTurn(t, client.Event{
86+
Type: "done", Latency: 1,
87+
ContextTokens: 900, OutputTokens: 100,
88+
SessionContextTokens: 50000, SessionOutputTokens: 300,
89+
})
90+
m.model = "big"
91+
m.models = []client.ModelInfo{{ID: "big", MaxContext: 1000}}
92+
m.resolveMaxContext()
93+
if out := plain(m.header()); !strings.Contains(out, "90%") {
94+
t.Errorf("gauge should reflect the turn fill (90%%), got:\n%s", out)
95+
}
96+
97+
// Next turn comes back smaller after a history trim — the gauge drops,
98+
// even though the cumulative session total kept growing.
99+
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true})
100+
m.curIdx = len(m.msgs) - 1
101+
m.busy = true
102+
m.handleEvent(client.Event{
103+
Type: "done", Latency: 1,
104+
ContextTokens: 300, OutputTokens: 100,
105+
SessionContextTokens: 51200, SessionOutputTokens: 400,
106+
})
107+
if m.winCtxTok != 300 {
108+
t.Fatalf("winCtxTok = %d, want 300", m.winCtxTok)
109+
}
110+
if out := plain(m.header()); !strings.Contains(out, "30%") {
111+
t.Errorf("gauge should drop after a trim (30%%), got:\n%s", out)
112+
}
113+
}
114+
81115
func TestContextGauge(t *testing.T) {
82116
m := newTestModel()
83117
m.model = "big"
@@ -87,6 +121,7 @@ func TestContextGauge(t *testing.T) {
87121
t.Fatalf("maxContext = %d, want 1000", m.maxContext)
88122
}
89123
m.sessCtxTok = 380
124+
m.winCtxTok = 380
90125

91126
out := plain(m.header())
92127
for _, want := range []string{"○", "38%", "380/1k"} {

internal/tui/view.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,16 @@ func (m *Model) header() string {
8787

8888
// ctxGauge renders the context-window usage indicator for the header right
8989
// cluster: a pressure-tinted fill glyph, a percentage, and (when not compact)
90-
// the used/max fraction. Returns "" when the model's budget is unknown so the
91-
// header silently keeps its prior shape rather than guessing.
90+
// the used/max fraction. Usage is the last turn's contextTokens — the live
91+
// window fill, which drops again after odek trims history — never the
92+
// cumulative session total, which only grows. Returns "" when the model's
93+
// budget is unknown so the header silently keeps its prior shape rather than
94+
// guessing.
9295
func (m *Model) ctxGauge(compact bool) string {
9396
if m.maxContext <= 0 {
9497
return ""
9598
}
96-
ratio := float64(m.sessCtxTok) / float64(m.maxContext)
99+
ratio := float64(m.winCtxTok) / float64(m.maxContext)
97100
if ratio < 0 {
98101
ratio = 0
99102
}
@@ -105,7 +108,7 @@ func (m *Model) ctxGauge(compact bool) string {
105108
if !compact {
106109
// used via human() so it matches the adjacent "∑ ⌂ …" summary; max via
107110
// humanCtx() for a tidy whole-k budget.
108-
g += " " + m.th.headerMeta.Render(human(m.sessCtxTok)+"/"+humanCtx(m.maxContext))
111+
g += " " + m.th.headerMeta.Render(human(m.winCtxTok)+"/"+humanCtx(m.maxContext))
109112
}
110113
return g
111114
}

0 commit comments

Comments
 (0)