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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions internal/tui/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
46 changes: 32 additions & 14 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -182,17 +182,35 @@ 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
}
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.
Expand Down