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.