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
4 changes: 3 additions & 1 deletion internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/last_gaps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions internal/tui/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"} {
Expand Down
11 changes: 7 additions & 4 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down