From dee8dcfb98a6f1bb173a434f7fff938741cd2ad3 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 22 Aug 2026 10:39:47 +0200 Subject: [PATCH] fix(tui): drop session token summary from header, sharpen context gauge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's right cluster carried a cumulative session summary (∑ ⌂ in · ⎇ out) that duplicated /stats and the per-turn footers and flashed placeholder zeros on a fresh session. Remove it — the context gauge becomes the header's sole token metric. The gauge earns the slot: eighth-block sub-cell fill (█▉░░░ instead of ▓▓░░░), a pressure-tinted percentage sharing the bar's green→amber→red hue, a dim ctx label, and raw used/max receding into the meta style. /stats inherits the sharper fill for free. --- README.md | 4 +++- internal/tui/stats_test.go | 30 ++++++++++++++++++++----- internal/tui/view.go | 46 ++++++++++++++++++++++++++------------ 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b4ed0d3..89ea952 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,9 @@ one `Esc`. - **Cancellation** (`Esc`) — abort a running turn via odek's cancel API. - **Sandbox aware** — the header shows `🛡 sandboxed` or `⚠ host access`; pass `--sandbox` to run tool calls inside odek's Docker isolation. -- **Telemetry** — session token totals and last-turn latency in the chrome. +- **Telemetry** — a pressure-tinted context-window gauge in the header + (`ctx █▉░░░ 38% 380/1k`, eighth-block fill, green→amber→red), per-turn + token/latency footers, and the full session roll-up in `/stats`. - **Cost tracking** — when odek has token prices configured (limits), the header shows the running session spend, each turn footer its estimated cost, and `/stats` rolls up the session (with the `max_cost_usd` cap when diff --git a/internal/tui/stats_test.go b/internal/tui/stats_test.go index c432e5e..f7213c2 100644 --- a/internal/tui/stats_test.go +++ b/internal/tui/stats_test.go @@ -232,18 +232,36 @@ func TestContextGauge(t *testing.T) { m.winCtxTok = 380 out := plain(m.header()) - for _, want := range []string{"▓▓░░░", "38%", "380/1k"} { + for _, want := range []string{"█▉░░░", "38%", "380/1k"} { if !strings.Contains(out, want) { t.Errorf("header gauge missing %q in:\n%s", want, out) } } + // The gauge is the header's sole token metric: the cumulative session + // summary (∑ ⌂ … · ⎇ …) lives in /stats and the per-turn stat line, not + // here — a fresh session must not flash placeholder zeros in the bar. + m.sessCtxTok, m.sessOutTok = 0, 0 + out = plain(m.header()) + for _, banned := range []string{"∑", "⌂ 0", "⎇ 0"} { + if strings.Contains(out, banned) { + t.Errorf("header still carries session summary %q:\n%s", banned, out) + } + } + if !strings.Contains(out, "ctx") { + t.Errorf("gauge should carry the ctx label:\n%s", out) + } - // The five-cell fill bar tracks the ratio (the WebUI's ctx ▓▓▓░░ idiom). - if g := gaugeGlyph(0.80); g != "▓▓▓▓░" { - t.Errorf("gaugeGlyph(0.80) = %q, want ▓▓▓▓░", g) + // The five-cell fill bar tracks the ratio with eighth-block sub-cell + // precision (the WebUI's ctx ▓▓▓░░ idiom, sharpened): full cells are █, + // the leading edge rounds to the nearest eighth block, the rest stays ░. + if g := gaugeGlyph(0.80); g != "████░" { + t.Errorf("gaugeGlyph(0.80) = %q, want ████░", g) + } + if g := gaugeGlyph(0.95); g != "████▊" { + t.Errorf("gaugeGlyph(0.95) = %q, want ████▊", g) } - if g := gaugeGlyph(0.95); g != "▓▓▓▓▓" { - t.Errorf("gaugeGlyph(0.95) = %q, want ▓▓▓▓▓", g) + if g := gaugeGlyph(1.0); g != "█████" { + t.Errorf("gaugeGlyph(1.0) = %q, want █████", g) } // Unknown budget hides the gauge entirely (no percent sign in the header). diff --git a/internal/tui/view.go b/internal/tui/view.go index 4584372..68bf87e 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -80,14 +80,11 @@ func (m *Model) header() string { } status := m.statusBadge() - tokens := th.headerMeta.Render(fmt.Sprintf("∑ ⌂ %s · ⎇ %s", - human(m.sessCtxTok), human(m.sessOutTok))) - sep := th.headerMeta.Render(" · ") + // The gauge is the header's sole token metric — session totals live in + // /stats and the per-turn stat line, so a fresh session never flashes + // placeholder zeros up here. buildRight := func(gauge string) string { - right := tokens - if gauge != "" { - right = gauge + sep + right - } + right := gauge if status != "" { // empty while busy — progress rides the status line right += " " + status } @@ -147,10 +144,13 @@ func (m *Model) ctxGauge(compact bool) string { ratio = 1 } pct := fmt.Sprintf("%d%%", int(ratio*100+0.5)) - g := m.gaugeColor(ratio).Render(gaugeGlyph(ratio)) + " " + m.th.headerMeta.Render(pct) + // Color carries state, dim carries magnitude: bar and percent share the + // pressure tint, raw token counts recede. The "ctx" label anchors the + // WebUI's documented `ctx ▓▓▓░░ 40%` idiom. + g := m.th.headerMeta.Render("ctx ") + + m.gaugeColor(ratio).Render(gaugeGlyph(ratio)+" "+pct) if !compact { - // used via human() so it matches the adjacent "∑ ⌂ …" summary; max via - // humanCtx() for a tidy whole-k budget. + // used via human(); max via humanCtx() for a tidy whole-k budget. g += " " + m.th.headerMeta.Render(human(m.winCtxTok)+"/"+humanCtx(m.maxContext)) } return g @@ -182,8 +182,10 @@ func (m *Model) sandboxBadge() string { // gaugeGlyph mirrors the gaugeColor bands (0.75 / 0.90) so fill and hue tell // the same story: open while comfortable, half when warm, full when hot. -// gaugeGlyph renders the context gauge as a five-cell fill bar — the -// WebUI's documented `ctx ▓▓▓░░ 40%` idiom. +// gaugeGlyph renders the context gauge as a five-cell fill bar with +// eighth-block sub-cell precision — the WebUI's documented `ctx ▓▓▓░░ 40%` +// idiom, sharpened: full cells are █, the leading edge rounds to the nearest +// eighth block (▏▎▍▌▋▊▉), the remainder stays ░. func gaugeGlyph(r float64) string { if r < 0 { r = 0 @@ -191,8 +193,24 @@ func gaugeGlyph(r float64) string { if r > 1 { r = 1 } - filled := int(r*5 + 0.5) - return strings.Repeat("▓", filled) + strings.Repeat("░", 5-filled) + const cells = 5 + v := r * cells + full := int(v) + bar := strings.Repeat("█", full) + if full < cells { + // eighths rounds the leading cell's fill to the nearest eighth; + // 0x2588 is █ and each codepoint down to 0x258F (▏) is one eighth + // lighter. A rounding to 8 lands back on █; a rounding to 0 stays + // ░ — the cell always renders, five cells total. + eighths := int((v-float64(full))*8 + 0.5) + if eighths > 0 { + bar += string(rune(0x2590 - eighths)) + } else { + bar += "░" + } + bar += strings.Repeat("░", cells-full-1) + } + return bar } // rule returns a full-width gradient hairline, cached per width.