From d75f50fb185807c3f8557b9ac2a6a6fbaf41f398 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Sun, 23 Aug 2026 11:10:40 +0000 Subject: [PATCH] fix(tui): never trim the lane dial on missing logins; make lean a real blend Two regressions from the #47/#49/#52 window, both hit live: - applyProviderAvailability deleted lanes from the dial, and required EVERY catalog pool connected before any blended lane survived. A catalog carrying the optional OpenRouter pool on a machine without an OpenRouter login therefore lost mixed and every led lane, leaving only gpt-only/claude-only - a missing optional credential nuked the blends between providers that WERE connected. Availability is now an annotation, not a trim: the dial always shows every catalog lane; unusable ones render struck with a ' - not logged in' note and cycling steps past them (laneUsable). A lane needs its policy pools plus the Required pools (pure lanes only their own), so an optional login gates exactly its own lanes. The selection is moved off unusable lanes (clampSel); suggestions skip them (quotaLane); noProviders still guards the accountless case. Fallback rungs on unconnected pools are filtered from the preview and the launch overlay (filterRows), so relief tails degrade instead of naming models omp cannot route, and the relief note only names spill pools a login actually backs. - laneSplit collapsed ox-lean into ('ox','led'), colliding with ox-led: the lean blend was unreachable from the split dial. laneSplit now carries the lane suffix verbatim, the blend dial offers led|lean|only (whatever the catalog serves per lead), and a lead switch re-clamps the blend to a served lane (composeLane) so gpt never lands on a nonexistent gpt-lean. --- main_test.go | 157 ++++++++++++++++++++++++++++++++++++++++------ model.go | 3 + providers.go | 56 ++++++++++++++--- render.go | 2 +- routing.go | 171 +++++++++++++++++++++++++++++++++++++++------------ suggest.go | 10 +-- update.go | 26 +++++++- view.go | 26 +++++++- 8 files changed, 370 insertions(+), 81 deletions(-) diff --git a/main_test.go b/main_test.go index 10fa17b..b6fd290 100644 --- a/main_test.go +++ b/main_test.go @@ -3752,36 +3752,59 @@ func TestApplyCatalogGrowsLaneDial(t *testing.T) { } } -func TestProviderAvailabilityFiltersDisconnectedLanes(t *testing.T) { - m := model{ - generated: map[string][]string{ - "gpt-only_fast_low_nosp_nofa": {" default gpt:low"}, - "gpt-led_fast_low_nosp_nofa": {" default gpt:low"}, - "mixed_fast_low_nosp_nofa": {" default gpt:low"}, - "claude-only_fast_low_nosp_nofa": {" default claude:low"}, - "ox-only_fast_low_nosp_nofa": {" default ox:low"}, - }, - facets: facetDefs(defaultGlyphs()), - sel: defaultSel(), - } +// TestProviderAvailabilityMarksDisconnectedLanes: credentials never shrink +// the dial — every catalog lane stays listed; the connected set decides which +// are usable (pickable) and the selection moves off an unusable lane. A +// missing optional login blocks exactly its own lanes, never the blends +// between connected providers. +func TestProviderAvailabilityMarksDisconnectedLanes(t *testing.T) { + catalog := map[string][]string{ + "gpt-only_fast_low_nosp_nofa": {" default gpt:low"}, + "gpt-led_fast_low_nosp_nofa": {" default gpt:low"}, + "mixed_fast_low_nosp_nofa": {" default gpt:low"}, + "claude-only_fast_low_nosp_nofa": {" default claude:low"}, + "ox-only_fast_low_nosp_nofa": {" default ox:low"}, + } + m := model{generated: catalog, facets: facetDefs(defaultGlyphs()), sel: defaultSel()} m.applyCatalog() - m.applyProviderAvailability(map[string]bool{"R": true}) + served := m.laneValues() - var lanes []string - for _, f := range m.facets { - if f.key == "lane" { - lanes = f.values + // The user's regression: O+A logged in, the catalog's optional R is not. + // Only the ox lane goes dark; every O/A lane and blend stays usable. + m.applyProviderAvailability(map[string]bool{"O": true, "A": true}) + if !reflect.DeepEqual(m.laneValues(), served) { + t.Fatalf("availability trimmed the dial: %v, want %v", m.laneValues(), served) + } + for _, lane := range []string{"gpt-only", "gpt-led", "mixed", "claude-only"} { + if !m.laneUsable(lane) { + t.Fatalf("connected-provider lane %q went unusable", lane) } } - if !reflect.DeepEqual(lanes, []string{"ox-only"}) { - t.Fatalf("OpenRouter-only lanes = %v, want [ox-only]", lanes) + if m.laneUsable("ox-only") { + t.Fatal("ox-only usable without an OpenRouter credential") + } + if m.sel["lane"] != "mixed" || m.noProviders { + t.Fatalf("selection = %q noProviders=%v, want mixed/false", m.sel["lane"], m.noProviders) + } + if note := m.disconnectedLeads([]string{"mixed", "gpt", "claude", "ox"}); note != "OpenRouter" { + t.Fatalf("lead note = %q, want OpenRouter", note) + } + + // The inverse setup: only OpenRouter is connected. The dial still shows + // everything; the selection lands on the one usable lane. + m.applyProviderAvailability(map[string]bool{"R": true}) + if !reflect.DeepEqual(m.laneValues(), served) { + t.Fatalf("availability trimmed the dial: %v", m.laneValues()) } if m.sel["lane"] != "ox-only" || m.noProviders { t.Fatalf("OpenRouter-only selection = %q noProviders=%v", m.sel["lane"], m.noProviders) } + if m.laneUsable("mixed") || m.laneUsable("gpt-only") { + t.Fatal("required-pool lanes usable without their credentials") + } for _, f := range m.visibleFacets() { if f.key == "blend" { - t.Fatalf("single available lane exposed an unavailable blend: %v", f.values) + t.Fatalf("single served blend exposed a blend dial: %v", f.values) } } @@ -3798,6 +3821,100 @@ func TestProviderAvailabilityFiltersDisconnectedLanes(t *testing.T) { } } +// TestCycleFacetSkipsUnusableLeads: an unconnected provider's lead is visible +// but not a stop — cycling steps past it onto the next usable lead, and stops +// at the edge when everything beyond is unusable. +func TestCycleFacetSkipsUnusableLeads(t *testing.T) { + catalog := map[string][]string{ + "gpt-only_fast_low_nosp_nofa": {" default gpt:low"}, + "gpt-led_fast_low_nosp_nofa": {" default gpt:low"}, + "mixed_fast_low_nosp_nofa": {" default gpt:low"}, + "claude-led_fast_low_nosp_nofa": {" default claude:low"}, + "claude-only_fast_low_nosp_nofa": {" default claude:low"}, + "ds-led_fast_low_nosp_nofa": {" default ds:low"}, + "ox-led_fast_low_nosp_nofa": {" default ox:low"}, + } + m := model{generated: catalog, facets: facetDefs(defaultGlyphs()), sel: defaultSel()} + m.applyCatalog() + m.applyProviderAvailability(map[string]bool{"O": true, "A": true}) + m.sel["lane"] = "claude-led" + m.visibleFacets() // sync lead/blend from lane + m.fcur = 0 + + // lead order: mixed gpt claude ds ox — right from claude must stop at + // claude (ds and ox are struck), not land on a dead lane. + m.cycleFacet(1) + if m.sel["lane"] != "claude-led" { + t.Fatalf("cycle onto unusable leads moved the lane: %q", m.sel["lane"]) + } + m.cycleFacet(-1) // back toward gpt: usable, normal stop + if m.sel["lane"] != "gpt-led" { + t.Fatalf("cycle to a usable lead = %q, want gpt-led", m.sel["lane"]) + } +} + +// TestLaneLeanBlend: ox-lean is a first-class blend — laneSplit/laneJoin +// round-trip it, the blend dial lists it between led and only, and a lead +// switch to a pool without a lean lane lands on that pool's led lane. +func TestLaneLeanBlend(t *testing.T) { + if lead, blend := laneSplit("ox-lean"); lead != "ox" || blend != "lean" { + t.Fatalf("laneSplit(ox-lean) = %q %q", lead, blend) + } + if lane := laneJoin("ox", "lean"); lane != "ox-lean" { + t.Fatalf("laneJoin(ox, lean) = %q", lane) + } + catalog := map[string][]string{ + "gpt-led_fast_low_nosp_nofa": {" default gpt:low"}, + "gpt-only_fast_low_nosp_nofa": {" default gpt:low"}, + "mixed_fast_low_nosp_nofa": {" default gpt:low"}, + "ox-led_fast_low_nosp_nofa": {" default ox:low"}, + "ox-lean_fast_low_nosp_nofa": {" default gpt:low"}, + "ox-only_fast_low_nosp_nofa": {" default ox:low"}, + } + m := model{generated: catalog, facets: facetDefs(defaultGlyphs()), sel: defaultSel()} + m.applyCatalog() + m.sel["lane"] = "ox-lean" + var blends []string + for _, f := range m.visibleFacets() { + if f.key == "blend" { + blends = f.values + } + } + if !reflect.DeepEqual(blends, []string{"led", "lean", "only"}) { + t.Fatalf("ox blends = %v, want [led lean only]", blends) + } + // A lead switch from ox-lean to gpt: gpt has no lean lane — land on led. + if lane := m.composeLane("gpt", "lean"); lane != "gpt-led" { + t.Fatalf("composeLane(gpt, lean) = %q, want gpt-led", lane) + } +} + +// TestFilterRowsDropsDisconnectedRungs: fallback rungs on pools nobody logged +// into vanish from routing rows (never the lead), so the preview and overlay +// only name models OMP can route. +func TestFilterRowsDropsDisconnectedRungs(t *testing.T) { + m := model{facts: map[string]modelFact{ + "gpt-5.6-terra": {pool: "O"}, + "claude-sonnet-5": {pool: "A"}, + "deepseek-v4": {pool: "D"}, + }} + m.providersResolved = true + m.connected = map[string]bool{"O": true, "A": true} + rows := []string{" ● task gpt-5.6-terra:medium → claude-sonnet-5:medium → deepseek-v4:medium"} + got := m.filterRows(rows)[0] + if strings.Contains(got, "deepseek") { + t.Fatalf("disconnected rung survived: %q", got) + } + if !strings.Contains(got, "gpt-5.6-terra:medium → claude-sonnet-5:medium") { + t.Fatalf("connected rungs mangled: %q", got) + } + // Before discovery resolves, rows pass through untouched. + m.providersResolved = false + if got := m.filterRows(rows)[0]; !strings.Contains(got, "deepseek-v4:medium") { + t.Fatalf("unresolved discovery must not filter: %q", got) + } +} + func TestDirectProviderProbeUsesOMPToken(t *testing.T) { dir := t.TempDir() script := filepath.Join(dir, "omp") diff --git a/model.go b/model.go index 84afb0c..54b23f5 100644 --- a/model.go +++ b/model.go @@ -32,6 +32,9 @@ type model struct { hasRelief bool // _rel_/_norel combos exist — show the relief dial providersResolved bool // connected-provider discovery completed noProviders bool // discovery found no provider usable by this catalog + // connected maps pool letters to "OMP holds a usable credential" — the + // probe result behind laneUsable and the struck lane rendering. + connected map[string]bool depth int // 0 lead · 1 full collapse bool // p: hide the Routing section diff --git a/providers.go b/providers.go index deda353..26540e6 100644 --- a/providers.go +++ b/providers.go @@ -249,22 +249,28 @@ func laneReliefApplies(lane string) bool { return true } +// laneBlends is the blend dial's canonical order: led (the lead pool drives +// everything), lean (the lead pool keeps the deliberative work and drains the +// rest elsewhere), only (pure). A lead offers the subset its catalog serves. +var laneBlends = []string{"led", "lean", "only"} + // laneSplit decomposes a lane into the two dials the TUI renders: the lead -// (a provider's lane segment, or "mixed") and the blend ("led" | "only"). -// mixed has no blend of its own; it reports "led" so a later lead change -// lands on the -led lane. +// (a provider's lane segment, or "mixed") and the blend ("led" | "lean" | +// "only" — the lane's suffix). mixed has no blend of its own; it reports +// "led" so a later lead change lands on the -led lane. func laneSplit(lane string) (lead, blend string) { if lane == "mixed" { return "mixed", "led" } + lead = lane blend = "led" - if lanePure(lane) { - blend = "only" + if seg, suffix, ok := strings.Cut(lane, "-"); ok { + lead, blend = seg, suffix } if p := providerByLane(lane); p != nil { - return p.Lane, blend + lead = p.Lane } - return lane, blend + return lead, blend } // laneJoin is laneSplit's inverse: the canonical lane a lead+blend pair names. @@ -275,6 +281,42 @@ func laneJoin(lead, blend string) string { return lead + "-" + blend } +// laneAvailable reports whether the connected credentials can serve the lane: +// a pure lane needs only its own provider, every blend needs the pools its +// policy leads roles on plus the Required pools its chains cross into. An +// optional pool nobody logged into therefore blocks exactly its own lanes — +// never the blends between the providers that are connected. +func laneAvailable(lane string, connected map[string]bool) bool { + pol, known := genLanePolicies[lane] + if !known { + // A lane this binary predates: require its own provider (if named) + // and the Required pools — the conservative reading of a blend. + if p := providerByLane(lane); p != nil { + if lanePure(lane) { + return connected[p.Pool] + } + if !connected[p.Pool] { + return false + } + } + } else { + if pol.pure { + return connected[pol.primary] + } + for _, pool := range []string{pol.primary, pol.delib, pol.util, pol.vision, pol.visionSmart} { + if pool != "" && !connected[pool] { + return false + } + } + } + for i := range providerRegistry { + if providerRegistry[i].Required && !connected[providerRegistry[i].Pool] { + return false + } + } + return true +} + // laneHostsSpecial reports whether a special-tier facet ("spark", "fable") can // be on for the lane: its provider's pool must be in the lane's pool-set. func laneHostsSpecial(lane, facet string) bool { diff --git a/render.go b/render.go index 5a1332d..3a28c14 100644 --- a/render.go +++ b/render.go @@ -147,7 +147,7 @@ func (m *model) syncPreviewAt(yoff int) { b.WriteString(stDim.Render("no connected OMP providers") + "\n") } else if base, ok := m.generated[id]; ok { _, roles := splitMeta(base) - roles = m.applyAdvisor(roles, m.sel["advisor"]) + roles = m.filterRows(m.applyAdvisor(roles, m.sel["advisor"])) b.WriteString(m.renderRoute(roles, m.depth, m.selectedLaunchAvailability(), rw)) } else { b.WriteString(stDim.Render("no profile for this combination") + "\n") diff --git a/routing.go b/routing.go index d053a2d..26b2a6a 100644 --- a/routing.go +++ b/routing.go @@ -55,7 +55,7 @@ const ( // currentRows is the routing block the cost/speed meters score: the generator's // facet combo with the advisor dial applied. func (m model) currentRows() []string { - return m.applyAdvisor(m.generated[comboID(m.sel, m.hasRelief)], m.sel["advisor"]) + return m.filterRows(m.applyAdvisor(m.generated[comboID(m.sel, m.hasRelief)], m.sel["advisor"])) } func (m model) weightedModels(rows []string, fn func(w float64, id, lvl string)) { @@ -350,7 +350,7 @@ func (m model) visibleFacets() []facet { } } var blends []string - for _, b := range []string{"led", "only"} { + for _, b := range laneBlends { if availableBlends[b] { blends = append(blends, b) } @@ -448,55 +448,133 @@ func connectedPools(accounts map[string][]account) map[string]bool { return pools } -// applyProviderAvailability narrows the generated lane catalog to credentials -// OMP can actually use. Pure lanes need only their own provider; blended lanes -// need every provider represented by the catalog because their role and -// fallback chains can cross the full pool set. -func (m *model) applyProviderAvailability(connected map[string]bool) { - m.providersResolved = true - catalog := catalogLanes(m.generated) - catalogPools := map[string]bool{} - for _, lane := range catalog { - if lanePure(lane) { - if provider := providerByLane(lane); provider != nil { - catalogPools[provider.Pool] = true - } +// laneValues is the lane facet's current value list — the catalog's lanes. +func (m model) laneValues() []string { + for _, f := range m.facets { + if f.key == "lane" { + return f.values } } - var available []string - for _, lane := range catalog { - if provider := providerByLane(lane); lanePure(lane) && provider != nil { - if connected[provider.Pool] { - available = append(available, lane) - } + return nil +} + +// laneUsable reports whether a lane is a real stop on the dial right now: the +// catalog serves it AND — once discovery has resolved — the connected +// credentials can run it. Before discovery resolves, everything served is +// usable, so the dial never flickers on startup. +func (m model) laneUsable(lane string) bool { + if !slices.Contains(m.laneValues(), lane) { + return false + } + return !m.providersResolved || laneAvailable(lane, m.connected) +} + +// disconnectedLeads names the lead-dial providers the credentials cannot run +// ("DeepSeek, OpenRouter") — the lead row's "log in to unlock" note. Empty +// before discovery resolves or when everything is connected. +func (m model) disconnectedLeads(leads []string) string { + if !m.providersResolved { + return "" + } + var out []string + for _, lead := range leads { + if lead == "mixed" { continue } - usable := len(catalogPools) > 0 - for pool := range catalogPools { - if !connected[pool] { - usable = false - break - } + if p := providerByLane(lead + "-led"); p != nil && !m.connected[p.Pool] { + out = append(out, p.AccountLabel) } - if usable { - available = append(available, lane) + } + return strings.Join(out, ", ") +} + +// connectedOptionalLabels names the pay-as-you-go pools a login actually +// backs — what relief can spill into right now. Before discovery resolves it +// falls back to every optional pool (the catalog's own promise). +func (m model) connectedOptionalLabels() string { + if !m.providersResolved { + return optionalPoolLabels() + } + var out []string + for i := range providerRegistry { + p := &providerRegistry[i] + if !p.Required && m.connected[p.Pool] { + out = append(out, p.Label) } } - m.noProviders = len(available) == 0 - if m.noProviders { - return + return strings.Join(out, ", ") +} + +// filterRows drops fallback rungs the connected credentials cannot serve from +// routing rows (a relief tail into a pool nobody logged into, say), so the +// preview and the launched overlay never name a model OMP cannot route. The +// lead token always stays — an unusable lead means an unusable lane, which +// laneUsable already keeps the selection off of. +func (m model) filterRows(rows []string) []string { + if !m.providersResolved { + return rows } - for i := range m.facets { - if m.facets[i].key == "lane" { - m.facets[i].values = available - break + out := make([]string, 0, len(rows)) + for _, r := range rows { + toks := strings.Split(r, " → ") + if len(toks) < 2 { + out = append(out, r) + continue } + kept := toks[:1] + for _, t := range toks[1:] { + id := t + if i := strings.LastIndexByte(t, ':'); i >= 0 { + id = t[:i] + } + pool := m.poolOfModel(strings.TrimSpace(id)) + if pool == "" || m.connected[pool] { + kept = append(kept, t) + } + } + out = append(out, strings.Join(kept, " → ")) } - served := make(map[string]bool, len(available)) - for _, lane := range available { - served[lane] = true + return out +} + +// composeLane joins a lead and a preferred blend into a lane the catalog +// serves: the preferred blend when that lane exists, else the first served +// blend for the lead (a lead switch from ox-lean to a pool without a lean +// lane lands on its led lane, never on a combo that was never generated). +func (m model) composeLane(lead, blend string) string { + if lead == "mixed" { + return "mixed" + } + served := m.laneValues() + if lane := laneJoin(lead, blend); slices.Contains(served, lane) { + return lane + } + for _, b := range laneBlends { + if lane := laneJoin(lead, b); slices.Contains(served, lane) { + return lane + } + } + return laneJoin(lead, blend) +} + +// applyProviderAvailability records which credentials OMP can actually use. +// The dial keeps every catalog lane — unavailable ones render struck and +// unpickable (see laneUsable) instead of vanishing, so a missing optional +// login never silently shrinks the dial; only the selection is moved off an +// unusable lane, preferring the canonical defaults. +func (m *model) applyProviderAvailability(connected map[string]bool) { + m.providersResolved = true + m.connected = connected + usable := false + for _, lane := range catalogLanes(m.generated) { + if laneAvailable(lane, connected) { + usable = true + } + } + m.noProviders = !usable + if m.noProviders { + return } - m.trimLanes(served) m.clampSel() } @@ -643,6 +721,17 @@ func (m *model) clampSel() { m.sel["lane"] = f.values[0] } } + // A lane the connected credentials cannot run moves to the first usable + // one, preferring the canonical defaults — the dial keeps showing it, + // struck, but the selection never rests on it. + if m.providersResolved && !m.noProviders && !laneAvailable(m.sel["lane"], m.connected) { + for _, lane := range append([]string{"mixed", "gpt-only", "claude-only"}, m.laneValues()...) { + if m.laneUsable(lane) { + m.sel["lane"] = lane + break + } + } + } } // laneColor tints the accent by lane: each provider carries a deeper shade for @@ -709,7 +798,7 @@ func (m model) poolOfModel(id string) string { // would keep the five agent-backed types pinned regardless of the generated // profile (issue atyrode/dotfiles#173). func (m model) genConfigYAML() string { - rows := m.applyAdvisor(m.generated[comboID(m.sel, m.hasRelief)], m.sel["advisor"]) + rows := m.currentRows() var mr, fc, ao strings.Builder advisorOn := false for _, r := range rows { diff --git a/suggest.go b/suggest.go index 57407f8..84b76af 100644 --- a/suggest.go +++ b/suggest.go @@ -250,21 +250,13 @@ func (m *model) quotaLane() string { if lead == nil || !m.avail.down(lead.mainBucket()) { return "" } - lanes := map[string]bool{} - for _, f := range m.facets { - if f.key == "lane" { - for _, v := range f.values { - lanes[v] = true - } - } - } for _, pool := range fallbackPoolOrder { p := providerByPool(pool) if p == nil || p.Pool == lead.Pool { continue } alt := p.Lane + "-led" - if !lanes[alt] { + if !m.laneUsable(alt) { continue } if p.Metered { diff --git a/update.go b/update.go index 6d3a9ba..8fc280c 100644 --- a/update.go +++ b/update.go @@ -315,7 +315,28 @@ func (m *model) cycleFacet(dir int) { idx = i } } + // lead/blend recompose the canonical lane; a value whose composed lane + // the connected credentials cannot run is visible but not a stop — keep + // stepping past it (and clamp at the ends like any other dial). + composed := func(i int) string { + switch f.key { + case "lead": + return m.composeLane(f.values[i], m.sel["blend"]) + case "blend": + return laneJoin(m.sel["lead"], f.values[i]) + } + return "" + } next := idx + dir + for f.key == "lead" || f.key == "blend" { + if next < 0 || next >= len(f.values) { + return // every further stop is out of range or unusable + } + if m.laneUsable(composed(next)) { + break + } + next += dir + } if next < 0 { next = 0 } else if next >= len(f.values) { @@ -327,7 +348,10 @@ func (m *model) cycleFacet(dir int) { m.sel[f.key] = f.values[next] // lead/blend are lane's rendered halves: recompose the canonical value // before anything re-derives them (visibleFacets syncs from lane). - if f.key == "lead" || f.key == "blend" { + switch f.key { + case "lead": + m.sel["lane"] = composed(next) + case "blend": m.sel["lane"] = laneJoin(m.sel["lead"], m.sel["blend"]) } // main is fable's sub-setting: whenever fable leaves "on" it must clear too, diff --git a/view.go b/view.go index 1d8ba6e..a96dfa6 100644 --- a/view.go +++ b/view.go @@ -333,7 +333,19 @@ func (m model) genLines() ([]string, int) { if f.key == "runtime" { display = m.runtimeValueLabel(v) } + // A lead/blend value whose composed lane the connected + // credentials cannot run stays visible — struck, unpickable — + // so a missing login reads as "log in", never as a gone dial. + usable := true + switch f.key { + case "lead": + usable = m.laneUsable(m.composeLane(v, m.sel["blend"])) + case "blend": + usable = m.laneUsable(laneJoin(m.sel["lead"], v)) + } switch { + case !usable && v != m.sel[f.key]: + row += " " + stStruck.Render(display) case v == m.sel[f.key]: col := acc if f.key == "lead" { @@ -351,6 +363,11 @@ func (m model) genLines() ([]string, int) { } } } + if f.key == "lead" { + if missing := m.disconnectedLeads(f.values); missing != "" { + row += " " + stDim.Render(missing+" — not logged in") + } + } switch { case (f.key == "fable" || f.key == "spark") && m.sel[f.key] == "on": bkt, lbl := "claude-fable", "Fable" @@ -368,10 +385,15 @@ func (m model) genLines() ([]string, int) { row += " " + stDim.Render("GPT only") case f.key == "relief": // relief is the least self-explanatory dial: say what it does, - // in the state it currently does it. + // in the state it currently does it — naming only the spill + // pools a login actually backs. note := "drained chains wait for quota reset" if m.sel["relief"] == "on" { - note = "drained chains spill into " + optionalPoolLabels() + if spill := m.connectedOptionalLabels(); spill != "" { + note = "drained chains spill into " + spill + } else { + note = "no pay-as-you-go login — drained chains wait" + } } row += " " + stDim.Render(note) }