diff --git a/cmd/mecatui/customization/defaults_test.go b/cmd/mecatui/customization/defaults_test.go
index 19823345a1..fac79e303b 100644
--- a/cmd/mecatui/customization/defaults_test.go
+++ b/cmd/mecatui/customization/defaults_test.go
@@ -118,6 +118,97 @@ func TestDefaultTitleTemplateElidesWideSessionTitle(t *testing.T) {
}
}
+func TestCanonicalStatus_Scenario3_UnknownContextDoesNotClaimZeroPressure(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ footerCols int
+ want string
+ delegation Delegation
+ wantTeam bool
+ }{
+ {name: "full", footerCols: 120, want: "ctx ?/200K"},
+ {name: "compact", footerCols: 20, want: "ctx ?", delegation: Delegation{Team: LiveTeam{Working: 1, Total: 1}}, wantTeam: true},
+ {name: "minimal", footerCols: 11, want: "ctx ?", delegation: Delegation{Team: LiveTeam{Working: 1, Total: 1}}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ footer := stockFooter(t, Input{
+ Context: Context{Used: ContextAtom{Human: "?"}, Window: ContextAtom{Raw: 200_000, Human: "200K"}},
+ Delegation: tc.delegation,
+ Terminal: Terminal{FooterAvailCols: tc.footerCols},
+ })
+ got := statusSurfaceText(footer)
+ if !strings.Contains(got, tc.want) {
+ t.Fatalf("footer = %q, want unknown context containing %q", got, tc.want)
+ }
+ if tc.wantTeam {
+ if !strings.Contains(got, "⟳ 1/1") || strings.Contains(got, "working") {
+ t.Fatalf("footer = %q, want compact team cue without the full team ratio", got)
+ }
+ } else if strings.Contains(got, "⟳") {
+ t.Fatalf("footer = %q, retained compact delegation content instead of selecting minimal", got)
+ }
+ if strings.Contains(got, "ctx 0%") {
+ t.Fatalf("footer = %q, fabricated zero pressure for unknown context", statusSurfaceText(footer))
+ }
+ for _, span := range footer.Spans {
+ if span.Token == TokenSuccess || span.Token == TokenWarning {
+ t.Fatalf("footer = %#v, assigns a pressure token to unknown context", footer)
+ }
+ }
+ })
+ }
+}
+
+func TestCanonicalStatus_Scenario3_EstimatedContextMarkedAtEveryWidth(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ footerCols int
+ }{
+ {name: "full", footerCols: 120},
+ {name: "compact", footerCols: 20},
+ {name: "minimal", footerCols: 12},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ estimated := stockFooter(t, Input{
+ Context: Context{Used: ContextAtom{Raw: 75_000, Human: "~75K"}, Window: ContextAtom{Raw: 100_000, Human: "100K"}, Percent: 75, Known: true, Estimated: true},
+ Terminal: Terminal{FooterAvailCols: tc.footerCols},
+ })
+ if text := statusSurfaceText(estimated); !strings.Contains(text, "~75%") {
+ t.Fatalf("estimated footer = %q, want visibly estimated percentage", text)
+ }
+
+ known := stockFooter(t, Input{
+ Context: Context{Used: ContextAtom{Raw: 90_000, Human: "90K"}, Window: ContextAtom{Raw: 100_000, Human: "100K"}, Percent: 90, Known: true},
+ Terminal: Terminal{FooterAvailCols: tc.footerCols},
+ })
+ if text := statusSurfaceText(known); strings.Contains(text, "~90%") || !strings.Contains(text, "90%") {
+ t.Fatalf("known footer = %q, want ordinary percentage", text)
+ }
+ if tc.name != "minimal" {
+ for _, span := range known.Spans {
+ if span.Token == TokenWarning {
+ return
+ }
+ }
+ t.Fatalf("known footer = %#v, want warning pressure token", known)
+ }
+ })
+ }
+}
+
+func stockFooter(t *testing.T, input Input) Surface {
+ t.Helper()
+ source := NewDefaultSource(0)
+ t.Cleanup(func() { _ = source.Close(context.Background()) })
+ source.Submit(input)
+ select {
+ case <-source.Changed():
+ case <-time.After(time.Second):
+ t.Fatal("stock source did not publish")
+ }
+ return source.Latest().Footer
+}
+
func TestDefaultStatusHeadersElideSessionTitleByVariant(t *testing.T) {
const title = "界界界界界界界界界界界界界界界界界界界界"
for _, tc := range []struct {
diff --git a/cmd/mecatui/customization/source_correction_test.go b/cmd/mecatui/customization/source_correction_test.go
index f2815bd71f..7f09ba0b81 100644
--- a/cmd/mecatui/customization/source_correction_test.go
+++ b/cmd/mecatui/customization/source_correction_test.go
@@ -36,7 +36,7 @@ func TestStatusLine_DefaultTemplatesExposeLegacyDisplayAtoms(t *testing.T) {
Model: Model{ProviderID: "openai", DisplayName: "GPT-5", Route: "azure"},
Server: ServerTarget{DisplayTarget: "server.example", ConnectionMode: "connect"},
Usage: Usage{Input: UsageAtom{Raw: 4_000, Human: "4K"}, Output: UsageAtom{Raw: 1_000, Human: "1K"}, CacheWrite: UsageAtom{Raw: 500, Human: "500"}, CacheReadPercent: 75},
- Context: Context{Used: ContextAtom{Raw: 7_000, Human: "7K"}, Window: ContextAtom{Raw: 10_000, Human: "10K"}, Percent: 70},
+ Context: Context{Used: ContextAtom{Raw: 7_000, Human: "7K"}, Window: ContextAtom{Raw: 10_000, Human: "10K"}, Percent: 70, Known: true},
Delegation: Delegation{
Parallel: DelegationSummary{Running: 1, Finished: 2},
Subagents: DelegationSummary{Running: 3, Finished: 4},
@@ -87,7 +87,7 @@ func TestStatusLine_DefaultHeaderOmitsEmbeddedServerTarget(t *testing.T) {
func TestStatusLine_DefaultFooterSheddingPreservesContextPriority(t *testing.T) {
input := Input{
Usage: Usage{Input: UsageAtom{Raw: 4_000, Human: "4K"}, Output: UsageAtom{Raw: 1_000, Human: "1K"}, CacheReadPercent: 75},
- Context: Context{Used: ContextAtom{Raw: 7_000, Human: "7K"}, Window: ContextAtom{Raw: 10_000, Human: "10K"}, Percent: 70},
+ Context: Context{Used: ContextAtom{Raw: 7_000, Human: "7K"}, Window: ContextAtom{Raw: 10_000, Human: "10K"}, Percent: 70, Known: true},
Delegation: Delegation{Parallel: DelegationSummary{Running: 1, Finished: 2}, Subagents: DelegationSummary{Running: 3, Finished: 4}, Team: LiveTeam{ID: "abc", Working: 1, Total: 2}},
}
cases := []struct {
diff --git a/cmd/mecatui/customization/template.go b/cmd/mecatui/customization/template.go
index 2ad552c04e..c37e479b58 100644
--- a/cmd/mecatui/customization/template.go
+++ b/cmd/mecatui/customization/template.go
@@ -179,16 +179,16 @@ type templateContext struct {
// It emits only trusted StatusML markup; user-derived values remain escaped in
// templateContext. The UI resolves its semantic token through the active theme.
func contextMeter(input templateContext) templateText {
+ if !input.Known {
+ if input.Window.Raw > 0 {
+ return templateText("ctx ?/" + string(input.Window.Human) + "")
+ }
+ return templateText("ctx ?")
+ }
if input.Window.Raw <= 0 {
return templateText("ctx " + string(input.Used.Human) + "")
}
- percent := input.Percent
- if percent < 0 {
- percent = 0
- }
- if percent > 100 {
- percent = 100
- }
+ percent := clampedContextPercent(input.Percent)
var token, fill string
switch {
case percent >= 85:
@@ -203,7 +203,7 @@ func contextMeter(input templateContext) templateText {
filled = 8
}
bar := strings.Repeat(fill, filled) + strings.Repeat("░", 8-filled)
- label := "ctx " + bar + " " + strconv.Itoa(percent) + "%"
+ label := "ctx " + bar + " " + contextPercentLabel(input, percent)
if percent >= 85 {
label += " ⚠"
}
@@ -211,6 +211,9 @@ func contextMeter(input templateContext) templateText {
}
func contextMeterCompact(input templateContext) templateText {
+ if !input.Known {
+ return templateText("ctx ?")
+ }
if input.Window.Raw <= 0 {
return templateText("ctx " + string(input.Used.Human) + "")
}
@@ -218,7 +221,7 @@ func contextMeterCompact(input templateContext) templateText {
token, fill := contextPressure(percent)
filled := (percent*8 + 50) / 100
bar := strings.Repeat(fill, filled) + strings.Repeat("░", 8-filled)
- label := "ctx " + bar + " " + strconv.Itoa(percent) + "%"
+ label := "ctx " + bar + " " + contextPercentLabel(input, percent)
if percent >= 85 {
label += " ⚠"
}
@@ -226,12 +229,15 @@ func contextMeterCompact(input templateContext) templateText {
}
func contextMeterMinimal(input templateContext) templateText {
+ if !input.Known {
+ return templateText("ctx ?")
+ }
if input.Window.Raw <= 0 {
return templateText("ctx " + string(input.Used.Human) + "")
}
percent := clampedContextPercent(input.Percent)
token, _ := contextPressure(percent)
- label := "ctx " + strconv.Itoa(percent) + "%"
+ label := "ctx " + contextPercentLabel(input, percent)
if percent >= 85 {
label += " ⚠"
}
@@ -248,6 +254,14 @@ func clampedContextPercent(percent int) int {
return percent
}
+func contextPercentLabel(input templateContext, percent int) string {
+ prefix := ""
+ if input.Estimated {
+ prefix = "~"
+ }
+ return prefix + strconv.Itoa(percent) + "%"
+}
+
func contextPressure(percent int) (token, fill string) {
switch {
case percent >= 85:
diff --git a/cmd/mecatui/ui/agents_inventory_test.go b/cmd/mecatui/ui/agents_inventory_test.go
index e016bf4482..3383a48544 100644
--- a/cmd/mecatui/ui/agents_inventory_test.go
+++ b/cmd/mecatui/ui/agents_inventory_test.go
@@ -366,6 +366,7 @@ func TestAgentsInvPanelGolden(t *testing.T) {
if m.agentsInv.view != agentsInvPanel {
t.Fatalf("view = %v, want agentsInvPanel", m.agentsInv.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "agents_inventory.golden", got)
}
@@ -376,6 +377,7 @@ func TestAgentsInvPanelEmptyNotEnabledGolden(t *testing.T) {
m := newAgentsInvModel(t, &fakeAgents{}, client.Capabilities{Agents: false})
mm, cmd := m.runAgentsInv()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "agents_inventory_empty_disabled.golden", got)
}
@@ -386,6 +388,7 @@ func TestAgentsInvPanelEmptyEnabledGolden(t *testing.T) {
m := newAgentsInvModel(t, &fakeAgents{}, client.Capabilities{Agents: true})
mm, cmd := m.runAgentsInv()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "agents_inventory_empty_enabled.golden", got)
}
@@ -544,6 +547,7 @@ func TestAgentsInvPanelScrollGolden(t *testing.T) {
}
mm, _, _ = m.onAgentsInvKey(tea.KeyPressMsg{Code: tea.KeyPgDown})
m = mm.(Model)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "agents_inventory_scroll.golden", got)
}
diff --git a/cmd/mecatui/ui/agents_overlay.go b/cmd/mecatui/ui/agents_overlay.go
index ad0453fc66..049483c0ce 100644
--- a/cmd/mecatui/ui/agents_overlay.go
+++ b/cmd/mecatui/ui/agents_overlay.go
@@ -15,6 +15,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback"
)
@@ -1311,7 +1312,7 @@ func subagentRosterDetails(ln *subagentLane) string {
details = append(details,
subagentLaneState(ln),
plural(ln.toolCount, "tool"),
- "↑"+humanizeTokens(ln.usage.InputTokens)+" ↓"+humanizeTokens(ln.usage.OutputTokens))
+ "↑"+renderfmt.HumanizeTokens(ln.usage.InputTokens)+" ↓"+renderfmt.HumanizeTokens(ln.usage.OutputTokens))
return strings.Join(details, " · ")
}
@@ -1763,7 +1764,7 @@ func parallelBranchDetails(br *parallelBranch) string {
if routed := delegationModelLabel(br.routedCategory, br.routedModel, br.routingReason, br.model, br.routingDecision); routed != "" {
parts = append(parts, routed)
}
- parts = append(parts, parallelBranchState(br), plural(br.toolCount, "tool"), "↑"+humanizeTokens(br.usage.InputTokens)+" ↓"+humanizeTokens(br.usage.OutputTokens))
+ parts = append(parts, parallelBranchState(br), plural(br.toolCount, "tool"), "↑"+renderfmt.HumanizeTokens(br.usage.InputTokens)+" ↓"+renderfmt.HumanizeTokens(br.usage.OutputTokens))
return strings.Join(parts, " · ")
}
@@ -1781,7 +1782,7 @@ func parallelBranchState(br *parallelBranch) string {
if br.done {
state := parallelBranchStopLabel(br)
if br.durationMs > 0 {
- state += " · " + humanizeDuration(br.durationMs)
+ state += " · " + renderfmt.HumanizeDuration(br.durationMs)
}
return state
}
@@ -1818,7 +1819,7 @@ func parallelBranchLine(br *parallelBranch) string {
if br.done {
state = parallelBranchStopLabel(br)
if br.durationMs > 0 {
- state += " · " + humanizeDuration(br.durationMs)
+ state += " · " + renderfmt.HumanizeDuration(br.durationMs)
}
} else if br.current != "" {
state = truncate(terminaltext.Sanitize(br.current), maxTraceToolNameLen) + "…"
@@ -1830,8 +1831,8 @@ func parallelBranchLine(br *parallelBranch) string {
return fmt.Sprintf("%s %s · %s%s · %s · %s · ↑%s ↓%s",
glyph, terminaltext.Sanitize(label), goal, routed, state,
plural(br.toolCount, "tool"),
- humanizeTokens(br.usage.InputTokens),
- humanizeTokens(br.usage.OutputTokens))
+ renderfmt.HumanizeTokens(br.usage.InputTokens),
+ renderfmt.HumanizeTokens(br.usage.OutputTokens))
}
// parallelBranchStopLabel labels a finished branch: "failed" (with the stop reason when
diff --git a/cmd/mecatui/ui/agents_overlay_test.go b/cmd/mecatui/ui/agents_overlay_test.go
index 36875ba10a..fcf5721b1a 100644
--- a/cmd/mecatui/ui/agents_overlay_test.go
+++ b/cmd/mecatui/ui/agents_overlay_test.go
@@ -198,146 +198,6 @@ func TestFleetMissingChildIDDropped(t *testing.T) {
}
}
-// TestFooterHiddenWithoutSubagents asserts the footer has NO fleet segment when no
-// subagent has run — the no-subagent footer stays byte-identical to before.
-func TestFooterHiddenWithoutSubagents(t *testing.T) {
- m := newMCPModel(t, aztec(), nil)
- out := stripANSIstr(m.fitFooter(m.deps.Theme.Style("muted").Render("connected"), 120))
- if strings.Contains(out, "subagents") || strings.Contains(out, subagentFleetGlyph) {
- t.Errorf("footer should carry no fleet segment with zero subagents, got %q", out)
- }
-}
-
-// TestFooterShowsRunningAndDone asserts the footer fleet segment shows the
-// running/done counts and the f6 cue once subagents have run (mixed live+done).
-func TestFooterShowsRunningAndDone(t *testing.T) {
- m := newMCPModel(t, aztec(), nil)
- m = seedSubagents(m, "p1",
- startSub("p1", "c1", "audit auth"),
- toolSub("p1", "c1", "Grep", false, 1),
- startSub("p1", "c2", "map coverage"),
- startSub("p1", "c3", "trace config"),
- endSub("p1", "c3", 15000, 4000, 9, "end_turn"),
- )
- out := stripANSIstr(m.fitFooter(m.deps.Theme.Style("muted").Render("connected"), 160))
- if !strings.Contains(out, "subagents") {
- t.Errorf("footer should show the fleet segment, got %q", out)
- }
- // 2 running (c1, c2), 1 done (c3).
- if !strings.Contains(out, "2"+subagentRunGlyph) || !strings.Contains(out, "1"+subagentDoneGlyph) {
- t.Errorf("footer should show 2 running / 1 done, got %q", out)
- }
- if !strings.Contains(out, "f6") {
- t.Errorf("footer fleet segment should advertise f6, got %q", out)
- }
-}
-
-// TestFooterFleetTiers asserts the three fleet footer tiers degrade cleanly at
-// narrowing widths (full → medium → compact → dropped), like the team segment.
-func TestFooterFleetTiers(t *testing.T) {
- th := aztec()
- full := stripANSIstr(subagentFooterFull(th, 3, 1, defaultHelpKeys().agents))
- medium := stripANSIstr(subagentFooterMedium(3, 1, defaultHelpKeys().agents))
- compact := stripANSIstr(subagentFooterCompact(3, 1))
- if !strings.Contains(full, "subagents") {
- t.Errorf("full tier should name 'subagents', got %q", full)
- }
- if strings.Contains(medium, "subagents") {
- t.Errorf("medium tier should drop the word 'subagents', got %q", medium)
- }
- if !strings.Contains(medium, "f6") {
- t.Errorf("medium tier should keep the f6 cue, got %q", medium)
- }
- if strings.Contains(compact, "f6") {
- t.Errorf("compact tier should drop the f6 cue, got %q", compact)
- }
- for _, s := range []string{full, medium, compact} {
- if !strings.Contains(s, "3"+subagentRunGlyph) || !strings.Contains(s, "1"+subagentDoneGlyph) {
- t.Errorf("tier %q should carry the counts", s)
- }
- }
-}
-
-// TestFooterFleetTierSelection proves the SELECTION wiring (not just the builders):
-// fitFooter picks the medium tier (drops the "subagents" word, keeps "f6") when the
-// full tier won't fit, and the compact tier (drops "f6") when even medium won't fit.
-//
-// The candidate widths are reconstructed EXACTLY as fitFooter builds them (agents prefix
-// + sep + the matching ctx-meter tier), so the chosen test widths are deterministic
-// regardless of glyph widths. With no team and no ctx window the agents prefix is the
-// fleet segment alone and the meter tiers collapse to a short "ctx " form, so the
-// agents tier drives the choice.
-func TestFooterFleetTierSelection(t *testing.T) {
- th := aztec()
- m := newMCPModel(t, aztec(), nil)
- m = seedSubagents(m, "p1",
- startSub("p1", "c1", "audit auth"),
- startSub("p1", "c2", "map coverage"),
- startSub("p1", "c3", "trace config"),
- ) // 3 running, 0 done
- left := th.Style("muted").Render("connected")
- leftW := lipgloss.Width(left)
-
- // Rebuild the four agents-bearing candidates fitFooter forms (see view.go fitFooter).
- const sep = " "
- meter := renderContextMeter(th, m.contextTokens, m.contextWindow())
- meterCompact := renderContextMeterCompact(th, m.contextTokens, m.contextWindow())
- meterMinimal := renderContextMeterMinimal(th, m.contextTokens, m.contextWindow())
- full := subagentFooterFull(th, 3, 0, defaultHelpKeys().agents)
- medium := th.Style("spinner").Render(subagentFooterMedium(3, 0, defaultHelpKeys().agents))
- compact := th.Style("spinner").Render(subagentFooterCompact(3, 0))
- cand0 := full + sep + meter + " · " + renderUsageFacets(m.usage) // richest
- cand1 := full + sep + meter
- cand2 := medium + sep + meterCompact
- cand3 := compact + sep + meterMinimal
- w := func(s string) int { return leftW + lipgloss.Width(s) + footerGapPad }
-
- // Sanity: the candidates strictly narrow, so a between-width selects a single tier.
- if w(cand0) <= w(cand1) || w(cand1) <= w(cand2) || w(cand2) <= w(cand3) {
- t.Fatalf("test premise broken: candidate widths not strictly decreasing: %d %d %d %d",
- w(cand0), w(cand1), w(cand2), w(cand3))
- }
-
- // At a width that fits cand2 (medium tier) but NOT cand1 (full tier): medium chosen —
- // the "subagents" word is dropped, the f6 cue survives.
- out := stripANSIstr(m.fitFooter(left, w(cand2)))
- if strings.Contains(out, "subagents") {
- t.Errorf("medium-width footer should drop the 'subagents' word, got %q", out)
- }
- if !strings.Contains(out, "f6") {
- t.Errorf("medium-width footer should keep the f6 cue, got %q", out)
- }
-
- // At a width that fits cand3 (compact tier) but NOT cand2 (medium tier): compact
- // chosen — the f6 cue is dropped, the counts survive.
- out = stripANSIstr(m.fitFooter(left, w(cand3)))
- if strings.Contains(out, "f6") {
- t.Errorf("compact-width footer should drop the f6 cue, got %q", out)
- }
- if !strings.Contains(out, "3"+subagentRunGlyph) {
- t.Errorf("compact-width footer should still render the counts, got %q", out)
- }
-}
-
-// TestFooterFleetAndTeamCoexist asserts a session running BOTH a live team and
-// subagents shows BOTH segments in the footer at full width (the unified-overlay
-// premise: both are reachable).
-func TestFooterFleetAndTeamCoexist(t *testing.T) {
- m := newMCPModel(t, aztec(), nil)
- m = seedTeam(m, func(c *conversation) {
- c.startTeamCard("t1", "team-x", roster())
- c.updateTeamCardMember(member("scout", "tool.call", client.TeamMsg{ToolName: "Grep"}))
- })
- m = seedSubagents(m, "p1", startSub("p1", "c1", "audit auth"))
- out := stripANSIstr(m.fitFooter(m.deps.Theme.Style("muted").Render("connected"), 200))
- if !strings.Contains(out, "team-x") {
- t.Errorf("footer should keep the live-team segment, got %q", out)
- }
- if !strings.Contains(out, "subagents") {
- t.Errorf("footer should also show the fleet segment, got %q", out)
- }
-}
-
// --- iteration 7: unified tabbed overlay ----------------------------------
// TestF6OpensSubagentsTabWhenNoTeam asserts the context-sensitive default: with
@@ -917,71 +777,6 @@ func TestAgentsKeyboardPagingUsesPhysicalRowsAndIndicators(t *testing.T) {
}
}
-// --- footer goldens -------------------------------------------------------
-
-// TestFooterFleetGolden locks the three footer fleet states (no subagents / N running
-// / mixed running+done) as a single stripped golden of the fitFooter output, so a
-// regression in the segment format or the tiering is caught.
-func TestFooterFleetGolden(t *testing.T) {
- left := aztec().Style("muted").Render("connected")
-
- none := newMCPModel(t, aztec(), nil)
- running := seedSubagents(newMCPModel(t, aztec(), nil), "p1",
- startSub("p1", "c1", "audit auth"),
- startSub("p1", "c2", "map coverage"),
- startSub("p1", "c3", "trace config"),
- )
- mixed := seedSubagents(newMCPModel(t, aztec(), nil), "p1",
- startSub("p1", "c1", "audit auth"),
- startSub("p1", "c2", "map coverage"),
- startSub("p1", "c3", "trace config"),
- endSub("p1", "c3", 15000, 4000, 9, "end_turn"),
- )
-
- var b strings.Builder
- b.WriteString("no subagents:\n")
- b.WriteString(stripANSIstr(none.fitFooter(left, 160)) + "\n\n")
- b.WriteString("3 running:\n")
- b.WriteString(stripANSIstr(running.fitFooter(left, 160)) + "\n\n")
- b.WriteString("mixed 2 running + 1 done:\n")
- b.WriteString(stripANSIstr(mixed.fitFooter(left, 160)) + "\n")
- compareGolden(t, "footer_fleet.golden", []byte(b.String()))
-}
-
-// TestFooterCtxMeterGolden locks the footer context-meter END-TO-END through the
-// client→ui relay (issue #65): a SessionReadyMsg carrying a 200K ResolvedModel
-// window + a TurnEndMsg setting the 40K numerator must render the bar + "40K/200K"
-// with NO --context-window override. The snapshot is the stripped fitFooter output
-// at a wide width so the full-fidelity tier survives. It proves the new default
-// denominator source (the server-echoed window) reaches the meter via the reducer.
-func TestFooterCtxMeterGolden(t *testing.T) {
- m := newMCPModel(t, aztec(), nil)
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 160, Height: 30},
- client.SessionReadyMsg{
- SessionID: "sess-ctx-0001",
- ResolvedModel: client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5", ContextWindow: 200000},
- },
- client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 40000, OutputTokens: 1200}},
- )
- left := aztec().Style("muted").Render("connected")
- compareGolden(t, "footer_ctx_meter.golden", []byte(stripANSIstr(m.fitFooter(left, 160))+"\n"))
-}
-
-// TestFooterCtxUnknownGolden locks the unknown-window degrade: with NEITHER an
-// override nor a server-echoed window (older server / no resolved_model), the same
-// 40K numerator renders the bare "ctx 40K" with no bar and no denominator.
-func TestFooterCtxUnknownGolden(t *testing.T) {
- m := newMCPModel(t, aztec(), nil)
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 160, Height: 30},
- client.SessionReadyMsg{SessionID: "sess-ctx-0002"}, // zero ResolvedModel → unknown window
- client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 40000, OutputTokens: 1200}},
- )
- left := aztec().Style("muted").Render("connected")
- compareGolden(t, "footer_ctx_unknown.golden", []byte(stripANSIstr(m.fitFooter(left, 160))+"\n"))
-}
-
// --- overlay goldens ------------------------------------------------------
// goldenFleet builds a representative mixed fleet for the overlay goldens: three
@@ -1140,6 +935,7 @@ func TestSubagentRosterGolden(t *testing.T) {
if m.agentsTab != tabSubagents {
t.Fatalf("expected Subagents tab, got %v", m.agentsTab)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "subagent_roster.golden", got)
@@ -1157,6 +953,7 @@ func TestSubagentFocusGolden(t *testing.T) {
if m.subagents.view != subagentFocus {
t.Fatalf("expected subagentFocus, got %v", m.subagents.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "subagent_focus.golden", got)
@@ -1192,6 +989,7 @@ func TestSubagentRosterBackgroundGolden(t *testing.T) {
if m.agentsTab != tabSubagents {
t.Fatalf("expected Subagents tab, got %v", m.agentsTab)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "subagent_roster_background.golden", got)
@@ -1210,6 +1008,7 @@ func TestSubagentFocusBackgroundGolden(t *testing.T) {
if m.subagents.view != subagentFocus || m.subagents.child != "explorer-f8a6" {
t.Fatalf("expected focus on the background child, got view=%v child=%q", m.subagents.view, m.subagents.child)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "subagent_focus_background.golden", got)
@@ -1461,6 +1260,7 @@ func TestAgentsTeamsTabGolden(t *testing.T) {
if m.agentsTab != tabTeams {
t.Fatalf("expected Teams tab, got %v", m.agentsTab)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "agents_teams_tab.golden", got)
@@ -1496,6 +1296,7 @@ func TestParallelRosterGolden(t *testing.T) {
if m.agentsTab != tabParallel {
t.Fatalf("expected Parallel tab, got %v", m.agentsTab)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "parallel_roster.golden", got)
@@ -1553,6 +1354,7 @@ func TestParallelGroupFocusGolden(t *testing.T) {
if m.parallel.view != parallelGroupView {
t.Fatalf("expected parallelGroupView, got %v", m.parallel.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "parallel_group_focus.golden", got)
@@ -1560,43 +1362,8 @@ func TestParallelGroupFocusGolden(t *testing.T) {
// TestFooterParallelGolden locks the footer when a Parallel run is present, alongside the
// fleet segment, across tiers (a Parallel run + a subagent fleet both advertised).
-func TestFooterParallelGolden(t *testing.T) {
- left := aztec().Style("muted").Render("connected")
-
- parOnly := goldenParallel(newMCPModel(t, aztec(), nil))
- parPlusSub := seedSubagents(goldenParallel(newMCPModel(t, aztec(), nil)), "s1",
- startSub("s1", "c1", "audit auth"))
-
- var b strings.Builder
- b.WriteString("parallel only (judge, 3 done):\n")
- b.WriteString(stripANSIstr(parOnly.fitFooter(left, 160)) + "\n\n")
- b.WriteString("parallel + subagent fleet:\n")
- b.WriteString(stripANSIstr(parPlusSub.fitFooter(left, 160)) + "\n\n")
- b.WriteString("parallel + sub, narrow (medium tier):\n")
- b.WriteString(stripANSIstr(parPlusSub.fitFooter(left, 70)) + "\n")
- compareGolden(t, "footer_parallel.golden", []byte(b.String()))
-}
-
// TestParallelFooterTiers asserts the Parallel footer segment renders at each tier and the
// counts are correct, mirroring the subagent fleet footer tier test.
-func TestParallelFooterTiers(t *testing.T) {
- th := aztec()
- full := stripANSIstr(parallelFooterFull(th, 1, 2, defaultHelpKeys().agents))
- medium := stripANSIstr(parallelFooterMedium(1, 2, defaultHelpKeys().agents))
- compact := stripANSIstr(parallelFooterCompact(1, 2))
- if !strings.Contains(full, "parallel") || !strings.Contains(full, "f6") {
- t.Errorf("full tier should name parallel + f6: %q", full)
- }
- for _, s := range []string{full, medium, compact} {
- if !strings.Contains(s, "1◐") || !strings.Contains(s, "2✓") {
- t.Errorf("tier missing running/done counts: %q", s)
- }
- }
- if strings.Contains(compact, "f6") {
- t.Errorf("compact tier should drop f6: %q", compact)
- }
-}
-
// TestAgentsOverlayNothingRanHint asserts f6 with neither a team nor subagents
// surfaces the honest "nothing ran" hint and does NOT open the overlay.
func TestAgentsOverlayNothingRanHint(t *testing.T) {
diff --git a/cmd/mecatui/ui/args_view_test.go b/cmd/mecatui/ui/args_view_test.go
index d28c6d18c0..f47ebe42a2 100644
--- a/cmd/mecatui/ui/args_view_test.go
+++ b/cmd/mecatui/ui/args_view_test.go
@@ -590,6 +590,7 @@ func TestWheelScrollsConversationWithoutModal(t *testing.T) {
// TestAskArgsViewPrettyGolden locks the full-screen args view's pretty frame.
func TestAskArgsViewPrettyGolden(t *testing.T) {
m := openArgsView(t, shellAskModel(t, longShellArgs))
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "askargs_view_pretty.golden", got)
}
@@ -598,6 +599,7 @@ func TestAskArgsViewPrettyGolden(t *testing.T) {
func TestAskArgsViewRawGolden(t *testing.T) {
m := openArgsView(t, shellAskModel(t, longShellArgs))
m, _ = pressKey(m, tea.KeyPressMsg{Code: 'r', Text: "r"})
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "askargs_view_raw.golden", got)
}
@@ -613,6 +615,7 @@ func TestAskArgsViewScrolledGolden(t *testing.T) {
if approvalSurfaceOf(t, m).argsVP.YOffset() != 2 {
t.Fatalf("the tall fixture must admit YOffset 2, got %d — the golden would be vacuous", approvalSurfaceOf(t, m).argsVP.YOffset())
}
+ m = goldenStatusFrame(t, m)
got := string(stripANSI([]byte(m.View().Content)))
prettyGolden, err := os.ReadFile(filepath.Join("testdata", "askargs_view_pretty.golden")) //nolint:gosec // test golden
if err != nil {
@@ -661,6 +664,7 @@ func tallShellCommandLines(n int) []string {
// Shell ask (the wrapped, capped mini-viewport + hint).
func TestAskArgsModalLongShellGolden(t *testing.T) {
m := shellAskModel(t, longShellArgs)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "askargs_modal_longshell.golden", got)
}
diff --git a/cmd/mecatui/ui/argsummary_test.go b/cmd/mecatui/ui/argsummary_test.go
index 82ff4a0093..cedf8cf46f 100644
--- a/cmd/mecatui/ui/argsummary_test.go
+++ b/cmd/mecatui/ui/argsummary_test.go
@@ -228,33 +228,6 @@ func TestSummarizeArgsFallback(t *testing.T) {
}
}
-func TestHumanizeBytesSI(t *testing.T) {
- // SI/decimal math (1 KB = 1000 B, 1 MB = 1e6 B) — the number must reconcile
- // with bytes/1000, so a labelled "KB" is honest (not mislabelled KiB).
- cases := []struct {
- n int64
- want string
- }{
- {0, "0 B"},
- {-5, "0 B"},
- {999, "999 B"},
- {1000, "1 KB"}, // exactly 1000 → 1 KB (would be "0.98 KB" under 1024 math)
- {1500, "1.5 KB"}, // 1500/1000
- {5000, "5 KB"}, // trailing .0 trimmed
- {999999, "1000 KB"},
- {1000000, "1 MB"}, // exactly 1e6 → 1 MB
- {2500000, "2.5 MB"},
- {1_000_000_000, "1 GB"},
- {2_500_000_000, "2.5 GB"},
- {1_000_000_000_000, "1 TB"},
- }
- for _, c := range cases {
- if got := humanizeBytes(c.n); got != c.want {
- t.Errorf("humanizeBytes(%d) = %q, want %q", c.n, got, c.want)
- }
- }
-}
-
func TestParseMCPName(t *testing.T) {
server, tool, ok := parseMCPName("mcp__github__issue_write")
if !ok || server != "github" || tool != "issue_write" {
diff --git a/cmd/mecatui/ui/background_subagent_test.go b/cmd/mecatui/ui/background_subagent_test.go
index c55fee627f..eb008f614c 100644
--- a/cmd/mecatui/ui/background_subagent_test.go
+++ b/cmd/mecatui/ui/background_subagent_test.go
@@ -129,27 +129,3 @@ func TestForegroundSubagentEndNoTransientNotice(t *testing.T) {
// keeps counting a background child that spans TURNS as running (the fleet is
// session-scoped, keyed on subagent.end — not on turn boundaries), and flips it to
// done when its end finally arrives.
-func TestFooterCountsCrossTurnBackgroundChild(t *testing.T) {
- m := newMCPModel(t, aztec(), nil)
- m = seedSubagents(m, "p1", startBgSub("p1", "subagent-p1", "long audit"))
-
- // The turn the background child was started in ends; a new turn begins. The
- // child is still detached-running.
- for _, msg := range []interface{}{
- client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 900, OutputTokens: 100}},
- client.TurnStartMsg{Turn: 2},
- } {
- mm, _ := m.Update(msg)
- m = mm.(Model)
- }
- out := stripANSIstr(m.fitFooter(m.deps.Theme.Style("muted").Render("connected"), 160))
- if !strings.Contains(out, "1"+subagentRunGlyph+" 0"+subagentDoneGlyph) {
- t.Errorf("cross-turn background child must still count as running, got %q", out)
- }
-
- m = seedSubagents(m, "p2", endSub("p1", "subagent-p1", 9000, 1200, 4, "end_turn"))
- out = stripANSIstr(m.fitFooter(m.deps.Theme.Style("muted").Render("connected"), 160))
- if !strings.Contains(out, "0"+subagentRunGlyph+" 1"+subagentDoneGlyph) {
- t.Errorf("ended background child must count as done, got %q", out)
- }
-}
diff --git a/cmd/mecatui/ui/bounded_scroll_adoption_test.go b/cmd/mecatui/ui/bounded_scroll_adoption_test.go
index cd8fb7aca2..a99be31d63 100644
--- a/cmd/mecatui/ui/bounded_scroll_adoption_test.go
+++ b/cmd/mecatui/ui/bounded_scroll_adoption_test.go
@@ -551,6 +551,7 @@ func TestMecatuiBoundedScrollCursor_Scenario3_ModelClickSelectsEnterActivates(t
m = resize(m, 40, 35)
_ = m.View()
s = modelsSurface(t, m)
+ _, _ = s.Render(40, 12)
wheelCursor := s.list.Cursor()
s.HandleWheel(tea.MouseWheelMsg{Button: tea.MouseWheelDown})
if s.list.Offset() != 1 || s.list.Cursor() != wheelCursor {
diff --git a/cmd/mecatui/ui/canonical_status_test.go b/cmd/mecatui/ui/canonical_status_test.go
new file mode 100644
index 0000000000..aaff471425
--- /dev/null
+++ b/cmd/mecatui/ui/canonical_status_test.go
@@ -0,0 +1,263 @@
+package ui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+
+ "github.com/stacklok/mecatl/cmd/mecatui/client"
+ customization "github.com/stacklok/mecatl/cmd/mecatui/customization"
+ "github.com/stacklok/mecatl/cmd/mecatui/theme"
+)
+
+func canonicalFrame(source customization.Source, width int) Model {
+ m := New(Deps{Ctx: context.Background(), Theme: theme.New("aztec", theme.AztecPalette()), StatusSource: source})
+ m.width, m.height = width, 24
+ m.phase = phaseIdle
+ m.sessionID = "session-1234567890"
+ m.resolvedSessionModel = client.ResolvedModel{ModelID: "canonical-model", ContextWindow: 200000}
+ m.contextTokens = 100000
+ m.usage.InputTokens = 1234
+ return m
+}
+
+func TestCanonicalStatus_Scenario1_StockSourceOwnsSurfaces(t *testing.T) {
+ for _, tc := range []struct {
+ name, wantFooter, absentFooter string
+ width int
+ }{
+ {name: "wide full", width: 120, wantFooter: "100K/200K"},
+ {name: "narrow compact", width: 48, wantFooter: "ctx ▒▒▒▒░░░░ 50%", absentFooter: "100K/200K"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ source := customization.NewDefaultSource(0)
+ t.Cleanup(func() { _ = source.Close(context.Background()) })
+ m := canonicalFrame(source, tc.width)
+ source.Submit(m.statusLineSnapshot())
+ updated, _ := m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ m = updated.(Model)
+ header, footer := stripANSIstr(m.renderHeader()), stripANSIstr(m.renderFooter())
+ if !m.generatedStatusLine.Header.Present || !m.generatedStatusLine.Footer.Present || !strings.Contains(header, "canonical-model") || !strings.Contains(footer, tc.wantFooter) {
+ t.Fatalf("width %d stock surfaces: header %q footer %q result %#v", tc.width, header, footer, m.generatedStatusLine)
+ }
+ if tc.absentFooter != "" && strings.Contains(footer, tc.absentFooter) {
+ t.Fatalf("width %d retained wide footer content %q: %q", tc.width, tc.absentFooter, footer)
+ }
+ if strings.Count(header, "canonical-model") != 1 || strings.Count(footer, "ctx") != 1 || lipgloss.Width(strings.Split(header, "\n")[0]) > tc.width || lipgloss.Width(strings.Split(footer, "\n")[1]) > tc.width {
+ t.Fatalf("width %d duplicate or oversized renderer content: %q / %q", tc.width, header, footer)
+ }
+ })
+ }
+}
+
+func TestCanonicalStatus_Scenario1_EmptySourceDoesNotFallBack(t *testing.T) {
+ source := &statusSourceFake{changed: make(chan struct{}, 1)}
+ m := canonicalFrame(source, 110)
+ m.caps.Posture = postureAuto
+ m.generatedStatusLine = customization.Result{Header: customization.Surface{Present: true, Spans: []customization.Span{{Text: strings.Repeat("oversize", 100)}}}, Footer: customization.Surface{Present: true, Spans: []customization.Span{{Text: strings.Repeat("oversize", 100)}}}}
+ for _, result := range []customization.Result{m.generatedStatusLine, {}, {Header: customization.Surface{Present: true}, Footer: customization.Surface{Present: true}}} {
+ m.generatedStatusLine = result
+ header, footer := stripANSIstr(m.renderHeader()), stripANSIstr(m.renderFooter())
+ if strings.Contains(header, "session") || strings.Contains(header, "canonical-model") || strings.Contains(header, "oversize") || strings.Contains(header, "·") || strings.Contains(footer, "ctx") || strings.Contains(footer, "oversize") {
+ t.Fatalf("source fallback: %q / %q", header, footer)
+ }
+ if !strings.Contains(header, "auto") || !strings.Contains(footer, "ready") || !strings.Contains(footer, "help") {
+ t.Fatalf("lost mandatory chrome: %q / %q", header, footer)
+ }
+ }
+}
+
+func TestCanonicalStatus_Scenario1_NoSourceMinimalIdentity(t *testing.T) {
+ m := canonicalFrame(nil, 110)
+ m.caps.Posture = postureAuto
+ for _, id := range []string{"", "safe-1234567890", "hostile\x1b[31m\nmore-1234567890", "\xff"} {
+ m.sessionID = id
+ header, footer := stripANSIstr(m.renderHeader()), stripANSIstr(m.renderFooter())
+ if handle := client.SessionHandle(id); handle == "" {
+ if strings.Contains(header, "session") || strings.Contains(header, "connecting") {
+ t.Fatalf("unknown/invalid identity: %q", header)
+ }
+ } else if !strings.Contains(header, "session "+handle) {
+ t.Fatalf("known identity: %q", header)
+ }
+ if strings.Contains(header, "canonical-model") || strings.Contains(header, "[31m") || strings.Contains(footer, "ctx") || strings.Contains(footer, "↑") || !strings.Contains(header, "auto") || !strings.Contains(footer, "ready") || !strings.Contains(footer, "help") {
+ t.Fatalf("unsafe/legacy identity or lost chrome: %q / %q", header, footer)
+ }
+ }
+}
+
+func TestCanonicalStatus_Scenario1_NoSourceDebugKeepsBothIdentities(t *testing.T) {
+ m := canonicalFrame(nil, 120)
+ m.sessionID = "debug-session-1234567890\x1b[31m"
+ m.deps.DebugTarget = "target-session-1234567890\x1b[31m"
+ m.sessionTitle = "source identity"
+ cue := "⚠ DEBUG target " + client.SessionHandle(m.deps.DebugTarget)
+ handle := "session " + client.SessionHandle(m.sessionID)
+
+ t.Run("no source", func(t *testing.T) {
+ header := stripANSIstr(m.renderHeader())
+ if i, j := strings.Index(header, cue), strings.Index(header, handle); i < 0 || j <= i || strings.Contains(header, "[31m") || strings.Contains(header, "canonical-model") {
+ t.Fatalf("debugger and target identities: %q", header)
+ }
+ if footer := stripANSIstr(m.renderFooter()); strings.Contains(footer, "ctx") || !strings.Contains(footer, "help") {
+ t.Fatalf("nil-source footer: %q", footer)
+ }
+ })
+ t.Run("source backed", func(t *testing.T) {
+ m = stockStatusFrame(t, m)
+ header := stripANSIstr(m.renderHeader())
+ if i, j := strings.Index(header, cue), strings.Index(header, "source identity"); i < 0 || j <= i || strings.Contains(header, handle) {
+ t.Fatalf("debug prefix and stock identity: %q", header)
+ }
+ })
+}
+
+func TestCanonicalStatus_Scenario2_DebugWarningPrecedesGeneratedHeader(t *testing.T) {
+ for _, sourceKind := range []string{"stock", "configured"} {
+ m := canonicalFrame(&statusSourceFake{changed: make(chan struct{}, 1)}, 120)
+ m.deps.DebugTarget = "hostile\x1b[31m\nmore-1234567890"
+ text := "custom header"
+ if sourceKind == "stock" {
+ source := customization.NewDefaultSource(0)
+ t.Cleanup(func() { _ = source.Close(context.Background()) })
+ m.deps.StatusSource = source
+ source.Submit(m.statusLineSnapshot())
+ updated, _ := m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ m = updated.(Model)
+ text = "canonical-model"
+ } else {
+ m.generatedStatusLine.Header = customization.Surface{Present: true, Spans: []customization.Span{{Text: text}}}
+ }
+ cue := "⚠ DEBUG target " + client.SessionHandle(m.deps.DebugTarget)
+ header := stripANSIstr(m.renderHeader())
+ if i, j := strings.Index(header, cue), strings.Index(header, text); i < 0 || j <= i || strings.Contains(header, "[31m") {
+ t.Fatalf("%s warning/header: %q", sourceKind, header)
+ }
+ m.width = 38
+ header = strings.Join(strings.Fields(stripANSIstr(m.renderHeader())), " ")
+ if !strings.Contains(header, cue) || strings.Contains(header, "custom header") || strings.Contains(header, "canonical-model") {
+ t.Fatalf("%s narrow warning: %q", sourceKind, header)
+ }
+ }
+}
+
+func TestCanonicalStatus_Scenario2_DebugDisclosureSurvivesMissingSourceAndFatal(t *testing.T) {
+ for _, kind := range []string{"missing", "empty", "stale", "oversize"} {
+ m := canonicalFrame(nil, 40)
+ m.deps.DebugTarget = "target-1234567890"
+ m.deps.DebugMCP = []string{"github"}
+ if kind != "missing" {
+ source := &statusSourceFake{changed: make(chan struct{}, 1)}
+ m.deps.StatusSource = source
+ if kind == "stale" {
+ m.generatedStatusLine.Header = customization.Surface{Present: true, Spans: []customization.Span{{Text: "old status"}}}
+ source.publish(customization.Result{})
+ updated, _ := m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ m = updated.(Model)
+ if m.generatedStatusLine.Header.Present {
+ t.Fatal("stale header survived source update")
+ }
+ }
+ if kind == "empty" {
+ m.generatedStatusLine.Header = customization.Surface{Present: true}
+ }
+ if kind == "oversize" {
+ m.generatedStatusLine.Header = customization.Surface{Present: true, Spans: []customization.Span{{Text: strings.Repeat("too-wide", 50)}}}
+ }
+ }
+ for _, fatal := range []bool{false, true} {
+ if fatal {
+ m.phase = phaseFatal
+ }
+ rendered := m.renderHeader()
+ if fatal {
+ rendered = m.View().Content
+ }
+ plain := strings.Join(strings.Fields(stripANSIstr(rendered)), " ")
+ if !strings.Contains(plain, "⚠ DEBUG target "+client.SessionHandle(m.deps.DebugTarget)) || !strings.Contains(plain, "PRIVACY: target evidence sent to the configured model may include prompts, assistant output, tool arguments/results, file paths, and secrets") || !strings.Contains(plain, "availability does not authorize publication or sending.") {
+ t.Fatalf("%s fatal=%t missing disclosure: %q", kind, fatal, plain)
+ }
+ if kind == "empty" && strings.Contains(plain, "·") {
+ t.Fatalf("empty generated header added separator: %q", plain)
+ }
+ if fatal {
+ if lipgloss.Height(rendered) > m.height || !strings.Contains(plain, "quit") {
+ t.Fatalf("%s fatal outside viewport or no exit: %q", kind, plain)
+ }
+ }
+ }
+ }
+}
+
+func TestCanonicalStatus_Scenario2_DebugCommandSourceReceivesReservedWidth(t *testing.T) {
+ m := canonicalFrame(nil, 120)
+ m.deps.DebugTarget = "target-1234567890"
+ m.caps.Posture = postureAuto
+ m.conv.recordFileChange("changed.go")
+ cue := "⚠ DEBUG target " + client.SessionHandle(m.deps.DebugTarget)
+ budget := m.width - 2 - lipgloss.Width(cue) - 5 - lipgloss.Width(autoBadgeText) - 2 - lipgloss.Width("✎ 1 file") - headerGapPad
+ if budget <= 0 {
+ t.Fatalf("no command header budget: %d", budget)
+ }
+ identity := strings.Repeat("x", budget)
+ // Match the exact JSON field received on stdin, not a renderer-local width.
+ script := fmt.Sprintf(`read -r input; case "$input" in *'"HeaderAvailCols":%d,'*) printf '' ;; *) printf '' ;; esac`, budget, identity)
+ source := customization.NewCommandSource(customization.Command{Path: "/bin/sh", Args: []string{"-c", script}, LaunchDir: t.TempDir()})
+ t.Cleanup(func() { _ = source.Close(context.Background()) })
+ m.deps.StatusSource = source
+ m.submitStatusLine()
+ updated, _ := m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ m = updated.(Model)
+ header := stripANSIstr(m.renderHeader())
+ first := strings.Split(header, "\n")[0]
+ if i, j := strings.Index(first, cue), strings.Index(first, identity); i < 0 || j <= i || !strings.Contains(first, autoBadgeText) || !strings.Contains(first, "✎ 1 file") || strings.Contains(header, "wrong-budget") || lipgloss.Width(first) > m.width {
+ t.Fatalf("command source width %d displaced debug warning or indicator: %q (surface %#v)", budget, header, m.generatedStatusLine.Header)
+ }
+ if !m.generatedStatusLine.Header.Present || !statusSurfaceFits(m.generatedStatusLine.Header, budget) || !strings.Contains(header, "PRIVACY:") {
+ t.Fatalf("command source did not fit beside mandatory debug chrome: %#v / %q", m.generatedStatusLine.Header, header)
+ }
+}
+
+func TestCanonicalStatus_Scenario2_DebugReservationReachesSource(t *testing.T) {
+ source := &statusSourceFake{changed: make(chan struct{}, 1)}
+ m := canonicalFrame(source, 120)
+ m.deps.DebugTarget = "target-1234567890"
+ m.width = 100
+ m = applyAll(m, tea.WindowSizeMsg{Width: 120, Height: 24})
+ first, ok := source.lastInput()
+ if !ok {
+ t.Fatal("no input")
+ }
+ warningWidth := lipgloss.Width(m.debugHeaderTarget())
+ if first.Terminal.HeaderAvailCols != m.statusLineGeometry().headerAvailable || first.Terminal.HeaderAvailCols > 120-2-warningWidth {
+ t.Fatalf("warning not reserved: %#v", first.Terminal)
+ }
+ m = applyAll(m, client.SessionReadyMsg{SessionID: m.sessionID, Capabilities: client.Capabilities{Posture: postureAuto}})
+ second, _ := source.lastInput()
+ if second.Terminal.HeaderAvailCols >= first.Terminal.HeaderAvailCols {
+ t.Fatalf("posture did not reserve: %d -> %d", first.Terminal.HeaderAvailCols, second.Terminal.HeaderAvailCols)
+ }
+ m = applyAll(m, client.ToolCallMsg{ID: "write-1", Name: "Write", Args: `{"path":"changed.go","content":"x"}`})
+ withNavigation, _ := source.lastInput()
+ if withNavigation.Terminal.HeaderAvailCols >= second.Terminal.HeaderAvailCols || !strings.Contains(stripANSIstr(m.renderHeader()), "✎ 1 file") {
+ t.Fatalf("navigation did not reserve: %d -> %d", second.Terminal.HeaderAvailCols, withNavigation.Terminal.HeaderAvailCols)
+ }
+ m = applyAll(m, tea.WindowSizeMsg{Width: 80, Height: 24})
+ third, _ := source.lastInput()
+ if third.Terminal.HeaderAvailCols >= second.Terminal.HeaderAvailCols || third.Terminal.HeaderAvailCols != m.statusLineGeometry().headerAvailable {
+ t.Fatalf("resize reservation: %d -> %d", second.Terminal.HeaderAvailCols, third.Terminal.HeaderAvailCols)
+ }
+ stock := customization.NewDefaultSource(0)
+ t.Cleanup(func() { _ = stock.Close(context.Background()) })
+ m.deps.StatusSource = stock
+ stock.Submit(m.statusLineSnapshot())
+ updated, _ := m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ m = updated.(Model)
+ if !m.generatedStatusLine.Header.Present || !statusSurfaceFits(m.generatedStatusLine.Header, third.Terminal.HeaderAvailCols) || !strings.Contains(stripANSIstr(m.renderHeader()), "⚠ DEBUG target") {
+ t.Fatalf("stock source displaced warning at reserved width %d: %#v", third.Terminal.HeaderAvailCols, m.generatedStatusLine.Header)
+ }
+}
diff --git a/cmd/mecatui/ui/capabilities_test.go b/cmd/mecatui/ui/capabilities_test.go
index 72bfcb2c1e..0e3091f2fa 100644
--- a/cmd/mecatui/ui/capabilities_test.go
+++ b/cmd/mecatui/ui/capabilities_test.go
@@ -114,12 +114,10 @@ func TestSessionReadyDefaultCapsAllFalse(t *testing.T) {
}
}
-// TestEffectiveModelInHeaderFromTurnZero asserts the effective model the server
-// resolved (echoed on SessionReadyMsg) lands in m.resolvedSessionModel AND renders in the
-// header from turn zero — and that NO model segment shows while still connecting (no
-// create response yet). It also covers the older-server nil-resolved-model graceful
-// degrade: zero value ⇒ still no segment, no crash.
-func TestEffectiveModelInHeaderFromTurnZero(t *testing.T) {
+// TestEffectiveModelFromTurnZero asserts the effective model the server resolved
+// (echoed on SessionReadyMsg) lands in m.resolvedSessionModel and in the canonical
+// status snapshot. Before the create response, no model fact is submitted.
+func TestEffectiveModelFromTurnZero(t *testing.T) {
resolved := client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5-effective", ContextWindow: 400000}
conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}, resolvedModel: resolved}
m := newTestModelFromDeps(Deps{
@@ -129,11 +127,11 @@ func TestEffectiveModelInHeaderFromTurnZero(t *testing.T) {
Ctx: context.Background(),
})
- // While connecting (before the create response), the header shows NO model
- // segment — the server owns the value and the ui must not guess it.
+ // While connecting (before the create response), the source has no model fact
+ // to render — the server owns the value and the UI must not guess it.
connecting := applyAll(m, tea.WindowSizeMsg{Width: 120, Height: 30})
- if strings.Contains(connecting.renderHeader(), "gpt-5-effective") {
- t.Fatalf("connecting header shows a model segment, want none:\n%s", connecting.renderHeader())
+ if got := connecting.statusLineSnapshot().Model; got.ID != "" || got.ProviderID != "" {
+ t.Fatalf("connecting status model = %+v, want empty", got)
}
ready := m.createSessionCmd()().(client.SessionReadyMsg)
@@ -144,8 +142,8 @@ func TestEffectiveModelInHeaderFromTurnZero(t *testing.T) {
if got.resolvedSessionModel != resolved {
t.Fatalf("m.resolvedSessionModel = %+v, want %+v", got.resolvedSessionModel, resolved)
}
- if !strings.Contains(got.renderHeader(), "gpt-5-effective") {
- t.Fatalf("header missing the effective model id from turn zero:\n%s", got.renderHeader())
+ if snapshot := got.statusLineSnapshot(); snapshot.Model.ProviderID != "openai" || snapshot.Model.ID != "gpt-5-effective" || snapshot.Model.ContextWindow.Raw != 400000 {
+ t.Fatalf("status snapshot model = %+v, want resolved model", snapshot.Model)
}
}
@@ -154,59 +152,10 @@ func TestEffectiveModelInHeaderFromTurnZero(t *testing.T) {
// m.resolvedSessionModel becomes the footer meter's denominator (no --context-window
// override), so a 40K occupancy renders the bar + "40K/400K" through the live
// reducer path.
-func TestEffectiveModelDrivesFooterMeter(t *testing.T) {
- resolved := client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5-effective", ContextWindow: 400000}
- conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}, resolvedModel: resolved}
- m := newTestModelFromDeps(Deps{
- Session: conv,
- Conv: conv,
- Theme: theme.New("aztec", theme.AztecPalette()),
- Ctx: context.Background(),
- })
- ready := m.createSessionCmd()().(client.SessionReadyMsg)
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 160, Height: 30},
- ready,
- client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 40000, OutputTokens: 800}},
- )
- if got := m.contextWindow(); got != 400000 {
- t.Fatalf("contextWindow() = %d, want 400000 (server-echoed)", got)
- }
- got := stripANSIstr(m.fitFooter("connected", 160))
- if !strings.Contains(got, "40K/400K") {
- t.Errorf("footer = %q, want it to contain %q (echo→render loop closed)", got, "40K/400K")
- }
-}
-
// TestFooterMeterFollowsModelSwitch covers req 4 ("follows model switch for free")
// directly: a model switch is just a fresh SessionReadyMsg carrying the new model's
// window, so applying one with a 200K window then a SECOND with a 400K window must
// move the footer denominator from /200K to /400K with NO --context-window override.
-func TestFooterMeterFollowsModelSwitch(t *testing.T) {
- m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette()))
- // First model: 200K window, 40K occupied.
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 160, Height: 30},
- client.SessionReadyMsg{
- SessionID: "sess-switch-0001",
- ResolvedModel: client.ResolvedModel{ProviderID: "openai", ModelID: "small", ContextWindow: 200000},
- },
- client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 40000}},
- )
- if got := stripANSIstr(m.fitFooter("connected", 160)); !strings.Contains(got, "/200K") {
- t.Fatalf("after first model the footer should show /200K, got %q", got)
- }
- // Switch model (a fresh SessionReadyMsg with a larger window) — the denominator
- // must follow with no other plumbing.
- m = applyAll(m, client.SessionReadyMsg{
- SessionID: "sess-switch-0002",
- ResolvedModel: client.ResolvedModel{ProviderID: "openai", ModelID: "large", ContextWindow: 400000},
- })
- if got := stripANSIstr(m.fitFooter("connected", 160)); !strings.Contains(got, "/400K") {
- t.Errorf("after model switch the footer should show /400K, got %q", got)
- }
-}
-
// TestEffectiveModelOlderServerNoSegment asserts an older server (nil resolved_model
// ⇒ zero value) leaves m.resolvedSessionModel zero and the header shows no model segment —
// graceful degrade, no crash.
diff --git a/cmd/mecatui/ui/debug_session_test.go b/cmd/mecatui/ui/debug_session_test.go
index 2e5cbcc1e8..6346968ac8 100644
--- a/cmd/mecatui/ui/debug_session_test.go
+++ b/cmd/mecatui/ui/debug_session_test.go
@@ -28,10 +28,10 @@ func TestDebugIdentityUsesNormalHeaderAcrossPhases(t *testing.T) {
rendered = m.View().Content
}
plain := stripANSIstr(rendered)
- if !strings.Contains(plain, "mecatui · "+want+" · session ") || strings.Contains(plain, "target[31m opaque") || strings.Contains(rendered, "\x1b[31m") {
+ if !strings.Contains(plain, "⚠ "+want) || strings.Contains(plain, "target[31m opaque") || strings.Contains(rendered, "\x1b[31m") {
t.Fatalf("phase %v debug header identity unsafe/missing: %q", p, plain)
}
- if !strings.Contains(rendered, m.deps.Theme.Style("warning").Bold(true).Render(want)) {
+ if !strings.Contains(rendered, m.deps.Theme.Style("warning").Bold(true).Render("⚠ "+want)) {
t.Fatalf("phase %v debug target lacks amber/bold treatment: %q", p, rendered)
}
}
diff --git a/cmd/mecatui/ui/effort_test.go b/cmd/mecatui/ui/effort_test.go
index b2322f3525..5e1658717a 100644
--- a/cmd/mecatui/ui/effort_test.go
+++ b/cmd/mecatui/ui/effort_test.go
@@ -445,6 +445,7 @@ func TestEffortPickerGolden(t *testing.T) {
if m.effort.view != effortPanel {
t.Fatalf("view = %v, want effortPanel", m.effort.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "effort_picker.golden", got)
}
@@ -533,25 +534,17 @@ func TestEffortPickerSwallowsOtherKeys(t *testing.T) {
}
}
-// TestEffortRendersInHeader asserts the resolved effort appears beside the model in
-// the header when set, and is absent when unset (the footer/header live display).
-func TestEffortRendersInHeader(t *testing.T) {
+// TestEffortReachesStatusSnapshot asserts the resolved effort is submitted beside
+// the effective model when set, and remains absent when unset.
+func TestEffortReachesStatusSnapshot(t *testing.T) {
m := newModelsModel(t, sampleModels(), &fakeStore{}, modelsCaps(), client.ModelSelection{})
- // Unset effort: no suffix beside the model. (The inventory is not loaded here, so
- // the header shows the raw effective model id "gpt-5", not the display name.)
- header := stripANSIstr(m.renderHeader())
- if !strings.Contains(header, "gpt-5") {
- t.Fatalf("header should show the model:\n%s", header)
+ if effort := m.statusLineSnapshot().Session.ReasoningEffort; effort != "" {
+ t.Fatalf("unset effort in status snapshot = %q, want empty", effort)
}
- if strings.Contains(header, "· high") {
- t.Fatalf("unset effort must not render a suffix:\n%s", header)
- }
- // Set effort: the suffix renders beside the model.
+
m.resolvedSessionModel.ReasoningEffort = "high"
- m.refreshView()
- header = stripANSIstr(m.renderHeader())
- if !strings.Contains(header, "· high") {
- t.Fatalf("a set effort should render a ` · high` suffix beside the model:\n%s", header)
+ if effort := m.statusLineSnapshot().Session.ReasoningEffort; effort != "high" {
+ t.Fatalf("status snapshot effort = %q, want high", effort)
}
}
diff --git a/cmd/mecatui/ui/footer.go b/cmd/mecatui/ui/footer.go
deleted file mode 100644
index e1739e9df4..0000000000
--- a/cmd/mecatui/ui/footer.go
+++ /dev/null
@@ -1,472 +0,0 @@
-package ui
-
-import (
- "fmt"
- "strings"
-
- "github.com/stacklok/mecatl/cmd/mecatui/client"
- "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
- "github.com/stacklok/mecatl/cmd/mecatui/theme"
-)
-
-// Context-meter pressure thresholds, as a fraction of the context window. The
-// bands echo the corpus guidance that compaction triggers around 70–80% full:
-// stay "ok" comfortably below it, "warn" approaching it, "danger" once over.
-const (
- ctxWarnFraction = 0.60
- ctxDangerFraction = 0.85
- ctxBarWidth = 8 // glyph cells in the meter bar
-)
-
-// Per-band fill glyphs. Pressure is encoded in the GLYPH (not just colour) so it
-// survives ANSI stripping and is legible to red/green-colourblind users: the
-// filled cells grow heavier with pressure — medium shade when ok, dark shade
-// when warning, full block in the danger band. The empty glyph is the light
-// shade (distinct from every fill glyph, and from the footer's "·" separator).
-const (
- ctxGlyphOk = "▒"
- ctxGlyphWarn = "▓"
- ctxGlyphDanger = "█"
- ctxGlyphEmpty = "░" // unfilled cells
-)
-
-// ctxDangerMark is appended to the percentage in the danger band — a non-colour
-// textual cue so "over budget" reads even with ANSI stripped.
-const ctxDangerMark = " ⚠"
-
-// teamLiveGlyph leads the footer team-summary segment when a team is LIVE. It is a
-// STATIC literal (the issue mockup's "⟳"), deliberately NOT the animated m.sp
-// spinner — the footer team segment is an advertisement, not a per-frame activity
-// indicator, so it must not force the model to re-render every tick.
-const teamLiveGlyph = "⟳"
-
-// teamFooterIDLimit caps the rune-length of the team id shown in the full footer
-// segment so a long id can't blow out the footer width before fitFooter even tiers
-// it. Rune-safe via truncate.
-const teamFooterIDLimit = 16
-
-// humanizeTokens renders a token count compactly: < 1000 verbatim, thousands as
-// "7.9K", millions as "1.2M". One decimal place, trailing ".0" trimmed
-// (e.g. 2000 → "2K", 7903 → "7.9K", 1200000 → "1.2M"). Negatives are clamped to
-// 0 (token counts are never negative on the wire).
-func humanizeTokens(n int64) string {
- if n < 0 {
- n = 0
- }
- switch {
- case n < 1000:
- return fmt.Sprintf("%d", n)
- case n < 1_000_000:
- return trimDecimal(float64(n)/1000.0) + "K"
- default:
- return trimDecimal(float64(n)/1_000_000.0) + "M"
- }
-}
-
-// trimDecimal formats v to one decimal place, dropping a redundant ".0".
-func trimDecimal(v float64) string {
- s := fmt.Sprintf("%.1f", v)
- return strings.TrimSuffix(s, ".0")
-}
-
-// cacheHitRate computes the session cache-hit rate exactly as
-// engine/session/usage.go does: CacheReadTokens / InputTokens, guarded against
-// divide-by-zero (→ 0). The result is a fraction in [0,1]. The rate is
-// meaningful because the provider adapters normalize CacheReadTokens ⊂
-// InputTokens (Anthropic's raw input_tokens excludes cache tokens; its adapter
-// folds them in), so the ratio can never exceed 1.
-func cacheHitRate(u client.Usage) float64 {
- if u.InputTokens <= 0 {
- return 0
- }
- return float64(u.CacheReadTokens) / float64(u.InputTokens)
-}
-
-// pctString formats a fraction in [0,1] as an integer percentage (e.g. 0.881 →
-// "88%"). Values are clamped to [0,100].
-func pctString(frac float64) string {
- p := int(frac*100 + 0.5)
- if p < 0 {
- p = 0
- }
- if p > 100 {
- p = 100
- }
- return fmt.Sprintf("%d%%", p)
-}
-
-// ctxFraction returns the clamped used/window fill fraction in [0,1].
-func ctxFraction(used, window int64) float64 {
- if window <= 0 {
- return 0
- }
- frac := float64(used) / float64(window)
- if frac < 0 {
- return 0
- }
- if frac > 1 {
- return 1
- }
- return frac
-}
-
-// ctxPressureSlot maps a fill fraction to the themed pressure slot name.
-func ctxPressureSlot(frac float64) string {
- switch {
- case frac >= ctxDangerFraction:
- return "ctxDanger"
- case frac >= ctxWarnFraction:
- return slotCtxWarn
- default:
- return "ctxOk"
- }
-}
-
-// ctxGlyph returns the per-band fill glyph for a fraction (non-colour pressure).
-func ctxGlyph(frac float64) string {
- switch {
- case frac >= ctxDangerFraction:
- return ctxGlyphDanger
- case frac >= ctxWarnFraction:
- return ctxGlyphWarn
- default:
- return ctxGlyphOk
- }
-}
-
-// ctxLabel is the bar-less percentage label, with the danger ⚠ cue appended in
-// the danger band. Used as the lowest-fidelity context tier ("ctx 92% ⚠").
-func ctxLabel(frac float64) string {
- label := pctString(frac)
- if frac >= ctxDangerFraction {
- label += ctxDangerMark
- }
- return label
-}
-
-// renderContextMeter renders the FULL-fidelity context segment for a known
-// numerator. renderContextMeterState additionally handles legacy snapshots where
-// the numerator is honestly unknown.
-func renderContextMeter(th theme.Theme, used, window int64) string {
- return renderContextMeterState(th, used, window, true, false)
-}
-
-func renderContextMeterState(th theme.Theme, used, window int64, known, estimated bool) string {
- if !known {
- if window <= 0 {
- return "ctx ?"
- }
- return "ctx ?/" + humanizeTokens(window)
- }
- if used < 0 {
- used = 0
- }
- prefix := ""
- if estimated {
- prefix = "~"
- }
- if window <= 0 {
- return "ctx " + prefix + humanizeTokens(used)
- }
- frac := ctxFraction(used, window)
- style := th.Style(ctxPressureSlot(frac))
- meter := style.Render(ctxBar(frac) + " " + ctxLabel(frac))
- return "ctx " + meter + " · " + prefix + humanizeTokens(used) + "/" + humanizeTokens(window)
-}
-
-// renderContextMeterCompact is the mid-fidelity tier: the coloured per-band bar +
-// label, WITHOUT the used/total suffix, e.g. "ctx ▓▓▓▓▓▓░░ 70%". Unknown window
-// degrades to the bare size, identical to the full tier.
-func renderContextMeterCompact(th theme.Theme, used, window int64) string {
- if used < 0 {
- used = 0
- }
- if window <= 0 {
- return "ctx " + humanizeTokens(used)
- }
- frac := ctxFraction(used, window)
- style := th.Style(ctxPressureSlot(frac))
- return "ctx " + style.Render(ctxBar(frac)+" "+ctxLabel(frac))
-}
-
-// renderContextMeterMinimal is the lowest-fidelity tier: no bar — just the
-// coloured percentage (+ ⚠ in danger), e.g. "ctx 70%". Unknown window degrades
-// to the bare size.
-func renderContextMeterMinimal(th theme.Theme, used, window int64) string {
- if used < 0 {
- used = 0
- }
- if window <= 0 {
- return "ctx " + humanizeTokens(used)
- }
- frac := ctxFraction(used, window)
- return "ctx " + th.Style(ctxPressureSlot(frac)).Render(ctxLabel(frac))
-}
-
-// ctxBar builds the meter bar: filled cells use the per-band glyph, the rest the
-// empty glyph. Glyph choice (not just colour) carries the pressure band.
-func ctxBar(frac float64) string {
- filled := int(frac*float64(ctxBarWidth) + 0.5)
- if filled > ctxBarWidth {
- filled = ctxBarWidth
- }
- return strings.Repeat(ctxGlyph(frac), filled) + strings.Repeat(ctxGlyphEmpty, ctxBarWidth-filled)
-}
-
-// trivialTurnTokens is the per-direction token count below which a turn's
-// input/output is considered negligible. A turn whose input AND output are both
-// at or under this AND has no measurable duration (sub-second or no clock) is a
-// near-empty turn whose stat line is pure noise, so it is suppressed entirely —
-// keeping a long multi-turn run scannable.
-const trivialTurnTokens = 50
-
-// turnStatCacheFloor is the cache-hit-rate floor below which the per-turn stat
-// line omits the "N% cached" facet: a negligible hit rate is noise, while a
-// material one is the signal that prompt caching is actually paying off this turn.
-const turnStatCacheFloor = 0.10
-
-// turnStatLine formats the inline per-turn stat line shown after a turn's model
-// exchange closes. It leads with cost — the turn's input/output tokens
-// (humanised) — then the elapsed model-call time, e.g. "↑1.2K ↓340 · 4.1s". The
-// duration segment is omitted when the server reported 0ms (no clock), giving
-// just "↑1.2K ↓340". A material cache-hit rate (≥ turnStatCacheFloor) appends a
-// "· N% cached" facet — the per-turn signal that prompt caching is paying off —
-// and is omitted below the floor to keep the line scannable. Users think in cost,
-// not turn numbers, so no index is shown.
-func turnStatLine(msg client.TurnEndMsg) string {
- var b strings.Builder
- fmt.Fprintf(&b, "↑%s ↓%s",
- humanizeTokens(msg.Usage.InputTokens), humanizeTokens(msg.Usage.OutputTokens))
- if msg.DurationMs > 0 {
- b.WriteString(" · " + formatDuration(msg.DurationMs))
- }
- if rate := cacheHitRate(msg.Usage); rate >= turnStatCacheFloor {
- b.WriteString(" · " + pctString(rate) + " cached")
- }
- return b.String()
-}
-
-// trivialTurn reports whether a turn's stats are too negligible to be worth a
-// scrollback line: both token directions at or below trivialTurnTokens AND no
-// sub-second-or-better duration signal. Such lines are suppressed.
-func trivialTurn(msg client.TurnEndMsg) bool {
- return msg.Usage.InputTokens <= trivialTurnTokens &&
- msg.Usage.OutputTokens <= trivialTurnTokens &&
- msg.DurationMs < 1000
-}
-
-// formatDuration renders an elapsed millisecond count compactly: under one
-// second verbatim in ms ("840ms"), otherwise seconds to one decimal ("4.1s",
-// trailing ".0" trimmed → "2s"). Negative values clamp to 0.
-func formatDuration(ms int64) string {
- if ms < 0 {
- ms = 0
- }
- if ms < 1000 {
- return fmt.Sprintf("%dms", ms)
- }
- return trimDecimal(float64(ms)/1000.0) + "s"
-}
-
-// stopError is the "error" terminal stop reason (proto Result.stop /
-// session.StopError). Named once so the few sites that branch on it — the footer
-// label, the subagent label, and the ResultMsg/teardown paths — share one
-// spelling rather than scattering the literal.
-const stopError = "error"
-
-// The closed team-member stop-reason vocabulary (mirrors the proto
-// TeamMemberStopReason / client.reasonString output). Named once so the disposition
-// label helper and any future site share one spelling.
-const (
- teamStopReasonError = "error"
- teamStopReasonCancelled = "cancelled"
- teamStopReasonBudget = "budget"
-)
-
-// slotCtxWarn is the themed warning slot name shared by the context-pressure meter
-// and the non-error LIMIT stop labels (turn/tool-call/repeated-failure/no-progress).
-const slotCtxWarn = "ctxWarn"
-
-// slotMuted is the themed muted slot name for the unobtrusive clean stops (end_turn,
-// cancelled, plan_approved, plan_iterate) and the unknown-reason fallback. Named once
-// so the literal does not trip goconst's min-occurrences across the switch arms.
-const slotMuted = "muted"
-
-const (
- statusDone = "done"
- statusFailed = "failed"
-)
-
-// stopReasonLabel maps a run's terminal stop reason (client.ResultMsg.Stop, the
-// proto Result.stop / session.StopReason vocabulary) to the human footer status
-// text and the theme style slot it should carry. The non-error LIMIT stops
-// (turn / tool-call / repeated-failure) are styled with the warning slot
-// ("ctxWarn") — they aren't failures but are worth noticing; a clean end_turn is
-// the unobtrusive muted "done"; cancelled is muted; error is the error slot (the
-// error block already carries the detail). An unknown/empty reason passes through
-// sanitized under the muted slot so a new server stop reason is never hidden.
-//
-// It is a pure function (no Model, no terminal) so it is unit-tested over every
-// reason; renderFooter / Update apply the returned slot.
-func stopReasonLabel(stop string) (text, slot string) {
- switch stop {
- case "end_turn", "":
- return statusDone, slotMuted
- case "max_turns":
- return "stopped · turn limit", slotCtxWarn
- case "max_tool_calls":
- return "stopped · tool-call limit", slotCtxWarn
- case "max_consecutive_failures":
- return "stopped · repeated failures", slotCtxWarn
- case "budget":
- return "stopped · token budget", slotCtxWarn
- case "cancelled":
- return "cancelled", slotMuted
- case "no_progress":
- // The model went silent (no tool call, no text) across the nudge budget. Not a
- // failure, but worth noticing — styled like the limit stops.
- return "stopped · no progress", slotCtxWarn
- case "structured_output":
- // A subagent could not satisfy the requested output schema within the
- // retry budget. Subagent-only today (it does not reach the main footer),
- // but mapped so the raw token never leaks if it ever does.
- return "stopped · schema unmet", slotCtxWarn
- case "plan_approved":
- return "plan approved · executing", slotMuted
- case "plan_iterate":
- // The operator chose to iterate on the plan (a deny verdict). The run ends
- // cleanly so the operator's next typed prompt drives the revision — a
- // muted/transient "awaiting your feedback" cue, not a warning.
- return "plan iterate · awaiting your feedback", slotMuted
- case stopError:
- return "error", "errorText"
- default:
- return terminaltext.Sanitize(stop), slotMuted
- }
-}
-
-// renderUsageFacets renders the session-total facets surfaced from ALL usage
-// fields: input/output token arrows, the previously-dropped cache-WRITE total
-// (⊕), and the cache-hit rate. Format: "↑7.9K ↓345 ⊕1.2K cache 88%". The cache
-// write is omitted when zero to keep the segment scannable. The ↑/↓/⊕ facets
-// are SESSION-CUMULATIVE totals (m.usage); the ctx meter beside them is CURRENT
-// occupancy (the latest turn's prompt size) — two different axes, deliberately.
-func renderUsageFacets(u client.Usage) string {
- var b strings.Builder
- fmt.Fprintf(&b, "↑%s ↓%s", humanizeTokens(u.InputTokens), humanizeTokens(u.OutputTokens))
- if u.CacheWriteTokens > 0 {
- fmt.Fprintf(&b, " ⊕%s", humanizeTokens(u.CacheWriteTokens))
- }
- fmt.Fprintf(&b, " cache %s", pctString(cacheHitRate(u)))
- return b.String()
-}
-
-// teamWorkingCounts classifies a team's member lanes into (working, total). total
-// is the lane count; working is the number of lanes NOT idle (genuinely in a turn).
-// It reuses the EXACT same !ln.idle predicate that teamLaneState uses for the
-// non-terminal roster glyph (◆ working / ○ idle / ✓ done — the last being terminal,
-// overlay-only), so the footer's "k/N working" can never disagree with the live
-// glyphs in the f6 panel. The footer segment is only rendered for a LIVE team
-// (liveTeamBlock returns nil once b.teamDone), so the terminal case never reaches
-// here — excluding idle is sufficient.
-func teamWorkingCounts(lanes []teamLane) (working, total int) {
- total = len(lanes)
- for i := range lanes {
- if !lanes[i].idle {
- working++
- }
- }
- return working, total
-}
-
-// teamFooterFull is the richest footer team-summary tier:
-// "⟳ team- · k/N working · agents". The id is sanitized and rune-safe
-// truncated; when it is empty (team.start missed) the id-less medium form is used
-// instead of showing a bare "team-". The segment carries the spinner (accent) slot
-// so the live team reads as active without animation. agentsMark is the LIVE Agents
-// chord (issue #457) so an override propagates to the footer affordance.
-func teamFooterFull(th theme.Theme, teamID string, working, total int, agentsMark string) string {
- id := truncate(terminaltext.Sanitize(teamID), teamFooterIDLimit)
- if id == "" {
- return th.Style("spinner").Render(teamFooterMedium(teamID, working, total, agentsMark))
- }
- seg := fmt.Sprintf("%s %s · %d/%d working · %s agents", teamLiveGlyph, id, working, total, agentsMark)
- return th.Style("spinner").Render(seg)
-}
-
-// teamFooterMedium drops the id and the "agents" word: "⟳ k/N working · ".
-// It carries no theme styling itself so it composes when called from
-// teamFooterFull (which styles the whole segment); fitFooter styles standalone uses.
-// agentsMark is the LIVE Agents chord (issue #457).
-func teamFooterMedium(_ string, working, total int, agentsMark string) string {
- return fmt.Sprintf("%s %d/%d working · %s", teamLiveGlyph, working, total, agentsMark)
-}
-
-// teamFooterCompact is the poorest team tier: "⟳ k/N" — just the glyph + counts.
-func teamFooterCompact(working, total int) string {
- return fmt.Sprintf("%s %d/%d", teamLiveGlyph, working, total)
-}
-
-// subagentFleetGlyph leads the footer fleet-summary segment when ≥1 subagent has run
-// this session. Like teamLiveGlyph it is a STATIC literal (the F2 mockup's gear),
-// deliberately NOT the animated spinner — the segment is a peripheral discoverability
-// cue, not a per-frame activity indicator, so it must not force a re-render every tick.
-const subagentFleetGlyph = "⛭"
-
-// subagentRunGlyph / subagentDoneGlyph are the running / done count markers on the
-// fleet footer segment, matching the Subagents-tab roster vocabulary (◐ in flight,
-// ✓ finished) so the footer and the overlay never use a different glyph for the same
-// state. They are glyph-not-colour cues so they read with ANSI stripped.
-const (
- subagentRunGlyph = "◐"
- subagentDoneGlyph = "✓"
-)
-
-// subagentFooterFull is the richest fleet footer tier:
-// "⛭ subagents 3◐ 1✓ · ". It is shown whenever ≥1 subagent has STARTED this
-// session (running+done > 0), so the parallel case is discoverable even before the
-// overlay is opened — the missing "3/4 done" peripheral cue. It carries the spinner
-// (accent) slot so the live fleet reads as active without animation. agentsMark is
-// the LIVE Agents chord (issue #457) so an override propagates.
-func subagentFooterFull(th theme.Theme, running, done int, agentsMark string) string {
- seg := fmt.Sprintf("%s subagents %d%s %d%s · %s", subagentFleetGlyph, running, subagentRunGlyph, done, subagentDoneGlyph, agentsMark)
- return th.Style("spinner").Render(seg)
-}
-
-// subagentFooterMedium drops the "subagents" word: "⛭ 3◐ 1✓ · ". It carries no
-// theme styling itself so it composes when styled by the caller (view.go).
-// agentsMark is the LIVE Agents chord (issue #457).
-func subagentFooterMedium(running, done int, agentsMark string) string {
- return fmt.Sprintf("%s %d%s %d%s · %s", subagentFleetGlyph, running, subagentRunGlyph, done, subagentDoneGlyph, agentsMark)
-}
-
-// subagentFooterCompact is the poorest fleet tier: "⛭ 3◐ 1✓" — glyph + counts only.
-func subagentFooterCompact(running, done int) string {
- return fmt.Sprintf("%s %d%s %d%s", subagentFleetGlyph, running, subagentRunGlyph, done, subagentDoneGlyph)
-}
-
-// parallelFleetGlyph leads the footer Parallel-summary segment when ≥1 Parallel run has
-// started this session. Like subagentFleetGlyph / teamLiveGlyph it is a STATIC literal (a
-// fork glyph distinct from ⛭ subagents and ⟳ team), deliberately NOT the animated spinner.
-const parallelFleetGlyph = "⑂"
-
-// parallelFooterFull is the richest Parallel footer tier: "⑂ parallel 1◐ 2✓ · ".
-// Like the fleet segment it is shown whenever ≥1 Parallel run has STARTED this session
-// (running+done > 0) and reuses the ◐/✓ count vocabulary so footer + overlay agree.
-// agentsMark is the LIVE Agents chord (issue #457) so an override propagates.
-func parallelFooterFull(th theme.Theme, running, done int, agentsMark string) string {
- seg := fmt.Sprintf("%s parallel %d%s %d%s · %s", parallelFleetGlyph, running, subagentRunGlyph, done, subagentDoneGlyph, agentsMark)
- return th.Style("spinner").Render(seg)
-}
-
-// parallelFooterMedium drops the "parallel" word: "⑂ 1◐ 2✓ · ". It carries no theme
-// styling itself so it composes when styled by the caller (view.go). agentsMark is the
-// LIVE Agents chord (issue #457).
-func parallelFooterMedium(running, done int, agentsMark string) string {
- return fmt.Sprintf("%s %d%s %d%s · %s", parallelFleetGlyph, running, subagentRunGlyph, done, subagentDoneGlyph, agentsMark)
-}
-
-// parallelFooterCompact is the poorest Parallel tier: "⑂ 1◐ 2✓" — glyph + counts only.
-func parallelFooterCompact(running, done int) string {
- return fmt.Sprintf("%s %d%s %d%s", parallelFleetGlyph, running, subagentRunGlyph, done, subagentDoneGlyph)
-}
diff --git a/cmd/mecatui/ui/footer_bench_test.go b/cmd/mecatui/ui/footer_bench_test.go
deleted file mode 100644
index 06354fee17..0000000000
--- a/cmd/mecatui/ui/footer_bench_test.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package ui
-
-// Offline microbenchmarks for the footer context-meter render path (issue #65
-// regression guard). renderFooter -> fitFooter runs on EVERY View() frame and is
-// NOT covered by the per-block / join render caches (those memoize the scrollback
-// conversation body; the footer is rebuilt verbatim every frame — see
-// assembleLayout in layout.go calling m.renderFooter() unconditionally).
-//
-// Issue #65 flipped the COMMON case: previously window==0 by default, so the meter
-// builders degraded to a cheap "ctx " string; now the server-echoed context
-// window is the default denominator, so the FULL bar path (ctxBar glyph repetition
-// + ctxFraction + ctxPressureSlot + ctxLabel + lipgloss style.Render) runs every
-// frame. These benches quantify that per-frame delta.
-//
-// Two granularities:
-// - BenchmarkFooterMeter{Bar,Degrade}: the three meter builders in isolation
-// (the exact code the change toggles between), no Model.
-// - BenchmarkFooterFit{Bar,Degrade}: the realistic full per-frame fitFooter path
-// (all three meter tiers + the usage facets + width fitting) over a Model, the
-// way renderFooter actually calls it each frame.
-//
-// They are Benchmarks, so `task test` (default -run) never runs them. Run with:
-// go test -run '^$' -bench 'BenchmarkFooter' -benchmem ./cmd/mecatui/ui/
-
-import (
- "context"
- "testing"
-
- tea "charm.land/bubbletea/v2"
-
- "github.com/stacklok/mecatl/cmd/mecatui/client"
- "github.com/stacklok/mecatl/cmd/mecatui/theme"
-)
-
-// benchTheme is a real (non-nil-style) theme so style.Render does the same ANSI
-// work it does in production — a no-op theme would understate the bar-path cost.
-func benchTheme() theme.Theme { return theme.New("aztec", theme.AztecPalette()) }
-
-// representative occupancy: ~70% of a 200K window — lands in the "warn" band, so
-// ctxBar emits a mix of filled (▓) + empty (░) glyphs (the realistic non-trivial bar).
-const (
- benchUsed int64 = 140_000
- benchWindow int64 = 200_000
-)
-
-var footerSink string
-
-// BenchmarkFooterMeterBar: the NEW common-case meter path (window>0) — full bar.
-func BenchmarkFooterMeterBar(b *testing.B) {
- th := benchTheme()
- b.ReportAllocs()
- for b.Loop() {
- footerSink = renderContextMeter(th, benchUsed, benchWindow)
- footerSink = renderContextMeterCompact(th, benchUsed, benchWindow)
- footerSink = renderContextMeterMinimal(th, benchUsed, benchWindow)
- }
-}
-
-// BenchmarkFooterMeterDegrade: the OLD common-case meter path (window==0) — the
-// cheap "ctx " degrade all three builders short-circuit to.
-func BenchmarkFooterMeterDegrade(b *testing.B) {
- th := benchTheme()
- b.ReportAllocs()
- for b.Loop() {
- footerSink = renderContextMeter(th, benchUsed, 0)
- footerSink = renderContextMeterCompact(th, benchUsed, 0)
- footerSink = renderContextMeterMinimal(th, benchUsed, 0)
- }
-}
-
-// buildFooterModel builds a connected, sized Model with a non-trivial usage so
-// renderUsageFacets does real work — the realistic per-frame footer input. The
-// window source is chosen by the caller via resolvedSessionModel.ContextWindow.
-func buildFooterModel(tb testing.TB, window int64) Model {
- tb.Helper()
- m := newTestModelFromDeps(Deps{
- Theme: benchTheme(),
- Ctx: context.Background(),
- NoAltScreen: true,
- })
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 100, Height: 40},
- client.SessionReadyMsg{SessionID: "perf-footer-0001"},
- )
- m.phase = phaseIdle
- m.statusMsg = "ready"
- m.contextTokens = benchUsed
- m.usage = client.Usage{
- InputTokens: benchUsed,
- OutputTokens: 12_000,
- CacheReadTokens: 90_000,
- CacheWriteTokens: 4_000,
- }
- // The new default source for the denominator: the server-echoed per-model window.
- m.resolvedSessionModel = client.ResolvedModel{ContextWindow: window}
- return m
-}
-
-// BenchmarkFooterFitBar: the realistic full per-frame footer path with the NEW
-// default (window>0 -> bar). This is what renderFooter pays every View() now.
-func BenchmarkFooterFitBar(b *testing.B) {
- m := buildFooterModel(b, benchWindow)
- left := "ready"
- width := m.widthOr()
- b.ReportAllocs()
- for b.Loop() {
- footerSink = m.fitFooter(left, width)
- }
-}
-
-// BenchmarkFooterFitDegrade: the realistic full per-frame footer path with the OLD
-// default (window==0 -> degrade). Same fitFooter ladder, cheap meter strings.
-func BenchmarkFooterFitDegrade(b *testing.B) {
- m := buildFooterModel(b, 0)
- left := "ready"
- width := m.widthOr()
- b.ReportAllocs()
- for b.Loop() {
- footerSink = m.fitFooter(left, width)
- }
-}
diff --git a/cmd/mecatui/ui/footer_heal_test.go b/cmd/mecatui/ui/footer_heal_test.go
index 1d42c94f17..e21617dca0 100644
--- a/cmd/mecatui/ui/footer_heal_test.go
+++ b/cmd/mecatui/ui/footer_heal_test.go
@@ -1,12 +1,14 @@
package ui
import (
+ "context"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/stacklok/mecatl/cmd/mecatui/client"
+ customization "github.com/stacklok/mecatl/cmd/mecatui/customization"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
)
@@ -39,14 +41,11 @@ func healModel(t *testing.T, conv *fakeConv) Model {
return m
}
-// footerStr renders the (ANSI-stripped) footer the user sees.
-func footerStr(m Model) string { return stripANSIstr(m.fitFooter("connected", 160)) }
-
// TestFooterHealRaceThenHeal is the decisive offline reproduction of the issue-#66
-// footer-heal race: a raced create echoes a 0 window (the footer degrades to the
-// bare "ctx 40K", no bar), the turn-end gate fires a GetSession refetch, and the
+// footer-heal race: a raced create echoes a 0 window (the footer initially
+// degrades to bare "ctx ~35K"), the turn-end gate fires a GetSession refetch, and the
// server's healed live window (1.05M) raises the denominator so the footer renders
-// the "40K/1.0M" bar — WITHOUT a model switch or restart.
+// estimated occupancy against the rounded 1.1M window — WITHOUT a model switch or restart.
func TestFooterHealRaceThenHeal(t *testing.T) {
conv := &fakeConv{
recv: &fakeRecver{}, send: &fakeSender{},
@@ -55,20 +54,28 @@ func TestFooterHealRaceThenHeal(t *testing.T) {
{ProviderID: "openrouter", ModelID: "openai/gpt-5.5", ContextWindow: 1_050_000},
},
}
+ source := customization.NewDefaultSource(0)
+ t.Cleanup(func() { _ = source.Close(context.Background()) })
m := healModel(t, conv)
+ m.deps.StatusSource = source
+ m.contextTokens = 35_000 // turn-end changes occupancy and publishes a distinct pre-heal result
+ m.contextEstimated = true
+ m.submitStatusLine()
+ updated, _ := m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ m = updated.(Model)
// 1) Raced create → window unknown → the footer degrades (no denominator bar).
- if got := m.contextWindow(); got != 0 {
- t.Fatalf("contextWindow() = %d, want 0 (raced create echoed a 0 window)", got)
+ if got := m.resolvedSessionModel.ContextWindow; got != 0 {
+ t.Fatalf("resolved context window = %d, want 0 (raced create echoed a 0 window)", got)
}
- if foot := footerStr(m); !strings.Contains(foot, "ctx 40K") || strings.Contains(foot, "/") {
- t.Fatalf("footer = %q, want the degraded bare 'ctx 40K' with no '/' denominator", foot)
+ if footer := stripANSIstr(m.renderFooter()); !strings.Contains(footer, "ctx ~35K") || strings.Contains(footer, "/1.1M") {
+ t.Fatalf("pre-heal stock footer = %q, want estimated occupancy without a denominator", footer)
}
// 2) A turn boundary fires the heal refetch. Drive the REAL reducer with a
// TurnEndMsg and assert it emits a command (the RefreshResolvedModelCmd batched
// alongside afterEvent's cmd).
- mm, cmd := m.Update(client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 40000, OutputTokens: 100}})
+ mm, cmd := m.Update(client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 40000, OutputTokens: 100}, Estimated: true})
m = mm.(Model)
if cmd == nil {
t.Fatal("TurnEndMsg with an unknown window emitted no command — the heal refetch did not fire")
@@ -80,22 +87,26 @@ func TestFooterHealRaceThenHeal(t *testing.T) {
if n := conv.getSessionCalls(); n != 1 {
t.Fatalf("GetSession called %d times after one turn-end with unknown window, want 1", n)
}
+ // Settle the turn-end source notification before applying the heal. Otherwise
+ // the next listener wake can observe the old result and falsely pass/fail.
+ updated, _ = m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ m = updated.(Model)
+ if footer := stripANSIstr(m.renderFooter()); !strings.Contains(footer, "ctx ~40K") || strings.Contains(footer, "/1.1M") {
+ t.Fatalf("turn-end stock footer = %q, want pre-heal estimated occupancy", footer)
+ }
- // 3) Feed the heal result back through the reducer: the window heals to 1.05M,
- // contextWindow() reports it, and the footer renders the bar.
+ // 3) Feed the heal result through the real reducer, then let the configured
+ // source publish and the UI listener apply the refreshed surface.
healed := client.ResolvedModelMsg{SessionID: "sess-test-0001", Resolved: client.ResolvedModel{ProviderID: "openrouter", ModelID: "openai/gpt-5.5", ContextWindow: 1_050_000}}
m = applyAll(m, healed)
if got := m.resolvedSessionModel.ContextWindow; got != 1_050_000 {
t.Fatalf("resolvedSessionModel.ContextWindow = %d, want 1,050,000 after the heal", got)
}
- if got := m.contextWindow(); got != 1_050_000 {
- t.Fatalf("contextWindow() = %d, want 1,050,000 after the heal", got)
- }
- if foot := footerStr(m); !strings.Contains(foot, "40K/1.1M") {
- t.Fatalf("footer = %q, want the healed '40K/1.1M' bar", foot)
- }
- if foot := footerStr(m); !strings.Contains(foot, ctxGlyphEmpty) && !strings.Contains(foot, ctxGlyphOk) {
- t.Fatalf("footer = %q, want a meter bar glyph after the heal", footerStr(m))
+ updated, _ = m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ m = updated.(Model)
+ footer := stripANSIstr(m.renderFooter())
+ if !m.generatedStatusLine.Footer.Present || !strings.Contains(footer, "~40K/1.1M") || !strings.Contains(footer, "~3%") {
+ t.Fatalf("healed stock footer = %q, want source-rendered estimated occupancy with healed denominator", footer)
}
// 4) Identity untouched (only the denominator self-corrected).
@@ -130,11 +141,8 @@ func TestFooterStartupResumeHealsProvisionalWindow(t *testing.T) {
})
m.width = 160
- if got := m.contextWindow(); got != 0 {
- t.Fatalf("resumed contextWindow() = %d, want provisional 0", got)
- }
- if foot := footerStr(m); !strings.Contains(foot, "ctx 40K") || strings.Contains(foot, "/") {
- t.Fatalf("resumed footer = %q, want bare occupancy before refresh", foot)
+ if got := m.resolvedSessionModel.ContextWindow; got != 0 {
+ t.Fatalf("resumed context window = %d, want provisional 0", got)
}
updated, cmd := m.Update(startupResumeReadyMsg{})
@@ -157,8 +165,8 @@ func TestFooterStartupResumeHealsProvisionalWindow(t *testing.T) {
updated, _ = m.Update(refreshed)
m = updated.(Model)
- if got := m.contextWindow(); got != 1_050_000 {
- t.Fatalf("healed contextWindow() = %d, want 1,050,000", got)
+ if got := m.resolvedSessionModel.ContextWindow; got != 1_050_000 {
+ t.Fatalf("healed context window = %d, want 1,050,000", got)
}
if got := m.contextTokens; got != occupancy {
t.Fatalf("refresh replaced resumed occupancy: got %d, want %d", got, occupancy)
@@ -166,9 +174,6 @@ func TestFooterStartupResumeHealsProvisionalWindow(t *testing.T) {
if got := m.usage; got != (client.Usage{InputTokens: 12345, OutputTokens: 678}) {
t.Fatalf("refresh replaced resumed usage: got %+v", got)
}
- if foot := footerStr(m); !strings.Contains(foot, "40K/1.1M") {
- t.Fatalf("healed footer = %q, want visible meter without input", foot)
- }
}
// TestFooterStartupResumeRefreshRestoresPersistedOccupancy covers the resume
@@ -212,9 +217,6 @@ func TestFooterStartupResumeRefreshRestoresPersistedOccupancy(t *testing.T) {
if m.contextUnknown || m.contextTokens != occupancy || !m.contextEstimated {
t.Fatalf("resumed context = tokens=%d unknown=%t estimated=%t", m.contextTokens, m.contextUnknown, m.contextEstimated)
}
- if foot := footerStr(m); !strings.Contains(foot, "~40K/1.1M") {
- t.Fatalf("footer = %q, want restored occupancy before a prompt", foot)
- }
}
// TestFooterStartupResumeRetriesProvisionalWindow proves resume does not strand
@@ -241,8 +243,8 @@ func TestFooterStartupResumeRetriesProvisionalWindow(t *testing.T) {
}
updated, retry := m.Update(first)
m = updated.(Model)
- if m.contextWindow() != 0 || retry == nil {
- t.Fatalf("first refresh = window %d retry %v, want provisional window with retry", m.contextWindow(), retry != nil)
+ if m.resolvedSessionModel.ContextWindow != 0 || retry == nil {
+ t.Fatalf("first refresh = window %d retry %v, want provisional window with retry", m.resolvedSessionModel.ContextWindow, retry != nil)
}
updated, refresh := m.Update(retry())
@@ -255,8 +257,8 @@ func TestFooterStartupResumeRetriesProvisionalWindow(t *testing.T) {
t.Fatalf("retry refresh = %#v, want attempt 1 snapshot", second)
}
m = applyAll(m, second)
- if m.contextWindow() != 200_000 || !strings.Contains(footerStr(m), "40K/200K") {
- t.Fatalf("retried footer = %q, want denominator after startup without interaction", footerStr(m))
+ if m.resolvedSessionModel.ContextWindow != 200_000 {
+ t.Fatalf("retried context window = %d, want 200000 after startup without interaction", m.resolvedSessionModel.ContextWindow)
}
}
@@ -272,8 +274,8 @@ func TestFooterStartupResumeDoesNotRetryKnownWindow(t *testing.T) {
SessionID: m.sessionID, Resolved: client.ResolvedModel{ContextWindow: 0}, AdoptContextOccupancy: true,
})
m = updated.(Model)
- if m.contextWindow() != 200_000 || cmd != nil {
- t.Fatalf("known window = %d retry %v, want preserved window without retry", m.contextWindow(), cmd != nil)
+ if m.resolvedSessionModel.ContextWindow != 200_000 || cmd != nil {
+ t.Fatalf("known window = %d retry %v, want preserved window without retry", m.resolvedSessionModel.ContextWindow, cmd != nil)
}
}
diff --git a/cmd/mecatui/ui/footer_test.go b/cmd/mecatui/ui/footer_test.go
index 5e0902aa0e..f5b33d2554 100644
--- a/cmd/mecatui/ui/footer_test.go
+++ b/cmd/mecatui/ui/footer_test.go
@@ -11,261 +11,6 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/theme"
)
-func TestHumanizeTokens(t *testing.T) {
- cases := []struct {
- in int64
- want string
- }{
- {0, "0"},
- {1, "1"},
- {999, "999"},
- {1000, "1K"},
- {2000, "2K"},
- {7903, "7.9K"},
- {1500, "1.5K"},
- {999999, "1000K"},
- {1_000_000, "1M"},
- {1_200_000, "1.2M"},
- {-5, "0"},
- }
- for _, c := range cases {
- if got := humanizeTokens(c.in); got != c.want {
- t.Errorf("humanizeTokens(%d) = %q, want %q", c.in, got, c.want)
- }
- }
-}
-
-func TestCacheHitRate(t *testing.T) {
- cases := []struct {
- name string
- u client.Usage
- want float64
- }{
- {"zero input", client.Usage{}, 0},
- {"half", client.Usage{InputTokens: 100, CacheReadTokens: 50}, 0.5},
- {"full", client.Usage{InputTokens: 100, CacheReadTokens: 100}, 1.0},
- {"88pct", client.Usage{InputTokens: 1500, CacheReadTokens: 1320}, 0.88},
- }
- for _, c := range cases {
- got := cacheHitRate(c.u)
- if diff := got - c.want; diff > 1e-9 || diff < -1e-9 {
- t.Errorf("%s: cacheHitRate = %v, want %v", c.name, got, c.want)
- }
- }
-}
-
-func TestPctString(t *testing.T) {
- cases := []struct {
- in float64
- want string
- }{
- {0, "0%"},
- {0.5, "50%"},
- {0.881, "88%"},
- {1.0, "100%"},
- {1.5, "100%"},
- {-0.2, "0%"},
- }
- for _, c := range cases {
- if got := pctString(c.in); got != c.want {
- t.Errorf("pctString(%v) = %q, want %q", c.in, got, c.want)
- }
- }
-}
-
-func TestRenderContextMeterUnknownWindow(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- got := renderContextMeter(th, 7903, 0)
- if got != "ctx 7.9K" {
- t.Errorf("unknown window: got %q, want %q", got, "ctx 7.9K")
- }
- // No bar glyphs when the window is unknown.
- if strings.ContainsAny(got, ctxGlyphOk+ctxGlyphWarn+ctxGlyphDanger+ctxGlyphEmpty) {
- t.Errorf("unknown window should have no bar, got %q", got)
- }
- // Negative used clamps to 0.
- if got := renderContextMeter(th, -10, 0); got != "ctx 0" {
- t.Errorf("negative used: got %q, want %q", got, "ctx 0")
- }
-}
-
-func TestRenderContextMeterKnownWindow(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- // 40K / 200K = 20% → ok band.
- got := stripANSIstr(renderContextMeter(th, 40000, 200000))
- if !strings.Contains(got, "20%") {
- t.Errorf("expected 20%% in %q", got)
- }
- if !strings.Contains(got, "40K/200K") {
- t.Errorf("expected used/total 40K/200K in %q", got)
- }
- if !strings.ContainsAny(got, ctxGlyphOk+ctxGlyphEmpty) {
- t.Errorf("expected a bar in %q", got)
- }
-}
-
-// TestContextMeterPressureColours asserts each pressure band selects its own
-// theme slot (colours differ) by comparing the raw ANSI output across bands.
-func TestContextMeterPressureColours(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- const window = 200000
- ok := renderContextMeter(th, 40000, window) // 20% → ctxOk
- warn := renderContextMeter(th, 140000, window) // 70% → ctxWarn
- danger := renderContextMeter(th, 190000, window) // 95% → ctxDanger
-
- if ok == warn || warn == danger || ok == danger {
- t.Errorf("expected distinct colouring across pressure bands:\nok=%q\nwarn=%q\ndanger=%q", ok, warn, danger)
- }
- // Sanity: slot selection by fraction.
- if s := ctxPressureSlot(0.2); s != "ctxOk" {
- t.Errorf("ctxPressureSlot(0.2) = %q, want ctxOk", s)
- }
- if s := ctxPressureSlot(0.70); s != "ctxWarn" {
- t.Errorf("ctxPressureSlot(0.70) = %q, want ctxWarn", s)
- }
- if s := ctxPressureSlot(0.90); s != "ctxDanger" {
- t.Errorf("ctxPressureSlot(0.90) = %q, want ctxDanger", s)
- }
-}
-
-// TestContextMeterPressureNonColour is the ACCESSIBILITY guard: pressure must be
-// legible WITHOUT colour. It strips ANSI and asserts the three bands still
-// differ — via the per-band fill glyph and the danger ⚠ marker — so a
-// red/green-colourblind user (or anyone reading the stripped golden) can tell a
-// 5% context from a 95% one.
-func TestContextMeterPressureNonColour(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- const window = 200000
- ok := stripANSIstr(renderContextMeter(th, 80000, window)) // 40% → ok (fills cells)
- warn := stripANSIstr(renderContextMeter(th, 140000, window)) // 70% → warn
- danger := stripANSIstr(renderContextMeter(th, 190000, window)) // 95% → danger
-
- if ok == warn || warn == danger || ok == danger {
- t.Errorf("pressure must differ with ANSI stripped:\nok=%q\nwarn=%q\ndanger=%q", ok, warn, danger)
- }
- // The bands carry distinct fill glyphs.
- if !strings.Contains(ok, ctxGlyphOk) {
- t.Errorf("ok band should use %q glyph: %q", ctxGlyphOk, ok)
- }
- if !strings.Contains(warn, ctxGlyphWarn) {
- t.Errorf("warn band should use %q glyph: %q", ctxGlyphWarn, warn)
- }
- if !strings.Contains(danger, ctxGlyphDanger) {
- t.Errorf("danger band should use %q glyph: %q", ctxGlyphDanger, danger)
- }
- // Danger also appends a non-colour textual cue; the others must not.
- if !strings.Contains(danger, ctxDangerMark) {
- t.Errorf("danger band should append the ⚠ marker: %q", danger)
- }
- if strings.Contains(ok, ctxDangerMark) || strings.Contains(warn, ctxDangerMark) {
- t.Errorf("only the danger band may show the ⚠ marker")
- }
-}
-
-func TestRenderUsageFacets(t *testing.T) {
- got := renderUsageFacets(client.Usage{
- InputTokens: 7903,
- OutputTokens: 345,
- CacheReadTokens: 6955,
- CacheWriteTokens: 1200,
- })
- for _, want := range []string{"↑7.9K", "↓345", "⊕1.2K", "cache 88%"} {
- if !strings.Contains(got, want) {
- t.Errorf("expected %q in facets %q", want, got)
- }
- }
-}
-
-// TestRenderUsageFacetsOmitsZeroCacheWrite keeps the segment scannable: the
-// cache-write facet is hidden when zero.
-func TestRenderUsageFacetsOmitsZeroCacheWrite(t *testing.T) {
- got := renderUsageFacets(client.Usage{InputTokens: 100, OutputTokens: 10, CacheReadTokens: 50})
- if strings.Contains(got, "⊕") {
- t.Errorf("zero cache-write should be omitted, got %q", got)
- }
-}
-
-// stripANSIstr is a string convenience over stripANSI for assertions.
-func stripANSIstr(s string) string { return string(stripANSI([]byte(s))) }
-
-// TestTurnStatLine pins the per-turn stat line: it always leads with the
-// input/output token arrows, appends the duration when the server reported one,
-// and appends "N% cached" ONLY when the turn's cache-hit rate is at or above
-// turnStatCacheFloor (decision 4). A negligible cache rate is omitted to keep the
-// line scannable.
-func TestTurnStatLine(t *testing.T) {
- cases := []struct {
- name string
- usage client.Usage
- durationMs int64
- wantSubs []string
- absentSubs []string
- }{
- {
- name: "no cache facet below floor",
- usage: client.Usage{InputTokens: 1200, OutputTokens: 340}, // 0% cache
- durationMs: 4100,
- wantSubs: []string{"↑1.2K", "↓340", "4.1s"},
- absentSubs: []string{"cached"},
- },
- {
- name: "cache facet when material",
- usage: client.Usage{InputTokens: 1500, OutputTokens: 30, CacheReadTokens: 1320}, // 88%
- durationMs: 0,
- wantSubs: []string{"↑1.5K", "↓30", "88% cached"},
- absentSubs: []string{" · 4"}, // no duration segment when 0ms
- },
- {
- name: "just under the floor is omitted",
- usage: client.Usage{InputTokens: 1000, OutputTokens: 10, CacheReadTokens: 90}, // 9% < 10%
- durationMs: 0,
- wantSubs: []string{"↑1K", "↓10"},
- absentSubs: []string{"cached"},
- },
- {
- name: "exactly at the floor is shown",
- usage: client.Usage{InputTokens: 1000, OutputTokens: 10, CacheReadTokens: 100}, // 10%
- durationMs: 0,
- wantSubs: []string{"10% cached"},
- },
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- got := turnStatLine(client.TurnEndMsg{Usage: c.usage, DurationMs: c.durationMs})
- for _, sub := range c.wantSubs {
- if !strings.Contains(got, sub) {
- t.Errorf("turnStatLine = %q, want it to contain %q", got, sub)
- }
- }
- for _, sub := range c.absentSubs {
- if strings.Contains(got, sub) {
- t.Errorf("turnStatLine = %q, must NOT contain %q", got, sub)
- }
- }
- })
- }
-}
-
-// TestTurnStatCacheReachesScrollback is the integration guard for the per-turn cache
-// facet: a TurnEndMsg carrying a material cache rate, driven through the REAL update
-// path (TurnEndMsg → addTurnStat(turnStatLine) → blockTurnStat → renderBlockFresh),
-// must surface "% cached" in the rendered conversation scrollback. TestTurnStatLine
-// tests the formatter in isolation; this proves the string actually reaches a rendered
-// block (the trivialTurn gate, the conversation append, and the block render all wired).
-func TestTurnStatCacheReachesScrollback(t *testing.T) {
- m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette()))
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 100, Height: 30},
- client.SessionReadyMsg{SessionID: "sess-test-0001"},
- // Non-trivial tokens (so the stat line is not suppressed) with an 88% cache rate.
- client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 1500, OutputTokens: 300, CacheReadTokens: 1320}, DurationMs: 4100},
- )
- got := stripANSIstr(m.rend.renderConversation(&m.conv, m.expandTools))
- if !strings.Contains(got, "88% cached") {
- t.Errorf("rendered scrollback missing the per-turn cache facet %q; got %q", "88% cached", got)
- }
-}
-
// TestFooterSelectionCount: an idle model with a known multi-line, non-empty
// selection shows the live "N chars · M lines" count in the footer-left.
func TestFooterSelectionCount(t *testing.T) {
@@ -357,44 +102,9 @@ func TestFooterSelectionCountSuppressedWhileRunning(t *testing.T) {
}
}
-// TestStopReasonLabel locks the human phrasing + style slot for every stop
-// reason in the session.StopReason / proto Result.stop vocabulary, plus the
-// empty and unknown fallbacks. The limit stops carry the warning slot; a clean
-// end_turn / cancelled is muted; error is the error slot.
-func TestStopReasonLabel(t *testing.T) {
- cases := []struct {
- stop string
- text string
- slot string
- }{
- {"end_turn", "done", "muted"},
- {"", "done", "muted"},
- {"max_turns", "stopped · turn limit", "ctxWarn"},
- {"max_tool_calls", "stopped · tool-call limit", "ctxWarn"},
- {"max_consecutive_failures", "stopped · repeated failures", "ctxWarn"},
- {"budget", "stopped · token budget", "ctxWarn"},
- {"cancelled", "cancelled", "muted"},
- {"no_progress", "stopped · no progress", "ctxWarn"},
- {"structured_output", "stopped · schema unmet", "ctxWarn"},
- {"plan_approved", "plan approved · executing", "muted"},
- {"plan_iterate", "plan iterate · awaiting your feedback", "muted"},
- {"error", "error", "errorText"},
- {"some_future_reason", "some_future_reason", "muted"},
- }
- for _, c := range cases {
- text, slot := stopReasonLabel(c.stop)
- if text != c.text {
- t.Errorf("stopReasonLabel(%q) text = %q, want %q", c.stop, text, c.text)
- }
- if slot != c.slot {
- t.Errorf("stopReasonLabel(%q) slot = %q, want %q", c.stop, slot, c.slot)
- }
- }
-}
-
// TestResultMsgStopReachesFooter is the end-to-end regression guard for the stop
// reason wiring (issue #81 Part 5): a terminal client.ResultMsg{Stop} must drive
-// applyResult → endRun → stopReasonLabel → m.statusMsg, and the rendered footer
+// applyResult → endRun → renderfmt.StopReasonLabel → m.statusMsg, and the rendered footer
// must show the human label. It covers the explicit-mapped reasons and an unknown
// passthrough. structured_output is now explicitly phrased ("stopped · schema
// unmet") so the raw underscore'd token never leaks even though it is a
@@ -421,131 +131,3 @@ func TestResultMsgStopReachesFooter(t *testing.T) {
}
}
}
-
-// TestStopReasonLabelSanitizesUnknown asserts an unknown reason carrying an ESC
-// byte is stripped before it reaches the footer (it is rendered via lipgloss,
-// which would otherwise pass the escape through).
-func TestStopReasonLabelSanitizesUnknown(t *testing.T) {
- text, _ := stopReasonLabel("evil\x1b[2Jreason")
- if strings.ContainsRune(text, 0x1b) {
- t.Errorf("unknown stop reason should be sanitized, got %q", text)
- }
-}
-
-// TestTeamWorkingCounts locks the (working, total) classification the footer
-// k/N segment derives from a team's lanes: total is the lane count, working is the
-// count of lanes NOT idle — the SAME !ln.idle predicate teamLaneState uses for the
-// non-terminal roster glyph. An idle lane (finished its round, awaiting the next)
-// is NOT counted as working.
-func TestTeamWorkingCounts(t *testing.T) {
- cases := []struct {
- name string
- lanes []teamLane
- wantWk, wantTot int
- }{
- {"empty", nil, 0, 0},
- {"all working", []teamLane{{}, {}, {}}, 3, 3},
- {"all idle", []teamLane{{idle: true}, {idle: true}}, 0, 2},
- {"mixed", []teamLane{{idle: true}, {}, {idle: true}, {}}, 2, 4},
- {"single working", []teamLane{{}}, 1, 1},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- wk, tot := teamWorkingCounts(tc.lanes)
- if wk != tc.wantWk || tot != tc.wantTot {
- t.Errorf("teamWorkingCounts = (%d, %d), want (%d, %d)", wk, tot, tc.wantWk, tc.wantTot)
- }
- })
- }
-}
-
-// TestFooterCountsIdleAsNotWorking pins the bug fix at the lane level: a live team
-// where one member has fired a per-round result (idle) and another is working must
-// count 1/2 working — NOT 2/2 (the old !ln.done predicate counted an idle member as
-// working) and NOT 0/2 (idle is not terminal). The lane is asserted idle (not a
-// fabricated terminal flag) to pin the cause.
-func TestFooterCountsIdleAsNotWorking(t *testing.T) {
- c := &conversation{}
- c.addTool("t1", "Team", `{}`)
- c.startTeamCard("t1", "", roster())
- // lead works; scout finishes its round (idle), team still live.
- c.updateTeamCardMember(member("lead", "tool.call", client.TeamMsg{ToolName: "Edit"}))
- c.updateTeamCardMember(member("scout", "tool.call", client.TeamMsg{ToolName: "Grep"}))
- c.updateTeamCardMember(member("scout", "result", client.TeamMsg{}))
-
- lanes := c.testTeamOverlay(0).teamLanes
- if !lanes[1].idle {
- t.Fatalf("scout lane must be idle after its per-round result, got %+v", lanes[1])
- }
- if lanes[0].idle {
- t.Fatalf("lead lane must be working (not idle), got %+v", lanes[0])
- }
- wk, tot := teamWorkingCounts(lanes)
- if wk != 1 || tot != 2 {
- t.Errorf("teamWorkingCounts = (%d, %d), want (1, 2) — idle scout must not count as working", wk, tot)
- }
-}
-
-// TestContextWindowPrecedence pins the footer-meter denominator source (issue #65,
-// simplified by the resolve-at-use unification): the SERVER-echoed per-model window
-// (now config/live-first server-side, and moved by embedded mecatui's or external
-// mecated's --context-window-override) is the single source; the client does not
-// recompute it. 0 echo ⇒ unknown.
-func TestContextWindowPrecedence(t *testing.T) {
- cases := []struct {
- name string
- echo int64 // m.resolvedSessionModel.ContextWindow (server-resolved, live-first)
- wantValue int64
- }{
- {"echo set → echo used", 200000, 200000},
- {"echo 0 → unknown", 0, 0},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- var m Model
- m.resolvedSessionModel = client.ResolvedModel{ContextWindow: tc.echo}
- if got := m.contextWindow(); got != tc.wantValue {
- t.Errorf("contextWindow() = %d, want %d", got, tc.wantValue)
- }
- })
- }
-}
-
-// TestFooterMeterUsesServerEchoedWindow proves the footer renders a real bar from
-// the SERVER-ECHOED per-model window when no --context-window override is set: a
-// session that resolved a 200K window with 40K occupied shows "40K/200K" and a bar
-// glyph in the stripped footer.
-func TestFooterMeterUsesServerEchoedWindow(t *testing.T) {
- m, _ := selModel(t)
- m.resolvedSessionModel = client.ResolvedModel{ContextWindow: 200000}
- m.contextTokens = 40000
- m.phase = phaseIdle
- m.sel = selection{} // inactive: show the status+meter footer, not the selection count
-
- got := stripANSIstr(m.fitFooter("connected", 160))
- if !strings.Contains(got, "40K/200K") {
- t.Errorf("footer = %q, want it to contain %q", got, "40K/200K")
- }
- if !strings.Contains(got, ctxGlyphEmpty) && !strings.Contains(got, ctxGlyphOk) {
- t.Errorf("footer = %q, want a meter bar glyph", got)
- }
-}
-
-// TestFooterMeterDegradesWhenWindowUnknown proves that with no server-echoed window
-// the meter degrades to the bare "ctx 40K" current size — no "/" denominator and no
-// percentage.
-func TestFooterMeterDegradesWhenWindowUnknown(t *testing.T) {
- m, _ := selModel(t)
- m.resolvedSessionModel = client.ResolvedModel{} // window unknown
- m.contextTokens = 40000
- m.phase = phaseIdle
- m.sel = selection{}
-
- got := stripANSIstr(m.fitFooter("connected", 160))
- if !strings.Contains(got, "ctx 40K") {
- t.Errorf("footer = %q, want it to contain %q", got, "ctx 40K")
- }
- if strings.Contains(got, "/") {
- t.Errorf("footer = %q, want NO denominator '/' when window is unknown", got)
- }
-}
diff --git a/cmd/mecatui/ui/help_test.go b/cmd/mecatui/ui/help_test.go
index a38b9167b3..b01e504566 100644
--- a/cmd/mecatui/ui/help_test.go
+++ b/cmd/mecatui/ui/help_test.go
@@ -72,6 +72,7 @@ func qmark() tea.KeyPressMsg { return tea.KeyPressMsg{Code: '?', Text: "?"} }
// mcp/commands/skills rows carry [not enabled]; memory/teams/shell are available.
func TestHelpOverlayEmbeddedGolden(t *testing.T) {
m := helpModel(t, embeddedCaps())
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "help_embedded.golden", got)
}
@@ -81,6 +82,7 @@ func TestHelpOverlayEmbeddedGolden(t *testing.T) {
// memory prose lines.
func TestHelpOverlayAllOnGolden(t *testing.T) {
m := helpModel(t, allOnCaps())
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "help_all_on.golden", got)
}
diff --git a/cmd/mecatui/ui/integration_test.go b/cmd/mecatui/ui/integration_test.go
index afdd1b3fad..f8e0ef3b12 100644
--- a/cmd/mecatui/ui/integration_test.go
+++ b/cmd/mecatui/ui/integration_test.go
@@ -1,7 +1,6 @@
package ui
import (
- "context"
"strings"
"testing"
@@ -15,58 +14,8 @@ import (
// TestFooterContextMeterWithWindow drives a result through Update and asserts the
// footer shows a populated context meter (bar + percentage + used/total) when a
// context window is configured, plus the session usage facets.
-func TestFooterContextMeterWithWindow(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- m := newTestModelFromDeps(Deps{
- Theme: th,
- Model: "mock-model",
- })
- // The footer denominator is the SERVER-echoed per-model window (resolve-at-use,
- // live-first server-side); set it as the test would receive it on SessionReady.
- m.resolvedSessionModel = client.ResolvedModel{ContextWindow: 200000}
- m = applyAll(m, tea.WindowSizeMsg{Width: 120, Height: 30})
- // The meter's numerator comes from the per-turn TurnEndMsg (current
- // occupancy); the facets come from the cumulative ResultMsg total.
- m = applyAll(m, client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 40000, OutputTokens: 345}})
- m = applyAll(m, client.ResultMsg{
- Stop: "end_turn",
- Usage: client.Usage{InputTokens: 40000, OutputTokens: 345, CacheReadTokens: 35200},
- })
-
- footer := stripANSIstr(m.renderFooter())
- if !strings.Contains(footer, "ctx ") || !strings.ContainsAny(footer, ctxGlyphOk+ctxGlyphEmpty) {
- t.Errorf("expected a context bar in footer:\n%s", footer)
- }
- if !strings.Contains(footer, "20%") {
- t.Errorf("expected 20%% context in footer:\n%s", footer)
- }
- if !strings.Contains(footer, "40K/200K") {
- t.Errorf("expected used/total in footer:\n%s", footer)
- }
- if !strings.Contains(footer, "↑40K") || !strings.Contains(footer, "↓345") {
- t.Errorf("expected usage facets in footer:\n%s", footer)
- }
- if !strings.Contains(footer, "cache 88%") {
- t.Errorf("expected cache-hit rate in footer:\n%s", footer)
- }
-}
-
// TestFooterContextMeterUnknownWindow asserts the meter degrades to just the
// current size when no window is known.
-func TestFooterContextMeterUnknownWindow(t *testing.T) {
- m := newTestModelFromDeps(Deps{Theme: theme.New("aztec", theme.AztecPalette())})
- m = applyAll(m, tea.WindowSizeMsg{Width: 120, Height: 30})
- m = applyAll(m, client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 7903}})
-
- footer := stripANSIstr(m.renderFooter())
- if !strings.Contains(footer, "ctx 7.9K") {
- t.Errorf("expected bare context size in footer:\n%s", footer)
- }
- if strings.ContainsAny(footer, ctxGlyphOk+ctxGlyphWarn+ctxGlyphDanger+ctxGlyphEmpty) {
- t.Errorf("expected no bar without a window:\n%s", footer)
- }
-}
-
// TestContextMeterTracksLatestTurnNotCumulative pins the two-axis usage model:
// m.contextTokens is CURRENT occupancy — assigned (not summed) from each
// TurnEndMsg's InputTokens — while m.usage is the SESSION-CUMULATIVE total fed
@@ -103,6 +52,7 @@ func TestContextMeterTracksLatestTurnNotCumulative(t *testing.T) {
}
// The cache-hit facet is cumulative cache-read / cumulative input: 1100/2200 = 50%.
+ m = stockStatusFrame(t, m)
footer := stripANSIstr(m.renderFooter())
if !strings.Contains(footer, "cache 50%") {
t.Errorf("expected cumulative cache-hit rate 50%% in footer:\n%s", footer)
@@ -116,137 +66,15 @@ func TestContextMeterTracksLatestTurnNotCumulative(t *testing.T) {
// TestFooterNarrowWidthTiers asserts the footer sheds detail in priority order
// as width shrinks — facets first, the context signal last. Context % must
// survive in every tier where anything fits beside the left status.
-func TestFooterNarrowWidthTiers(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- m := newTestModelFromDeps(Deps{Theme: th})
- m.resolvedSessionModel = client.ResolvedModel{ContextWindow: 200000}
- m = applyAll(m, client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 140000, OutputTokens: 345}})
- m = applyAll(m, client.ResultMsg{
- Stop: "end_turn",
- Usage: client.Usage{InputTokens: 140000, OutputTokens: 345, CacheReadTokens: 70000},
- })
- left := "ready"
-
- // Wide: full tier — meter (with used/total) AND facets.
- wide := stripANSIstr(m.fitFooter(left, 120))
- if !strings.Contains(wide, "140K/200K") || !strings.Contains(wide, "↑140K") {
- t.Errorf("wide footer should be the full tier:\n%q", wide)
- }
-
- // Medium: meter survives, facets dropped.
- med := stripANSIstr(m.fitFooter(left, 40))
- if strings.Contains(med, "↑140K") {
- t.Errorf("medium footer should drop io/cache facets first:\n%q", med)
- }
- if !strings.Contains(med, "ctx ") || !strings.Contains(med, "70%") {
- t.Errorf("medium footer should keep the context signal:\n%q", med)
- }
-
- // Tight: even the minimal bar-less percentage must carry the context %.
- tight := stripANSIstr(m.fitFooter(left, 22))
- if !strings.Contains(tight, "ctx ") || !strings.Contains(tight, "70%") {
- t.Errorf("tight footer should still show ctx %%:\n%q", tight)
- }
- if strings.Contains(tight, "140K/200K") {
- t.Errorf("tight footer should not carry used/total:\n%q", tight)
- }
-
- // Too narrow for anything: just the left status, no usage bleed-through.
- none := stripANSIstr(m.fitFooter(left, 10))
- if strings.Contains(none, "ctx") {
- t.Errorf("ultra-narrow footer should drop the usage segment entirely:\n%q", none)
- }
-}
-
// TestFooterTeamSegmentTiers asserts the live-team footer summary segment tiers
// alongside the context meter, and that the team segment (an advertisement) is the
// FIRST thing dropped under width pressure while the context % survives longest.
-func TestFooterTeamSegmentTiers(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- m := newTestModelFromDeps(Deps{Theme: th})
- m.resolvedSessionModel = client.ResolvedModel{ContextWindow: 200000}
- m = applyAll(m, tea.WindowSizeMsg{Width: 200, Height: 30})
- m = applyAll(m, client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 140000, OutputTokens: 345}})
- m = applyAll(m, client.ResultMsg{
- Stop: "end_turn",
- Usage: client.Usage{InputTokens: 140000, OutputTokens: 345, CacheReadTokens: 70000},
- })
- m = seedTeam(m, func(c *conversation) {
- c.startTeamCard("t1", "team-x", roster()) // lead + scout, both working → 2/2
- })
- left := "ready"
-
- // Wide: full team tier (glyph + team-id + k/N working + f6 agents) AND the
- // full context meter (ctx + %).
- wide := stripANSIstr(m.fitFooter(left, 200))
- for _, want := range []string{teamLiveGlyph, "team-x", "2/2 working", "f6 agents", "ctx ", "70%"} {
- if !strings.Contains(wide, want) {
- t.Errorf("wide footer should contain %q:\n%q", want, wide)
- }
- }
-
- // Medium: team segment degrades to the id-less "⟳ k/N working · f6"; the
- // context % must still be present.
- med := stripANSIstr(m.fitFooter(left, 56))
- if !strings.Contains(med, teamLiveGlyph) || !strings.Contains(med, "2/2 working") {
- t.Errorf("medium footer should keep the team summary:\n%q", med)
- }
- if strings.Contains(med, "team-x") {
- t.Errorf("medium footer should drop the team id:\n%q", med)
- }
- if !strings.Contains(med, "ctx ") || !strings.Contains(med, "70%") {
- t.Errorf("medium footer should keep the context signal:\n%q", med)
- }
-
- // Tight: the team segment is dropped entirely; the context % wins (survives
- // longest). This locks the priority: context % over the team advertisement.
- tight := stripANSIstr(m.fitFooter(left, 18))
- if strings.Contains(tight, teamLiveGlyph) {
- t.Errorf("tight footer should drop the team segment:\n%q", tight)
- }
- if !strings.Contains(tight, "ctx ") || !strings.Contains(tight, "70%") {
- t.Errorf("tight footer should still show ctx %%:\n%q", tight)
- }
-}
-
// TestFooterNoTeamSegment is the regression guard for the byte-identical no-team
// path: with no team seeded the footer carries no team glyph and no "team-" id.
-func TestFooterNoTeamSegment(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- m := newTestModelFromDeps(Deps{Theme: th})
- m.resolvedSessionModel = client.ResolvedModel{ContextWindow: 200000}
- m = applyAll(m, client.ResultMsg{
- Stop: "end_turn",
- Usage: client.Usage{InputTokens: 140000, OutputTokens: 345},
- })
- footer := stripANSIstr(m.fitFooter("ready", 200))
- if strings.Contains(footer, teamLiveGlyph) || strings.Contains(footer, "team-") {
- t.Errorf("no-team footer must carry no team segment:\n%q", footer)
- }
-}
-
// TestFooterTeamDoneDropsSegment asserts a team that has ENDED drops the footer
// segment — latestTeamBlock still returns it, so liveTeamBlock's !teamDone gate is
// what hides it. This is deliberately NARROWER than the f6 overlay, which
// opens on the last-seen team (done or not) to review a finished roster.
-func TestFooterTeamDoneDropsSegment(t *testing.T) {
- th := theme.New("aztec", theme.AztecPalette())
- m := newTestModelFromDeps(Deps{Theme: th})
- m.resolvedSessionModel = client.ResolvedModel{ContextWindow: 200000}
- m = applyAll(m, client.ResultMsg{
- Stop: "end_turn",
- Usage: client.Usage{InputTokens: 140000},
- })
- m = seedTeam(m, func(c *conversation) {
- c.startTeamCard("t1", "team-x", roster())
- c.finishTeamCard("t1", "team-x", 3, "end_turn", client.Usage{InputTokens: 100}, nil)
- })
- footer := stripANSIstr(m.fitFooter("ready", 200))
- if strings.Contains(footer, teamLiveGlyph) || strings.Contains(footer, "team-x") {
- t.Errorf("ended team must drop the footer segment:\n%q", footer)
- }
-}
-
// TestAgentsInvSlashCommandEndToEnd drives the FULL palette path for the /agents
// definition inventory (issue #15, Gap A): type "/agents", press enter, and assert
// the panel opens, fires ListAgents, and renders the resolved defs in the
@@ -391,31 +219,6 @@ func TestTeamOverlayF6MidRunEndToEnd(t *testing.T) {
}
}
-// TestHeaderTruncatesLongModel asserts a long model id is capped in the header.
-// The model segment renders from the EFFECTIVE model the server resolved (echoed on
-// SessionReadyMsg), so the test drives a create response with a long resolved id —
-// the header shows it (no model segment while still connecting, by design).
-func TestHeaderTruncatesLongModel(t *testing.T) {
- long := "anthropic/claude-opus-4-8-with-a-really-long-suffix-2026"
- conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{},
- resolvedModel: client.ResolvedModel{ProviderID: "anthropic", ModelID: long}}
- m := newTestModelFromDeps(Deps{Theme: theme.New("aztec", theme.AztecPalette()), Session: conv, Conv: conv, Ctx: context.Background()})
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 200, Height: 30},
- client.SessionReadyMsg{SessionID: "sess-test-0001", ResolvedModel: client.ResolvedModel{ProviderID: "anthropic", ModelID: long}},
- )
- header := stripANSIstr(m.renderHeader())
- if strings.Contains(header, long) {
- t.Errorf("long model id should be truncated in header:\n%q", header)
- }
- if !strings.Contains(header, "…") {
- t.Errorf("truncated model should carry an ellipsis:\n%q", header)
- }
- if !strings.Contains(header, "anthropic/claude") {
- t.Errorf("truncation should keep the model prefix:\n%q", header)
- }
-}
-
// TestEditCardRendersDiffInConversation drives an Edit tool call through the
// conversation and asserts the rendered viewport shows a red/green diff (not raw
// JSON args).
@@ -611,6 +414,26 @@ func TestTurnEndStatLine(t *testing.T) {
}
}
+// TestTurnStatCacheReachesScrollback is the integration guard for the per-turn cache
+// facet: a TurnEndMsg carrying a material cache rate, driven through the REAL update
+// path (TurnEndMsg → addTurnStat(renderfmt.TurnStatLine) → blockTurnStat → renderBlockFresh),
+// must surface "% cached" in the rendered conversation scrollback. TestTurnStatLine
+// tests the formatter in isolation; this proves the string actually reaches a rendered
+// block (the renderfmt.TrivialTurn gate, the conversation append, and the block render all wired).
+func TestTurnStatCacheReachesScrollback(t *testing.T) {
+ m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette()))
+ m = applyAll(m,
+ tea.WindowSizeMsg{Width: 100, Height: 30},
+ client.SessionReadyMsg{SessionID: "sess-test-0001"},
+ // Non-trivial tokens (so the stat line is not suppressed) with an 88% cache rate.
+ client.TurnEndMsg{Turn: 1, Usage: client.Usage{InputTokens: 1500, OutputTokens: 300, CacheReadTokens: 1320}, DurationMs: 4100},
+ )
+ got := stripANSIstr(m.rend.renderConversation(&m.conv, m.expandTools))
+ if !strings.Contains(got, "88% cached") {
+ t.Errorf("rendered scrollback missing the per-turn cache facet %q; got %q", "88% cached", got)
+ }
+}
+
// TestTurnEndTrivialSuppressed asserts a near-empty turn (tiny tokens, sub-second
// or no duration) produces NO stat line, so a long run isn't littered.
func TestTurnEndTrivialSuppressed(t *testing.T) {
diff --git a/cmd/mecatui/ui/internal/renderfmt/renderfmt.go b/cmd/mecatui/ui/internal/renderfmt/renderfmt.go
new file mode 100644
index 0000000000..db2a71c83a
--- /dev/null
+++ b/cmd/mecatui/ui/internal/renderfmt/renderfmt.go
@@ -0,0 +1,247 @@
+// Package renderfmt provides stateless formatting shared by mecatui renderers.
+package renderfmt
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/stacklok/mecatl/cmd/mecatui/client"
+ "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
+ "github.com/stacklok/mecatl/cmd/mecatui/theme"
+)
+
+const (
+ ctxWarnFraction = 0.60
+ ctxDangerFraction = 0.85
+ ctxBarWidth = 8
+ ctxGlyphOK = "▒"
+ ctxGlyphWarn = "▓"
+ ctxGlyphDanger = "█"
+ ctxGlyphEmpty = "░"
+ ctxDangerMark = " ⚠"
+
+ turnStatCacheFloor = 0.10
+ trivialTurnTokens = 50
+ slotCtxWarn = "ctxWarn"
+ slotMuted = "muted"
+)
+
+// HumanizeTokens renders a token count compactly.
+func HumanizeTokens(n int64) string {
+ if n < 0 {
+ n = 0
+ }
+ switch {
+ case n < 1000:
+ return fmt.Sprintf("%d", n)
+ case n < 1_000_000:
+ return trimDecimal(float64(n)/1000.0) + "K"
+ default:
+ return trimDecimal(float64(n)/1_000_000.0) + "M"
+ }
+}
+
+// trimDecimal formats v to one decimal place, dropping a redundant ".0".
+func trimDecimal(v float64) string {
+ s := fmt.Sprintf("%.1f", v)
+ return strings.TrimSuffix(s, ".0")
+}
+
+// CacheHitRate returns the cache-read fraction of input tokens.
+func CacheHitRate(u client.Usage) float64 {
+ if u.InputTokens <= 0 {
+ return 0
+ }
+ return float64(u.CacheReadTokens) / float64(u.InputTokens)
+}
+
+// PctString formats a fraction as a clamped integer percentage.
+func PctString(frac float64) string {
+ p := int(frac*100 + 0.5)
+ if p < 0 {
+ p = 0
+ }
+ if p > 100 {
+ p = 100
+ }
+ return fmt.Sprintf("%d%%", p)
+}
+
+// contextFraction returns the clamped used/window fill fraction in [0,1].
+func contextFraction(used, window int64) float64 {
+ if window <= 0 {
+ return 0
+ }
+ frac := float64(used) / float64(window)
+ if frac < 0 {
+ return 0
+ }
+ if frac > 1 {
+ return 1
+ }
+ return frac
+}
+
+func contextPressureSlot(frac float64) string {
+ switch {
+ case frac >= ctxDangerFraction:
+ return "ctxDanger"
+ case frac >= ctxWarnFraction:
+ return slotCtxWarn
+ default:
+ return "ctxOk"
+ }
+}
+
+func contextGlyph(frac float64) string {
+ switch {
+ case frac >= ctxDangerFraction:
+ return ctxGlyphDanger
+ case frac >= ctxWarnFraction:
+ return ctxGlyphWarn
+ default:
+ return ctxGlyphOK
+ }
+}
+
+// contextLabel formats the percentage with the non-colour danger cue.
+func contextLabel(frac float64) string {
+ label := PctString(frac)
+ if frac >= ctxDangerFraction {
+ label += ctxDangerMark
+ }
+ return label
+}
+
+// RenderContextMeter formats context for a team roster subhead.
+func RenderContextMeter(th theme.Theme, used, window int64) string {
+ if used < 0 {
+ used = 0
+ }
+ if window <= 0 {
+ return "ctx " + HumanizeTokens(used)
+ }
+ frac := contextFraction(used, window)
+ meter := th.Style(contextPressureSlot(frac)).Render(contextBar(frac) + " " + contextLabel(frac))
+ return "ctx " + meter + " · " + HumanizeTokens(used) + "/" + HumanizeTokens(window)
+}
+
+// RenderContextMeterPlain is the ANSI-free representation required before generic
+// roster wrapping; RenderContextMeter's styled bar must not be sanitized as raw text.
+func RenderContextMeterPlain(used, window int64) string {
+ if used < 0 {
+ used = 0
+ }
+ if window <= 0 {
+ return "ctx " + HumanizeTokens(used)
+ }
+ frac := contextFraction(used, window)
+ return "ctx " + contextBar(frac) + " " + contextLabel(frac) + " · " + HumanizeTokens(used) + "/" + HumanizeTokens(window)
+}
+
+// contextBar builds a meter bar whose glyphs carry the pressure band.
+func contextBar(frac float64) string {
+ filled := int(frac*float64(ctxBarWidth) + 0.5)
+ if filled > ctxBarWidth {
+ filled = ctxBarWidth
+ }
+ return strings.Repeat(contextGlyph(frac), filled) + strings.Repeat(ctxGlyphEmpty, ctxBarWidth-filled)
+}
+
+// TurnStatLine formats the inline per-turn model-exchange statistics.
+func TurnStatLine(msg client.TurnEndMsg) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "↑%s ↓%s", HumanizeTokens(msg.Usage.InputTokens), HumanizeTokens(msg.Usage.OutputTokens))
+ if msg.DurationMs > 0 {
+ b.WriteString(" · " + FormatDuration(msg.DurationMs))
+ }
+ if rate := CacheHitRate(msg.Usage); rate >= turnStatCacheFloor {
+ b.WriteString(" · " + PctString(rate) + " cached")
+ }
+ return b.String()
+}
+
+// TrivialTurn reports whether a turn's statistics are too negligible for scrollback.
+func TrivialTurn(msg client.TurnEndMsg) bool {
+ return msg.Usage.InputTokens <= trivialTurnTokens && msg.Usage.OutputTokens <= trivialTurnTokens && msg.DurationMs < 1000
+}
+
+// FormatDuration renders an elapsed millisecond count compactly.
+func FormatDuration(ms int64) string {
+ if ms < 0 {
+ ms = 0
+ }
+ if ms < 1000 {
+ return fmt.Sprintf("%dms", ms)
+ }
+ return trimDecimal(float64(ms)/1000.0) + "s"
+}
+
+// HumanizeDuration renders a millisecond wall-clock duration compactly: sub-second
+// as "Nms", under a minute as "N.Ns", else "Nm Ns". A non-positive duration (no
+// clock) renders as "0ms".
+func HumanizeDuration(ms int64) string {
+ if ms <= 0 {
+ return "0ms"
+ }
+ if ms < 1000 {
+ return strconv.FormatInt(ms, 10) + "ms"
+ }
+ secs := float64(ms) / 1000.0
+ if secs < 60 {
+ return trimDecimal(secs) + "s"
+ }
+ m := int64(secs) / 60
+ s := int64(secs) % 60
+ return strconv.FormatInt(m, 10) + "m " + strconv.FormatInt(s, 10) + "s"
+}
+
+// HumanizeBytes renders a byte count compactly: bytes verbatim under 1 KB, then
+// "N.N KB"/"N.N MB"/"N.N GB"/"N.N TB" with one decimal (trailing ".0" trimmed).
+// The math is SI/decimal (1 KB = 1000 B, 1 MB = 1e6 B, …).
+func HumanizeBytes(n int64) string {
+ if n < 0 {
+ n = 0
+ }
+ const unit = 1000
+ if n < unit {
+ return strconv.FormatInt(n, 10) + " B"
+ }
+ div, exp := int64(unit), 0
+ for value := n / unit; value >= unit && exp < 3; value /= unit {
+ div *= unit
+ exp++
+ }
+ return trimDecimal(float64(n)/float64(div)) + " " + [...]string{"KB", "MB", "GB", "TB"}[exp]
+}
+
+// StopReasonLabel maps a terminal stop reason to status text and theme style slot.
+func StopReasonLabel(stop string) (text, slot string) {
+ switch stop {
+ case "end_turn", "":
+ return "done", slotMuted
+ case "max_turns":
+ return "stopped · turn limit", slotCtxWarn
+ case "max_tool_calls":
+ return "stopped · tool-call limit", slotCtxWarn
+ case "max_consecutive_failures":
+ return "stopped · repeated failures", slotCtxWarn
+ case "budget":
+ return "stopped · token budget", slotCtxWarn
+ case "cancelled":
+ return "cancelled", slotMuted
+ case "no_progress":
+ return "stopped · no progress", slotCtxWarn
+ case "structured_output":
+ return "stopped · schema unmet", slotCtxWarn
+ case "plan_approved":
+ return "plan approved · executing", slotMuted
+ case "plan_iterate":
+ return "plan iterate · awaiting your feedback", slotMuted
+ case "error":
+ return "error", "errorText"
+ default:
+ return terminaltext.Sanitize(stop), slotMuted
+ }
+}
diff --git a/cmd/mecatui/ui/internal/renderfmt/renderfmt_test.go b/cmd/mecatui/ui/internal/renderfmt/renderfmt_test.go
new file mode 100644
index 0000000000..3ce01fa149
--- /dev/null
+++ b/cmd/mecatui/ui/internal/renderfmt/renderfmt_test.go
@@ -0,0 +1,229 @@
+package renderfmt_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stacklok/mecatl/cmd/mecatui/client"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
+)
+
+func TestHumanizeTokens(t *testing.T) {
+ cases := []struct {
+ in int64
+ want string
+ }{
+ {0, "0"},
+ {1, "1"},
+ {999, "999"},
+ {1000, "1K"},
+ {2000, "2K"},
+ {7903, "7.9K"},
+ {1500, "1.5K"},
+ {999999, "1000K"},
+ {1_000_000, "1M"},
+ {1_200_000, "1.2M"},
+ {-5, "0"},
+ }
+ for _, c := range cases {
+ if got := renderfmt.HumanizeTokens(c.in); got != c.want {
+ t.Errorf("renderfmt.HumanizeTokens(%d) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestCacheHitRate(t *testing.T) {
+ cases := []struct {
+ name string
+ u client.Usage
+ want float64
+ }{
+ {"zero input", client.Usage{}, 0},
+ {"half", client.Usage{InputTokens: 100, CacheReadTokens: 50}, 0.5},
+ {"full", client.Usage{InputTokens: 100, CacheReadTokens: 100}, 1.0},
+ {"88pct", client.Usage{InputTokens: 1500, CacheReadTokens: 1320}, 0.88},
+ }
+ for _, c := range cases {
+ got := renderfmt.CacheHitRate(c.u)
+ if diff := got - c.want; diff > 1e-9 || diff < -1e-9 {
+ t.Errorf("%s: renderfmt.CacheHitRate = %v, want %v", c.name, got, c.want)
+ }
+ }
+}
+
+func TestPctString(t *testing.T) {
+ cases := []struct {
+ in float64
+ want string
+ }{
+ {0, "0%"},
+ {0.5, "50%"},
+ {0.881, "88%"},
+ {1.0, "100%"},
+ {1.5, "100%"},
+ {-0.2, "0%"},
+ }
+ for _, c := range cases {
+ if got := renderfmt.PctString(c.in); got != c.want {
+ t.Errorf("renderfmt.PctString(%v) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestHumanizeDuration(t *testing.T) {
+ for _, tc := range []struct {
+ ms int64
+ want string
+ }{
+ {-1, "0ms"}, {840, "840ms"}, {2000, "2s"}, {4100, "4.1s"}, {60000, "1m 0s"},
+ } {
+ if got := renderfmt.HumanizeDuration(tc.ms); got != tc.want {
+ t.Errorf("HumanizeDuration(%d) = %q, want %q", tc.ms, got, tc.want)
+ }
+ }
+}
+
+func TestRenderContextMeterPlain(t *testing.T) {
+ for _, tc := range []struct {
+ used, window int64
+ want string
+ }{
+ {-1, 0, "ctx 0"},
+ {40_000, 200_000, "ctx ▒▒░░░░░░ 20% · 40K/200K"},
+ {190_000, 200_000, "ctx ████████ 95% ⚠ · 190K/200K"},
+ } {
+ if got := renderfmt.RenderContextMeterPlain(tc.used, tc.window); got != tc.want {
+ t.Errorf("RenderContextMeterPlain(%d, %d) = %q, want %q", tc.used, tc.window, got, tc.want)
+ }
+ }
+}
+
+// TestTurnStatLine pins the per-turn stat line: it always leads with the
+// input/output token arrows, appends the duration when the server reported one,
+// and appends "N% cached" ONLY when the turn's cache-hit rate is at or above
+// turnStatCacheFloor (decision 4). A negligible cache rate is omitted to keep the
+// line scannable.
+func TestTurnStatLine(t *testing.T) {
+ cases := []struct {
+ name string
+ usage client.Usage
+ durationMs int64
+ wantSubs []string
+ absentSubs []string
+ }{
+ {
+ name: "no cache facet below floor",
+ usage: client.Usage{InputTokens: 1200, OutputTokens: 340}, // 0% cache
+ durationMs: 4100,
+ wantSubs: []string{"↑1.2K", "↓340", "4.1s"},
+ absentSubs: []string{"cached"},
+ },
+ {
+ name: "cache facet when material",
+ usage: client.Usage{InputTokens: 1500, OutputTokens: 30, CacheReadTokens: 1320}, // 88%
+ durationMs: 0,
+ wantSubs: []string{"↑1.5K", "↓30", "88% cached"},
+ absentSubs: []string{" · 4"}, // no duration segment when 0ms
+ },
+ {
+ name: "just under the floor is omitted",
+ usage: client.Usage{InputTokens: 1000, OutputTokens: 10, CacheReadTokens: 90}, // 9% < 10%
+ durationMs: 0,
+ wantSubs: []string{"↑1K", "↓10"},
+ absentSubs: []string{"cached"},
+ },
+ {
+ name: "exactly at the floor is shown",
+ usage: client.Usage{InputTokens: 1000, OutputTokens: 10, CacheReadTokens: 100}, // 10%
+ durationMs: 0,
+ wantSubs: []string{"10% cached"},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := renderfmt.TurnStatLine(client.TurnEndMsg{Usage: c.usage, DurationMs: c.durationMs})
+ for _, sub := range c.wantSubs {
+ if !strings.Contains(got, sub) {
+ t.Errorf("renderfmt.TurnStatLine = %q, want it to contain %q", got, sub)
+ }
+ }
+ for _, sub := range c.absentSubs {
+ if strings.Contains(got, sub) {
+ t.Errorf("renderfmt.TurnStatLine = %q, must NOT contain %q", got, sub)
+ }
+ }
+ })
+ }
+}
+
+// TestStopReasonLabel locks the human phrasing + style slot for every stop
+// reason in the session.StopReason / proto Result.stop vocabulary, plus the
+// empty and unknown fallbacks. The limit stops carry the warning slot; a clean
+// end_turn / cancelled is muted; error is the error slot.
+func TestStopReasonLabel(t *testing.T) {
+ cases := []struct {
+ stop string
+ text string
+ slot string
+ }{
+ {"end_turn", "done", "muted"},
+ {"", "done", "muted"},
+ {"max_turns", "stopped · turn limit", "ctxWarn"},
+ {"max_tool_calls", "stopped · tool-call limit", "ctxWarn"},
+ {"max_consecutive_failures", "stopped · repeated failures", "ctxWarn"},
+ {"budget", "stopped · token budget", "ctxWarn"},
+ {"cancelled", "cancelled", "muted"},
+ {"no_progress", "stopped · no progress", "ctxWarn"},
+ {"structured_output", "stopped · schema unmet", "ctxWarn"},
+ {"plan_approved", "plan approved · executing", "muted"},
+ {"plan_iterate", "plan iterate · awaiting your feedback", "muted"},
+ {"error", "error", "errorText"},
+ {"some_future_reason", "some_future_reason", "muted"},
+ }
+ for _, c := range cases {
+ text, slot := renderfmt.StopReasonLabel(c.stop)
+ if text != c.text {
+ t.Errorf("renderfmt.StopReasonLabel(%q) text = %q, want %q", c.stop, text, c.text)
+ }
+ if slot != c.slot {
+ t.Errorf("renderfmt.StopReasonLabel(%q) slot = %q, want %q", c.stop, slot, c.slot)
+ }
+ }
+}
+
+// TestStopReasonLabelSanitizesUnknown asserts an unknown reason carrying an ESC
+// byte is stripped before it reaches the footer (it is rendered via lipgloss,
+// which would otherwise pass the escape through).
+func TestStopReasonLabelSanitizesUnknown(t *testing.T) {
+ text, _ := renderfmt.StopReasonLabel("evil\x1b[2Jreason")
+ if strings.ContainsRune(text, 0x1b) {
+ t.Errorf("unknown stop reason should be sanitized, got %q", text)
+ }
+}
+
+func TestHumanizeBytesSI(t *testing.T) {
+ // SI/decimal math (1 KB = 1000 B, 1 MB = 1e6 B) — the number must reconcile
+ // with bytes/1000, so a labelled "KB" is honest (not mislabelled KiB).
+ cases := []struct {
+ n int64
+ want string
+ }{
+ {0, "0 B"},
+ {-5, "0 B"},
+ {999, "999 B"},
+ {1000, "1 KB"}, // exactly 1000 → 1 KB (would be "0.98 KB" under 1024 math)
+ {1500, "1.5 KB"}, // 1500/1000
+ {5000, "5 KB"}, // trailing .0 trimmed
+ {999999, "1000 KB"},
+ {1000000, "1 MB"}, // exactly 1e6 → 1 MB
+ {2500000, "2.5 MB"},
+ {1_000_000_000, "1 GB"},
+ {2_500_000_000, "2.5 GB"},
+ {1_000_000_000_000, "1 TB"},
+ }
+ for _, c := range cases {
+ if got := renderfmt.HumanizeBytes(c.n); got != c.want {
+ t.Errorf("renderfmt.HumanizeBytes(%d) = %q, want %q", c.n, got, c.want)
+ }
+ }
+}
diff --git a/cmd/mecatui/ui/jev_routing_user_journey_test.go b/cmd/mecatui/ui/jev_routing_user_journey_test.go
index 1481923a5b..80da7317bb 100644
--- a/cmd/mecatui/ui/jev_routing_user_journey_test.go
+++ b/cmd/mecatui/ui/jev_routing_user_journey_test.go
@@ -148,6 +148,7 @@ func TestADR_0352_Scenario7_UserJourney(t *testing.T) {
m = mm.(Model)
m.agentsTab = tabSubagents
m.subagents = subagentState{view: subagentFocus, child: "sub-child"}
+ m = goldenStatusFrame(t, m)
views["Subagent focus"] = stripANSIstr(m.View().Content)
m.subagents = subagentState{view: subagentFocus, child: "accepted-child"}
views["accepted Subagent focus"] = stripANSIstr(m.View().Content)
diff --git a/cmd/mecatui/ui/keymark_liveness_test.go b/cmd/mecatui/ui/keymark_liveness_test.go
index 93d0eca86c..c6ef82cd11 100644
--- a/cmd/mecatui/ui/keymark_liveness_test.go
+++ b/cmd/mecatui/ui/keymark_liveness_test.go
@@ -200,149 +200,6 @@ func TestInlineCardsReflectKeyOverride(t *testing.T) {
// mnemonics (allow/allowAlways/deny), the quitArmed cue, the fatal-screen cue, and
// the team/subagent/parallel agents-overlay advertisements must each carry the
// overridden chord and NOT the default.
-func TestFooterReflectsKeyOverride(t *testing.T) {
- km := applyKeyOverrides(defaultKeys(), overrideAll())
- m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette()))
- m.keys = km
- m.rend = newRenderer(m.deps.Theme, keyMarkings(km))
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 120, Height: 30},
- client.SessionReadyMsg{SessionID: "sess-test-0001", Capabilities: allOnCaps()},
- )
-
- t.Run("idle help line", func(t *testing.T) {
- m.phase = phaseIdle
- got := stripANSIstr(m.renderFooter())
- // help line: " help · / commands · quit"
- if !strings.Contains(got, "ctrl+f31 select all") {
- t.Errorf("footer help line should carry the overridden SelectAll chord ctrl+f31: %q", got)
- }
- if !strings.Contains(got, "ctrl+f32 copy") {
- t.Errorf("footer help line should carry the overridden CopySelection chord ctrl+f32: %q", got)
- }
- if strings.Contains(got, "ctrl+g select all") || strings.Contains(got, "ctrl+y copy") {
- t.Errorf("footer help line still shows a default selection chord: %q", got)
- }
- if !strings.Contains(got, "ctrl+f12 help") {
- t.Errorf("footer help line should carry the overridden help chord ctrl+f12: %q", got)
- }
- if !strings.Contains(got, "ctrl+f13 quit") {
- t.Errorf("footer help line should carry the overridden quit chord ctrl+f13: %q", got)
- }
- if strings.Contains(got, "? help") || strings.Contains(got, "ctrl+c quit") {
- t.Errorf("footer help line still shows a default chord: %q", got)
- }
- })
-
- t.Run("running queue/cancel affordances", func(t *testing.T) {
- m.phase = phaseRunning
- got := stripANSIstr(m.renderFooter())
- if !strings.Contains(got, "ctrl+f1 queue") {
- t.Errorf("running footer should carry the overridden submit ctrl+f1: %q", got)
- }
- if !strings.Contains(got, "ctrl+f33 clear") || !strings.Contains(got, "ctrl+f4 cancel") {
- t.Errorf("running footer should carry the overridden clear/cancel chords: %q", got)
- }
- if strings.Contains(got, "enter queue") || strings.Contains(got, "esc cancel") || strings.Contains(got, "ctrl+u clear") {
- t.Errorf("running footer still shows a default chord: %q", got)
- }
- })
-
- t.Run("plan-approval mnemonics", func(t *testing.T) {
- m.phase = phaseAwaitingApproval
- openApprovalSurface(&m).ask = pendingAsk{Tool: "PresentPlan", offerAlways: true, Args: `{"plan":"x"}`}
- got := stripANSIstr(m.renderFooter())
- // approvalMnemonic upper-cases the bare rune: y→Y, q→Q, n→N.
- if !strings.Contains(got, "Y approve & run") {
- t.Errorf("plan-approval footer should carry the overridden allow mnemonic Y: %q", got)
- }
- if !strings.Contains(got, "Q auto-accept") {
- t.Errorf("plan-approval footer should carry the overridden allowAlways mnemonic Q: %q", got)
- }
- if !strings.Contains(got, "N iterate") {
- t.Errorf("plan-approval footer should carry the overridden deny mnemonic N: %q", got)
- }
- if strings.Contains(got, "A approve") || strings.Contains(got, "W auto-accept") || strings.Contains(got, "D iterate") {
- t.Errorf("plan-approval footer still shows a default mnemonic: %q", got)
- }
- })
-
- t.Run("quitArmed cue", func(t *testing.T) {
- m.phase = phaseIdle
- m.quitArmed = true
- got := stripANSIstr(m.renderFooter())
- if !strings.Contains(got, "ctrl+f13 again to quit") {
- t.Errorf("quitArmed cue should carry the overridden quit chord ctrl+f13: %q", got)
- }
- if strings.Contains(got, "ctrl+c again") {
- t.Errorf("quitArmed cue still shows the default ctrl+c: %q", got)
- }
- m.quitArmed = false
- })
-
- t.Run("quitArmed statusMsg carries the live override (not the stale default)", func(t *testing.T) {
- // The arm path (onQuitKey) sets m.statusMsg LIVE from m.keys.Quit, so the
- // footer-left hint advertises the rebound chord and not the frozen
- // "ctrl+c" default (issue #457 SPEC gap: quitHint was a constant). Drive
- // the real arm reducer and assert the footer-left (idleFooterLeft →
- // statusMsg) and the footer-help cue BOTH carry the override.
- mm := m
- mm.phase = phaseIdle
- mm.quitArmed = false
- mm.statusMsg = ""
- mm, _ = pressKey(mm, tea.KeyPressMsg{Code: tea.KeyF13, Mod: tea.ModCtrl})
- if !mm.quitArmed {
- t.Fatal("overridden ctrl+f13 should arm the quit guard")
- }
- want := "press ctrl+f13 again to quit"
- if mm.statusMsg != want {
- t.Errorf("statusMsg should carry the live quit hint %q, got %q", want, mm.statusMsg)
- }
- if strings.Contains(mm.statusMsg, "ctrl+c") {
- t.Errorf("statusMsg still advertises the stale default ctrl+c: %q", mm.statusMsg)
- }
- got := stripANSIstr(mm.renderFooter())
- if !strings.Contains(got, want) {
- t.Errorf("armed footer-left should show the live override %q: %q", want, got)
- }
- if strings.Contains(got, "press ctrl+c again to quit") {
- t.Errorf("armed footer still shows the stale default ctrl+c hint: %q", got)
- }
- })
-
- t.Run("fatal screen cue", func(t *testing.T) {
- m.phase = phaseFatal
- m.fatalErr = "boom"
- got := stripANSIstr(m.renderFatal())
- if !strings.Contains(got, "press ctrl+f13 to quit") {
- t.Errorf("fatal screen should carry the overridden quit chord ctrl+f13: %q", got)
- }
- if strings.Contains(got, "press ctrl+c") {
- t.Errorf("fatal screen still shows the default ctrl+c: %q", got)
- }
- })
-
- t.Run("team/subagent agents advertisement", func(t *testing.T) {
- // Build a live team block + subagent fleet so fitFooter renders the
- // agents prefix tiers that advertise the overlay chord.
- m.phase = phaseIdle
- m.conv = conversation{}
- m.conv.addTool("t1", "Team", `{"goal":"ship"}`)
- m.conv.startTeamCard("t1", "team-abc", roster())
- m.conv.updateTeamCardMember(member("lead", "tool.call", client.TeamMsg{ToolName: "Edit"}))
- m.conv.startSubagentCard("p1", "scout", "", "", "", "")
- m.conv.updateSubagentCard(client.SubagentMsg{Kind: client.SubagentTool, ParentCallID: "p1", ToolName: "Grep", ToolCount: 1})
- m.refreshView()
- got := stripANSIstr(m.fitFooter("ready", 160))
- if !strings.Contains(got, "ctrl+f9") {
- t.Errorf("footer agents prefix should carry the overridden agents chord ctrl+f9: %q", got)
- }
- if strings.Contains(got, "f6") {
- t.Errorf("footer agents prefix still shows the default f6: %q", got)
- }
- })
-}
-
// TestPlanReviewActionBarReflectsKeyOverride proves the plan-review action bar
// reads the LIVE approval and scroll chords (issue #457): the bracketed
// mnemonics and ScrollU/ScrollD/ScrollTop/ScrollBottom hint must carry the
@@ -553,18 +410,6 @@ func TestDefaultFooterHelp(t *testing.T) {
if !strings.Contains(got, "A approve & run · W auto-accept · D iterate") {
t.Errorf("default plan-approval line should be the historical literal, got %q", got)
}
-
- // Team footer advertisement.
- m.phase = phaseIdle
- m.conv = conversation{}
- m.conv.addTool("t1", "Team", `{"goal":"ship"}`)
- m.conv.startTeamCard("t1", "team-abc", roster())
- m.conv.updateTeamCardMember(member("lead", "tool.call", client.TeamMsg{ToolName: "Edit"}))
- m.refreshView()
- got = stripANSIstr(m.fitFooter("ready", 160))
- if !strings.Contains(got, "f6 agents") {
- t.Errorf("default team footer should carry the historical f6 literal, got %q", got)
- }
}
// TestDefaultInlineCardsBytesUnchanged is the byte-identical guard for the default
diff --git a/cmd/mecatui/ui/mcp_test.go b/cmd/mecatui/ui/mcp_test.go
index 6c44d36fde..5045800bfc 100644
--- a/cmd/mecatui/ui/mcp_test.go
+++ b/cmd/mecatui/ui/mcp_test.go
@@ -296,6 +296,7 @@ func TestMCPPanelGolden(t *testing.T) {
if !st.groupsDone || len(st.groups) != 0 {
t.Fatalf("groups state = %#v, want done+empty", st)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_panel.golden", got)
}
@@ -308,6 +309,7 @@ func TestMCPPanelGroupsGolden(t *testing.T) {
if st == nil || len(st.groups) != 2 {
t.Fatalf("groups = %#v, want 2", st)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_panel_groups.golden", got)
}
@@ -445,6 +447,7 @@ func TestMCPResourceListGolden(t *testing.T) {
if st == nil || st.view != mcpResources || len(st.resources) != 2 {
t.Fatalf("resources not loaded: %#v", st)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_resources.golden", got)
}
@@ -463,6 +466,7 @@ func TestMCPResourcePreviewGolden(t *testing.T) {
if st == nil || st.view != mcpResourcePrev {
t.Fatalf("view = %v, want mcpResourcePrev", m.modal)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_resource_preview.golden", got)
}
@@ -559,6 +563,7 @@ func TestMCPPromptListGolden(t *testing.T) {
if st == nil || st.view != mcpPrompts || len(st.prompts) != 2 {
t.Fatalf("prompts not loaded: %#v", st)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_prompts.golden", got)
}
@@ -573,6 +578,7 @@ func TestMCPPromptArgsGolden(t *testing.T) {
if st == nil || st.view != mcpPromptArgs || len(st.argFields) != 1 {
t.Fatalf("arg entry not entered: %#v", st)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_prompt_args.golden", got)
}
@@ -713,6 +719,7 @@ func TestMCPErrInputGolden(t *testing.T) {
if st == nil || st.errCls != client.MCPErrInput {
t.Fatalf("errCls = %v, want input", st)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_err_input.golden", got)
}
@@ -725,6 +732,7 @@ func TestMCPErrServerGolden(t *testing.T) {
if st == nil || st.errCls != client.MCPErrServer {
t.Fatalf("errCls = %v, want server", st)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_err_server.golden", got)
}
@@ -737,6 +745,7 @@ func TestMCPErrNotConfiguredGolden(t *testing.T) {
if st == nil || st.errCls != client.MCPErrNotConfigured {
t.Fatalf("errCls = %v, want not-configured", st)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "mcp_err_not_configured.golden", got)
}
diff --git a/cmd/mecatui/ui/mode_test.go b/cmd/mecatui/ui/mode_test.go
index c521882f53..1590e76bce 100644
--- a/cmd/mecatui/ui/mode_test.go
+++ b/cmd/mecatui/ui/mode_test.go
@@ -56,8 +56,8 @@ func TestModeSwitchUpdatesServerAndHeader(t *testing.T) {
if m.activeMode != "plan" || m.pendingMode != "" {
t.Fatalf("active/pending mode = %q/%q, want plan/empty", m.activeMode, m.pendingMode)
}
- if header := stripANSIstr(m.renderHeader()); !strings.Contains(header, "mode plan") {
- t.Fatalf("header missing mode plan: %q", header)
+ if mode := m.statusLineSnapshot().Session.Mode; mode != "plan" {
+ t.Fatalf("status snapshot mode = %q, want plan", mode)
}
}
@@ -108,8 +108,8 @@ func TestModeSwitchFailureDefersToNextPrompt(t *testing.T) {
if !strings.Contains(stripANSIstr(m.statusMsg), "will apply on the next prompt") {
t.Fatalf("status = %q, want deferred notice", stripANSIstr(m.statusMsg))
}
- if header := stripANSIstr(m.renderHeader()); !strings.Contains(header, "mode plan pending") {
- t.Fatalf("header missing pending mode: %q", header)
+ if mode := m.statusLineSnapshot().Session.Mode; mode != "plan pending" {
+ t.Fatalf("status snapshot mode = %q, want plan pending", mode)
}
conv.setModeErr = nil
diff --git a/cmd/mecatui/ui/models_enrich_test.go b/cmd/mecatui/ui/models_enrich_test.go
index d451e24e6c..99c661ffd4 100644
--- a/cmd/mecatui/ui/models_enrich_test.go
+++ b/cmd/mecatui/ui/models_enrich_test.go
@@ -18,110 +18,8 @@ import (
// line, restart-now confirm overlay, ctrl+g global default, ●/★ markers, reconcile
// notice naming the fallback. Client-only (no proto/server change).
-// --- header next: badge ----------------------------------------------------
-
-// idleModelWith builds an idle, sized model with the given effective + pending-next
-// selections wired (no overlays open), so the header next-badge logic is exercised
-// directly. effective is delivered as the create response's resolved model; next is
-// set as the pending createModelSelection.
-func idleModelWith(t *testing.T, effective client.ResolvedModel, next client.ModelSelection, inv []client.ModelInfo) Model {
- t.Helper()
- conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}}
- m := newTestModelFromDeps(Deps{
- Session: conv,
- Conv: conv,
- Theme: theme.New("aztec", theme.AztecPalette()),
- Server: "127.0.0.1:8080",
- Mode: "default",
- Ctx: context.Background(),
- })
- m.modelCatalog.models = inv
- m = applyAll(m,
- tea.WindowSizeMsg{Width: 160, Height: 30},
- client.SessionReadyMsg{SessionID: "sess-test-0001", ResolvedModel: effective},
- )
- m.createModelSelection = next
- return m
-}
-
-// TestHeaderNextBadgeShownWhenDiffers: a pending-next model that differs from the
-// effective model renders a "next: " header segment (display name resolved
-// from the inventory).
-func TestHeaderNextBadgeShownWhenDiffers(t *testing.T) {
- inv := []client.ModelInfo{
- {ID: "gpt-5", ProviderID: "openai", DisplayName: "GPT-5"},
- {ID: "anthropic/claude", ProviderID: "openrouter", DisplayName: "Claude"},
- }
- m := idleModelWith(t,
- client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5"},
- client.ModelSelection{ProviderID: "openrouter", ModelID: "anthropic/claude"},
- inv)
- if got := m.headerNextBadge(); got != "next: Claude" {
- t.Fatalf("headerNextBadge = %q, want %q", got, "next: Claude")
- }
- header := stripANSIstr(m.renderHeader())
- if !strings.Contains(header, "next: Claude") {
- t.Fatalf("header missing the next: badge:\n%s", header)
- }
- // The effective model (GPT-5) is still the primary model segment.
- if !strings.Contains(header, "GPT-5") {
- t.Fatalf("header missing the effective model segment:\n%s", header)
- }
-}
-
-// TestHeaderNextBadgeHiddenWhenSame: a pending-next equal to the effective model
-// shows NO badge (nothing to preview).
-func TestHeaderNextBadgeHiddenWhenSame(t *testing.T) {
- m := idleModelWith(t,
- client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5"},
- client.ModelSelection{ProviderID: "openai", ModelID: "gpt-5"},
- nil)
- if got := m.headerNextBadge(); got != "" {
- t.Fatalf("headerNextBadge = %q, want empty (same model ⇒ no badge)", got)
- }
- if strings.Contains(stripANSIstr(m.renderHeader()), "next:") {
- t.Fatalf("header should carry no next: badge when next==effective")
- }
-}
-
-// TestHeaderNextBadgeHiddenWhenNoEffective: with no known effective model (older
-// server / connecting), the model SEGMENT already shows the pending-next, so a next:
-// badge would duplicate — it is suppressed.
-func TestHeaderNextBadgeHiddenWhenNoEffective(t *testing.T) {
- m := idleModelWith(t,
- client.ResolvedModel{}, // no effective model known
- client.ModelSelection{ProviderID: "openai", ModelID: "gpt-5"},
- nil)
- if got := m.headerNextBadge(); got != "" {
- t.Fatalf("headerNextBadge = %q, want empty (no effective ⇒ no badge)", got)
- }
-}
-
-// TestHeaderNextBadgeDroppedUnderWidthPressure: at a narrow width the next: badge is
-// the FIRST segment shed — the socket/mode survive, the badge does not.
-func TestHeaderNextBadgeDroppedUnderWidthPressure(t *testing.T) {
- inv := []client.ModelInfo{
- {ID: "gpt-5", ProviderID: "openai", DisplayName: "GPT-5"},
- {ID: "anthropic/claude", ProviderID: "openrouter", DisplayName: "Claude"},
- }
- m := idleModelWith(t,
- client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5"},
- client.ModelSelection{ProviderID: "openrouter", ModelID: "anthropic/claude"},
- inv)
- // Wide: the badge fits.
- if !strings.Contains(stripANSIstr(m.renderHeader()), "next: Claude") {
- t.Fatalf("at 160 cols the next: badge should fit")
- }
- // Narrow: shed the badge first; the socket (the LAST identity segment) survives.
- m = applyAll(m, tea.WindowSizeMsg{Width: 60, Height: 30})
- header := stripANSIstr(m.renderHeader())
- if strings.Contains(header, "next:") {
- t.Fatalf("at 60 cols the next: badge must be dropped first, got:\n%s", header)
- }
- if !strings.Contains(header, "127.0.0.1:8080") {
- t.Fatalf("the socket must survive when the next: badge is dropped, got:\n%s", header)
- }
-}
+// Model picker tests below cover selection and provenance; the selected status source
+// owns the former header-only next-model preview.
// --- provenance line -------------------------------------------------------
diff --git a/cmd/mecatui/ui/models_surface.go b/cmd/mecatui/ui/models_surface.go
index 27d00ebbce..5c4da771e1 100644
--- a/cmd/mecatui/ui/models_surface.go
+++ b/cmd/mecatui/ui/models_surface.go
@@ -16,6 +16,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
)
// modelsView is the active /models overlay.
@@ -470,7 +471,7 @@ func modelCapSegments(mi client.ModelInfo) []string {
segs = append(segs, "reason")
}
if mi.ContextLimit > 0 {
- segs = append(segs, humanizeTokens(mi.ContextLimit))
+ segs = append(segs, renderfmt.HumanizeTokens(mi.ContextLimit))
}
// ADR 0346: mark a row mecatl sends no cache breakpoint for. The row stays
// SELECTABLE, and marking rather than hiding was the explicit decision
diff --git a/cmd/mecatui/ui/models_test.go b/cmd/mecatui/ui/models_test.go
index d514802756..7ff1a3b731 100644
--- a/cmd/mecatui/ui/models_test.go
+++ b/cmd/mecatui/ui/models_test.go
@@ -1696,106 +1696,40 @@ func TestModelsCatalogUpdatesWhilePickerClosed(t *testing.T) {
}
}
-// TestHeaderToolhiveSegment proves the persistent "via ToolHive gateway"
-// header segment (issue #262 R6.3) appears ONLY when the active session's
-// provider is toolhive, and sheds under width pressure like any other
-// low-priority segment.
-func TestHeaderToolhiveSegment(t *testing.T) {
+// TestToolhiveProviderReachesStatusSnapshot proves the effective provider identity
+// is handed to the selected status source; source templates own its presentation.
+func TestToolhiveProviderReachesStatusSnapshot(t *testing.T) {
conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}}
m := newTestModelFromDeps(Deps{Session: conv, Conv: conv, Theme: theme.New("aztec", theme.AztecPalette()), Ctx: context.Background(), Server: "127.0.0.1:8080"})
- m.resolvedSessionModel = client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5"}
- if strings.Contains(stripANSIstr(m.renderHeader()), "via ToolHive gateway") {
- t.Fatal("non-toolhive session must NOT show the gateway segment")
- }
-
- m.resolvedSessionModel = client.ResolvedModel{ProviderID: "toolhive", ModelID: "claude-sonnet-4-6"}
- m = applyAll(m, tea.WindowSizeMsg{Width: 160, Height: 30})
- if !strings.Contains(stripANSIstr(m.renderHeader()), "via ToolHive gateway") {
- t.Fatal("a toolhive session must show the gateway segment at a wide width")
- }
-
- m.resolvedSessionModel = client.ResolvedModel{ProviderID: "toolhive-anthropic", ModelID: "claude-sonnet-4-6"}
- m.modelCatalog.statuses = []client.ProviderStatus{
- {ProviderID: "toolhive", State: "ok", AvailableNotDefault: true},
- {ProviderID: "toolhive-anthropic", State: "ok"},
- }
- header := stripANSIstr(m.renderHeader())
- if !strings.Contains(header, "via ToolHive gateway") || strings.Contains(header, "gateway available") {
- t.Fatalf("native ToolHive header must show one active-family segment, got:\n%s", header)
- }
-
- // At a narrow width the segment sheds along with the other low-priority
- // segments; the header must not panic/overflow.
- m = applyAll(m, tea.WindowSizeMsg{Width: 40, Height: 30})
- _ = m.renderHeader()
-}
-// TestHeaderGatewayAvailableSegment (N1): a muted " gateway
-// available" segment renders when an AvailableNotDefault status exists and the
-// active provider is NOT the gateway. It is the mutually-exclusive sibling of
-// the "via ToolHive gateway" segment (active case): when the gateway IS the
-// active default, availableNotDefaultStatus returns false so ONLY the "via"
-// segment renders — never both.
-func TestHeaderGatewayAvailableSegment(t *testing.T) {
- conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}}
- m := newTestModelFromDeps(Deps{Session: conv, Conv: conv, Theme: theme.New("aztec", theme.AztecPalette()), Ctx: context.Background(), Server: "127.0.0.1:8080"})
- m = applyAll(m, tea.WindowSizeMsg{Width: 160, Height: 30})
-
- // Active provider is openai (key-driven); toolhive is available-but-not-default.
m.resolvedSessionModel = client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5"}
- m.modelCatalog.statuses = gatewayStatuses()
- header := stripANSIstr(m.renderHeader())
- if !strings.Contains(header, "toolhive gateway available") {
- t.Errorf("header should show the 'gateway available' segment when an AvailableNotDefault status exists and the active provider is not the gateway, got:\n%s", header)
- }
- // The active-case "via ToolHive gateway" segment must NOT also render.
- if strings.Contains(header, "via ToolHive gateway") {
- t.Errorf("the 'via ToolHive gateway' segment must NOT render alongside the 'available' segment, got:\n%s", header)
+ if got := m.statusLineSnapshot().Model.ProviderID; got != "openai" {
+ t.Fatalf("status snapshot provider = %q, want openai", got)
}
- // Now make the gateway the ACTIVE default: AvailableNotDefault flips false, so
- // the 'available' segment disappears and the 'via' segment renders instead.
m.resolvedSessionModel = client.ResolvedModel{ProviderID: "toolhive", ModelID: "claude-sonnet-4-6"}
- m.modelCatalog.statuses = []client.ProviderStatus{{ProviderID: "toolhive", State: "ok", ModelCount: 5, AvailableNotDefault: false}}
- header = stripANSIstr(m.renderHeader())
- if strings.Contains(header, "gateway available") {
- t.Errorf("the 'available' segment must NOT render when the gateway IS the active default, got:\n%s", header)
- }
- if !strings.Contains(header, "via ToolHive gateway") {
- t.Errorf("the 'via ToolHive gateway' segment should render when the gateway is active, got:\n%s", header)
- }
-
- // No statuses (byte-identical pre-feature path): neither segment renders.
- m.resolvedSessionModel = client.ResolvedModel{ProviderID: "openai", ModelID: "gpt-5"}
- m.modelCatalog.statuses = nil
- header = stripANSIstr(m.renderHeader())
- if strings.Contains(header, "gateway available") || strings.Contains(header, "via ToolHive gateway") {
- t.Errorf("neither gateway segment should render with no statuses, got:\n%s", header)
+ if got := m.statusLineSnapshot().Model.ProviderID; got != "toolhive" {
+ t.Fatalf("status snapshot provider = %q, want toolhive", got)
}
}
-// TestHeaderProviderRouteSuffix proves the routed downstream provider appears as a
-// "/ " suffix on the header model segment (issue #480) ONLY once a route has
-// been reported this session, and is absent before any routed turn (no stale or
-// fabricated suffix).
-func TestHeaderProviderRouteSuffix(t *testing.T) {
+// TestProviderRouteReachesStatusSnapshot proves routed downstream provider state is
+// submitted to the selected source and cleared at the next-turn boundary.
+func TestProviderRouteReachesStatusSnapshot(t *testing.T) {
conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}}
m := newTestModelFromDeps(Deps{Session: conv, Conv: conv, Theme: theme.New("aztec", theme.AztecPalette()), Ctx: context.Background(), Server: "127.0.0.1:8080"})
m.resolvedSessionModel = client.ResolvedModel{ProviderID: "openrouter", ModelID: "moonshotai/kimi-k3"}
m.phase = phaseIdle // a bound session, so the model segment renders
m = applyAll(m, tea.WindowSizeMsg{Width: 160, Height: 30})
- // Before any provider.route event: the bare model segment, no "/" suffix.
- header := stripANSIstr(m.renderHeader())
- if strings.Contains(header, "kimi-k3/") {
- t.Fatalf("no route yet — header must NOT show a downstream suffix, got:\n%s", header)
+ if route := m.statusLineSnapshot().Model.Route; route != "" {
+ t.Fatalf("status snapshot route = %q, want empty", route)
}
// A provider.route event arrives (the openrouter entry routed to Google).
m = applyAll(m, client.ProviderRouteMsg{Text: "Google"})
- header = stripANSIstr(m.renderHeader())
- if !strings.Contains(header, "kimi-k3/Google") {
- t.Errorf("header should show the model + routed downstream as 'kimi-k3/Google', got:\n%s", header)
+ if route := m.statusLineSnapshot().Model.Route; route != "Google" {
+ t.Errorf("status snapshot route = %q, want Google", route)
}
if statusText := stripANSIstr(m.statusMsg); !strings.Contains(statusText, "via Google") {
t.Errorf("route arrival should show transient footer status, got %q", statusText)
@@ -1803,17 +1737,15 @@ func TestHeaderProviderRouteSuffix(t *testing.T) {
// A subsequent route updates the suffix (e.g. a fallback kicked in).
m = applyAll(m, client.ProviderRouteMsg{Text: "Amazon Bedrock"})
- header = stripANSIstr(m.renderHeader())
- if !strings.Contains(header, "kimi-k3/Amazon Bedrock") {
- t.Errorf("header should track the latest routed downstream, got:\n%s", header)
+ if route := m.statusLineSnapshot().Model.Route; route != "Amazon Bedrock" {
+ t.Errorf("status snapshot route = %q, want Amazon Bedrock", route)
}
// The next turn clears the per-turn route before any metadata arrives. This is
// the cache-hit/metadata-miss path: absence must render absence, never stale data.
m = applyAll(m, client.TurnStartMsg{Turn: 2})
- header = stripANSIstr(m.renderHeader())
- if strings.Contains(header, "kimi-k3/") {
- t.Errorf("a new turn with no route must clear the stale suffix, got:\n%s", header)
+ if route := m.statusLineSnapshot().Model.Route; route != "" {
+ t.Errorf("new turn left stale route in status snapshot: %q", route)
}
// Session reset is the other stale-state boundary.
@@ -1836,6 +1768,7 @@ func TestModelsPickerGolden(t *testing.T) {
if modelsSurface(t, m).view != modelsPanel {
t.Fatalf("view = %v, want modelsPanel", modelsSurface(t, m).view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models.golden", got)
}
@@ -1866,6 +1799,7 @@ func TestModelsPickerDisabledGolden(t *testing.T) {
m := newModelsModel(t, &fakeModels{}, &fakeStore{}, client.Capabilities{ModelSelection: false}, client.ModelSelection{})
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models_disabled.golden", got)
}
@@ -1883,6 +1817,7 @@ func TestOpenAICodexCommandRootSurfaces(t *testing.T) {
m := newModelsModel(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{})
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
rendered := stripANSI([]byte(m.View().Content))
for _, want := range []string{"openai-codex", "manual token rejected", "auth.yaml", "restart"} {
if !bytes.Contains(rendered, []byte(want)) {
@@ -1903,6 +1838,7 @@ func TestOpenAICodexHealthyStatusDoesNotImplyOrgGolden(t *testing.T) {
m := newModelsModel(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{})
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
rendered := stripANSI([]byte(m.View().Content))
if !bytes.Contains(rendered, []byte("openai-codex · GPT-5")) {
t.Fatalf("healthy Codex model missing from picker:\n%s", rendered)
@@ -1918,6 +1854,7 @@ func TestModelsPickerEmptyGolden(t *testing.T) {
m := newModelsModel(t, &fakeModels{}, &fakeStore{}, modelsCaps(), client.ModelSelection{})
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models_empty.golden", got)
}
@@ -1930,6 +1867,7 @@ func TestModelsPickerFilteredGolden(t *testing.T) {
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
m = typeFilter(t, m, "gpt")
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models_filtered.golden", got)
}
@@ -1945,6 +1883,7 @@ func TestModelsPickerScrolledGolden(t *testing.T) {
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
m = pressModelsKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnd})
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
if !bytes.Contains(got, []byte("↑ 26 items")) {
t.Fatalf("scrolled picker must count complete hidden logical items in its overflow indicator:\n%s", got)
@@ -1959,6 +1898,7 @@ func TestModelsPickerNoMatchGolden(t *testing.T) {
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
m = typeFilter(t, m, "zzzzz")
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models_nomatch.golden", got)
}
@@ -1974,6 +1914,7 @@ func TestModelsPickerToolhiveUnreachableGolden(t *testing.T) {
m := newModelsModel(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{ProviderID: "openai", ModelID: "gpt-5"})
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models_toolhive_unreachable.golden", got)
}
@@ -1988,6 +1929,7 @@ func TestModelsPickerGatewayEmptyGolden(t *testing.T) {
m := newModelsModel(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{})
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models_gateway_empty.golden", got)
}
@@ -2005,6 +1947,7 @@ func TestModelsPickerMixedDeploymentEmptyGolden(t *testing.T) {
m := newModelsModel(t, fm, &fakeStore{}, modelsCaps(), client.ModelSelection{ProviderID: "openai", ModelID: "gpt-5"})
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models_mixed_deployment_empty.golden", got)
}
@@ -2019,6 +1962,7 @@ func TestModelsPickerGlobalDefaultGolden(t *testing.T) {
m.modelCatalog.globalDefault = client.ModelSelection{ProviderID: "openrouter", ModelID: "anthropic/claude"}
mm, cmd := m.runModels()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "models_global_default.golden", got)
}
diff --git a/cmd/mecatui/ui/noprogress_test.go b/cmd/mecatui/ui/noprogress_test.go
index 95f7d96e7f..b088e653a4 100644
--- a/cmd/mecatui/ui/noprogress_test.go
+++ b/cmd/mecatui/ui/noprogress_test.go
@@ -6,6 +6,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback"
)
@@ -55,13 +56,13 @@ func TestCompactionStillScrollbackNotice(t *testing.T) {
// TestNoProgressTerminalStopStillShown confirms the durable terminal signal survives the
// scrollback removal: the terminal no-progress stop is rendered by the footer's
-// stopReasonLabel as "stopped · no progress", independent of any scrollback notice.
+// renderfmt.StopReasonLabel as "stopped · no progress", independent of any scrollback notice.
func TestNoProgressTerminalStopStillShown(t *testing.T) {
- text, slot := stopReasonLabel("no_progress")
+ text, slot := renderfmt.StopReasonLabel("no_progress")
if text != "stopped · no progress" {
t.Errorf("terminal no-progress footer label = %q, want %q", text, "stopped · no progress")
}
- if slot != slotCtxWarn {
- t.Errorf("terminal no-progress slot = %q, want %q", slot, slotCtxWarn)
+ if slot != "ctxWarn" {
+ t.Errorf("terminal no-progress slot = %q, want %q", slot, "ctxWarn")
}
}
diff --git a/cmd/mecatui/ui/palette_bounded_list_scenario_test.go b/cmd/mecatui/ui/palette_bounded_list_scenario_test.go
index 5a0dd44a75..c7956167b9 100644
--- a/cmd/mecatui/ui/palette_bounded_list_scenario_test.go
+++ b/cmd/mecatui/ui/palette_bounded_list_scenario_test.go
@@ -78,7 +78,7 @@ func TestMecatuiSlashPaletteBoundedList_Scenario1_GeometryAndIndicators(t *testi
m.conv.addUser("settled conversation")
m.refreshView()
m = typeRune(t, m, '/')
- m = applyAll(m, tea.WindowSizeMsg{Width: 40, Height: 18})
+ m = applyAll(m, tea.WindowSizeMsg{Width: 40, Height: 17})
frame := m.View().Content
if rows := lipglossHeight(frame); rows > m.height {
t.Fatalf("short frame height = %d, offered %d vp=%d visible=%t:\n%s", rows, m.height, m.vp.Height(), m.paletteVisible(), ansi.Strip(frame))
@@ -325,7 +325,7 @@ func TestMecatuiSlashPaletteBoundedList_Scenario1_PreservesInteractionOwnership(
{Name: "beta", Description: "second"},
}})
paged = typeRune(t, paged, '/')
- paged = applyAll(paged, tea.WindowSizeMsg{Width: 24, Height: 21})
+ paged = applyAll(paged, tea.WindowSizeMsg{Width: 24, Height: 19})
_ = paged.View()
paged.palette.list.SetCursor(8) // alpha follows the eight built-ins.
paged.keys = applyKeyOverrides(paged.keys, map[string][]string{"ScrollU": {"p"}, "ScrollD": {"n"}})
diff --git a/cmd/mecatui/ui/paste_large_test.go b/cmd/mecatui/ui/paste_large_test.go
index a55d8e286f..609ef1bce5 100644
--- a/cmd/mecatui/ui/paste_large_test.go
+++ b/cmd/mecatui/ui/paste_large_test.go
@@ -543,6 +543,7 @@ func TestPlaceholderGoldenView(t *testing.T) {
mm, _ := m.Update(pasteMsg(largePasteText()))
m = mm.(Model)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "paste_placeholder.golden", got)
}
diff --git a/cmd/mecatui/ui/plan_approval_test.go b/cmd/mecatui/ui/plan_approval_test.go
index 0319f03ca7..8b2a0e19ec 100644
--- a/cmd/mecatui/ui/plan_approval_test.go
+++ b/cmd/mecatui/ui/plan_approval_test.go
@@ -11,6 +11,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1"
)
@@ -188,12 +189,12 @@ func TestPlanAskDenyThenIterateTerminalReturnsToIdle(t *testing.T) {
}
func TestStopPlanApprovedFooterLabel(t *testing.T) {
- text, slot := stopReasonLabel("plan_approved")
+ text, slot := renderfmt.StopReasonLabel("plan_approved")
if text != "plan approved · executing" {
- t.Errorf("stopReasonLabel(plan_approved) text = %q, want 'plan approved · executing'", text)
+ t.Errorf("renderfmt.StopReasonLabel(plan_approved) text = %q, want 'plan approved · executing'", text)
}
if slot != "muted" {
- t.Errorf("stopReasonLabel(plan_approved) slot = %q, want 'muted'", slot)
+ t.Errorf("renderfmt.StopReasonLabel(plan_approved) slot = %q, want 'muted'", slot)
}
}
diff --git a/cmd/mecatui/ui/render.go b/cmd/mecatui/ui/render.go
index 4d93a45848..ebfc1d9f3c 100644
--- a/cmd/mecatui/ui/render.go
+++ b/cmd/mecatui/ui/render.go
@@ -18,6 +18,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback"
)
@@ -1436,8 +1437,8 @@ func teamLaneLine(ln *teamLane, nameW int, teamDone bool) string {
teamMutCue(ln),
name,
teamLaneState(ln, teamDone),
- humanizeTokens(ln.usage.InputTokens),
- humanizeTokens(ln.usage.OutputTokens))
+ renderfmt.HumanizeTokens(ln.usage.InputTokens),
+ renderfmt.HumanizeTokens(ln.usage.OutputTokens))
}
// teamMutCue is the PERSISTENT per-member mutating cue (stable roster metadata): a
@@ -1706,25 +1707,6 @@ func subagentStopLabel(stop string) string {
}
}
-// humanizeDuration renders a millisecond wall-clock duration compactly: sub-second
-// as "Nms", under a minute as "N.Ns", else "Nm Ns". A non-positive duration (no
-// clock) renders as "0ms".
-func humanizeDuration(ms int64) string {
- if ms <= 0 {
- return "0ms"
- }
- if ms < 1000 {
- return strconv.FormatInt(ms, 10) + "ms"
- }
- secs := float64(ms) / 1000.0
- if secs < 60 {
- return trimDecimal(secs) + "s"
- }
- m := int64(secs) / 60
- s := int64(secs) % 60
- return strconv.FormatInt(m, 10) + "m " + strconv.FormatInt(s, 10) + "s"
-}
-
// resultBody renders a tool result body with the legacy logical-line cap for
// callers without card geometry.
func (r *renderer) resultBody(body string, expand bool) string {
@@ -2151,7 +2133,7 @@ func summarizeStringValue(s string) string {
preview += "…"
}
preview = terminaltext.Sanitize(preview)
- return fmt.Sprintf("%s / %s · %q", humanizeBytes(int64(len(s))), plural(lines, "line"), preview)
+ return fmt.Sprintf("%s / %s · %q", renderfmt.HumanizeBytes(int64(len(s))), plural(lines, "line"), preview)
}
// summarizeArrayValue renders a JSON array value and reports whether it collapsed:
@@ -2210,30 +2192,6 @@ func firstLine(s string) string {
return strings.TrimRight(s, "\r")
}
-// humanizeBytes renders a byte count compactly: bytes verbatim under 1 KB, then
-// "N.N KB"/"N.N MB"/"N.N GB"/"N.N TB" with one decimal (trailing ".0" trimmed).
-// Sibling of the humanizeTokens/humanizeDuration formatters; used for the
-// collapsed long-string arg row size signal AND the session-storage byte
-// counts (which can run into the GB range). The math is SI/decimal (1 KB =
-// 1000 B, 1 MB = 1e6 B, …) so a human-facing size reconciles with how
-// file/content sizes are reported everywhere — the labels stay "KB"/"MB"/…
-// (honest, not mislabelled KiB/MiB).
-func humanizeBytes(n int64) string {
- if n < 0 {
- n = 0
- }
- const unit = 1000
- if n < unit {
- return strconv.FormatInt(n, 10) + " B"
- }
- div, exp := int64(unit), 0
- for value := n / unit; value >= unit && exp < 3; value /= unit {
- div *= unit
- exp++
- }
- return trimDecimal(float64(n)/float64(div)) + " " + [...]string{"KB", "MB", "GB", "TB"}[exp]
-}
-
// parseMCPName splits an MCP tool name "mcp____" into its server
// and tool parts (the tool half may itself contain "__", so the split is on the
// FIRST "__" after the prefix). It returns ok=false for any non-MCP name, so a
diff --git a/cmd/mecatui/ui/resumable_session_status_metrics_test.go b/cmd/mecatui/ui/resumable_session_status_metrics_test.go
index e377a2dc67..d806ea7d67 100644
--- a/cmd/mecatui/ui/resumable_session_status_metrics_test.go
+++ b/cmd/mecatui/ui/resumable_session_status_metrics_test.go
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
+ tea "charm.land/bubbletea/v2"
+
"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
)
@@ -18,20 +20,81 @@ func TestResumableSessionStatusMetrics_Scenario3_StartupResumeRestoresStatus(t *
},
}
m := New(Deps{Resume: resume, Theme: theme.New("aztec", theme.AztecPalette()), NoAltScreen: true})
-
+ m = applyAll(m, tea.WindowSizeMsg{Width: 160, Height: 30})
if m.usage != resume.Snapshot.Usage || m.contextTokens != 40_000 || !m.contextEstimated {
t.Fatalf("startup resume did not adopt snapshot status: usage=%+v context=%d estimated=%t", m.usage, m.contextTokens, m.contextEstimated)
}
- footer := stripANSIstr(m.fitFooter("connected", 160))
- for _, want := range []string{"~40K/200K", "20%", "↑120K", "↓4K", "cache 75%"} {
+ input := m.statusLineSnapshot()
+ if input.Context.Window.Raw != 200_000 || input.Context.Used.Raw != 40_000 || !input.Context.Known || !input.Context.Estimated || input.Context.Percent != 20 ||
+ input.Usage.Input.Raw != 120_000 || input.Usage.Output.Raw != 4_000 || input.Usage.CacheRead.Raw != 90_000 || input.Usage.CacheReadPercent != 75 {
+ t.Fatalf("startup status snapshot = %#v, want restored occupancy and cumulative usage", input)
+ }
+ m = stockStatusFrame(t, m)
+ if !m.generatedStatusLine.Footer.Present {
+ t.Fatal("stock source did not publish a footer")
+ }
+ footer := stripANSIstr(m.renderFooter())
+ for _, want := range []string{"~40K/200K", "~20%", "↑120K", "↓4K", "cache 75%"} {
if !strings.Contains(footer, want) {
- t.Fatalf("footer = %q, want %q before a prompt", footer, want)
+ t.Fatalf("stock footer = %q, want %q before a prompt", footer, want)
}
}
m = applyAll(m, client.TurnEndMsg{Usage: client.Usage{InputTokens: 50_000}, Estimated: true})
- if footer := stripANSIstr(m.fitFooter("connected", 160)); !strings.Contains(footer, "~50K/200K") {
- t.Fatalf("live estimated footer = %q, want estimated marker", footer)
+ if context := m.statusLineSnapshot().Context; context.Used.Raw != 50_000 || !context.Estimated || context.Percent != 25 {
+ t.Fatalf("live context = %#v, want estimated 50K/200K", context)
+ }
+ m = stockStatusFrame(t, m)
+ if !m.generatedStatusLine.Footer.Present {
+ t.Fatal("stock source did not publish a footer")
+ }
+ footer = stripANSIstr(m.renderFooter())
+ if !strings.Contains(footer, "~50K/200K") || !strings.Contains(footer, "~25%") {
+ t.Fatalf("live estimated stock footer = %q, want estimated context", footer)
+ }
+}
+
+func TestResumableSessionStatusMetrics_Scenario3_UnknownAndProvisionalStatus(t *testing.T) {
+ resume := &client.ResumeSelection{
+ Row: client.SessionListItem{ID: "legacy-main"},
+ Snapshot: client.SessionSnapshot{
+ ResolvedModel: client.ResolvedModel{ContextWindow: 0},
+ Usage: client.Usage{InputTokens: 120_000},
+ },
+ }
+ m := New(Deps{Resume: resume, Theme: theme.New("aztec", theme.AztecPalette()), NoAltScreen: true})
+ m = applyAll(m, tea.WindowSizeMsg{Width: 160, Height: 30})
+ if !m.contextUnknown {
+ t.Fatal("legacy snapshot without occupancy must remain unknown")
+ }
+ input := m.statusLineSnapshot()
+ if input.Context.Known || input.Context.Estimated || input.Context.Used.Human != "?" || input.Context.Window.Raw != 0 || input.Usage.Input.Raw != 120_000 {
+ t.Fatalf("legacy status snapshot = %#v, want unknown occupancy and retained cumulative usage", input)
+ }
+ m = stockStatusFrame(t, m)
+ if !m.generatedStatusLine.Footer.Present {
+ t.Fatal("stock source did not publish a footer")
+ }
+ footer := stripANSIstr(m.renderFooter())
+ if !strings.Contains(footer, "ctx ?") || strings.Contains(footer, "120K/") {
+ t.Fatalf("provisional stock footer = %q, want unknown context, never cumulative input as numerator", footer)
+ }
+
+ m = applyAll(m, client.ResolvedModelMsg{SessionID: "legacy-main", Resolved: client.ResolvedModel{ContextWindow: 200_000}})
+ if m.resolvedSessionModel.ContextWindow != 200_000 || m.usage.InputTokens != 120_000 || !m.contextUnknown {
+ t.Fatalf("refresh changed metrics: window=%d usage=%+v contextUnknown=%t", m.resolvedSessionModel.ContextWindow, m.usage, m.contextUnknown)
+ }
+ input = m.statusLineSnapshot()
+ if input.Context.Known || input.Context.Estimated || input.Context.Used.Human != "?" || input.Context.Window.Raw != 200_000 || input.Usage.Input.Raw != 120_000 {
+ t.Fatalf("healed status snapshot = %#v, want unknown numerator with healed denominator", input)
+ }
+ m = stockStatusFrame(t, m)
+ if !m.generatedStatusLine.Footer.Present {
+ t.Fatal("stock source did not publish a footer")
+ }
+ footer = stripANSIstr(m.renderFooter())
+ if !strings.Contains(footer, "ctx ?/200K") || strings.Contains(footer, "120K/200K") || strings.Contains(footer, "ctx 0%") {
+ t.Fatalf("healed stock footer = %q, want unknown numerator and healed denominator", footer)
}
}
@@ -92,7 +155,7 @@ func TestResumableSessionStatusMetrics_Scenario3_StartupRefreshSubmitsPersistedO
m = updated.(Model)
input, ok := source.lastInput()
if m.contextTokens != 40_000 || !m.contextEstimated || !ok || source.inputCount() != 2 || input.Context.Used.Raw != 40_000 || !input.Context.Known || !input.Context.Estimated || input.Context.Window.Raw != 200_000 {
- t.Fatalf("refreshed startup status = %#v (count=%d), want persisted occupancy", input, source.inputCount())
+ t.Fatalf("refreshed startup status = %#v (count=%d), want persisted occupancy after the healed denominator was submitted once", input, source.inputCount())
}
}
@@ -187,31 +250,3 @@ func TestResumableSessionStatusMetrics_Scenario3_InspectionIsNonDestructive(t *t
})
}
}
-
-func TestResumableSessionStatusMetrics_Scenario3_UnknownAndProvisionalStatus(t *testing.T) {
- resume := &client.ResumeSelection{
- Row: client.SessionListItem{ID: "legacy-main"},
- Snapshot: client.SessionSnapshot{
- ResolvedModel: client.ResolvedModel{ContextWindow: 0},
- Usage: client.Usage{InputTokens: 120_000},
- },
- }
- m := New(Deps{Resume: resume, Theme: theme.New("aztec", theme.AztecPalette()), NoAltScreen: true})
- if !m.contextUnknown {
- t.Fatal("legacy snapshot without occupancy must remain unknown")
- }
- if footer := stripANSIstr(m.fitFooter("connected", 160)); !strings.Contains(footer, "ctx ?") || strings.Contains(footer, "120K/") {
- t.Fatalf("legacy footer = %q, want an unknown context numerator, never cumulative input", footer)
- }
-
- m = applyAll(m, client.ResolvedModelMsg{SessionID: "legacy-main", Resolved: client.ResolvedModel{ContextWindow: 200_000}})
- if m.resolvedSessionModel.ContextWindow != 200_000 {
- t.Fatalf("resolved-model refresh did not heal provisional window: %d", m.resolvedSessionModel.ContextWindow)
- }
- if m.usage.InputTokens != 120_000 || !m.contextUnknown {
- t.Fatalf("resolved-model refresh applied stale metrics: usage=%+v contextUnknown=%t", m.usage, m.contextUnknown)
- }
- if footer := stripANSIstr(m.fitFooter("connected", 160)); !strings.Contains(footer, "?/200K") || strings.Contains(footer, "120K/200K") {
- t.Fatalf("healed legacy footer = %q, want unknown numerator with healed denominator", footer)
- }
-}
diff --git a/cmd/mecatui/ui/selection_test.go b/cmd/mecatui/ui/selection_test.go
index 56fdf64475..9c034fdcf5 100644
--- a/cmd/mecatui/ui/selection_test.go
+++ b/cmd/mecatui/ui/selection_test.go
@@ -187,10 +187,10 @@ func TestConvTopRowMatchesRenderedBodyTop(t *testing.T) {
// cross-check is not vacuous.
func TestConvTopRowTracksWrappedHeader(t *testing.T) {
m, _ := selModel(t)
- m.deps.Model = "openrouter/anthropic/claude-3.5-sonnet-20241022-extended"
- m.deps.Mode = "acceptEdits"
- m.deps.Server = "some-long-host.example.internal:50051"
- // Resize NARROW so the joined identity line exceeds the width and wraps.
+ // The renderer-owned debug disclosure wraps at a narrow width; this exercises
+ // measured layout without relying on the removed legacy identity formatter.
+ m.deps.DebugTarget = "debug-target"
+ // Resize NARROW so the mandatory disclosure wraps.
mm, _ := m.onResize(tea.WindowSizeMsg{Width: 40, Height: 30})
m = mm.(Model)
@@ -262,11 +262,9 @@ func TestOnResizeUsesMeasuredHeaderHeight(t *testing.T) {
// pre-top-pad layout — the inputRailPadTop row.)
const taH, footerH, spacerH, totalH = 5, 2, 1, 30
- // Wrapping case: long deps at a narrow width.
+ // Wrapping case: the renderer-owned debug disclosure at a narrow width.
m, _ := selModel(t)
- m.deps.Model = "openrouter/anthropic/claude-3.5-sonnet-20241022-extended"
- m.deps.Mode = "acceptEdits"
- m.deps.Server = "some-long-host.example.internal:50051"
+ m.deps.DebugTarget = "debug-target"
mm, _ := m.onResize(tea.WindowSizeMsg{Width: 40, Height: totalH})
m = mm.(Model)
wrappedHeader := lipgloss.Height(m.renderHeader())
diff --git a/cmd/mecatui/ui/sessions_golden_test.go b/cmd/mecatui/ui/sessions_golden_test.go
index e5eb87c315..6b24db7331 100644
--- a/cmd/mecatui/ui/sessions_golden_test.go
+++ b/cmd/mecatui/ui/sessions_golden_test.go
@@ -34,11 +34,33 @@ func openAndLoad(t *testing.T, m Model, sessions []client.SessionListItem) Model
func TestSessionsPickerGolden(t *testing.T) {
rows := sampleSessions()
m := openAndLoad(t, newSessionsGoldenModel(t, rows), rows)
+ m = goldenStatusFrame(t, m)
compareGolden(t, "sessions_picker.golden", stripANSI([]byte(m.View().Content)))
}
+func TestSessionsPickerFilteredGolden(t *testing.T) {
+ rows := sampleSessions()
+ m := openAndLoad(t, newSessionsGoldenModel(t, rows), rows)
+ st := ensureActiveSessions(&m)
+ st.filter.SetValue("Fix")
+ st.syncFilter()
+ m = goldenStatusFrame(t, m)
+ compareGolden(t, "sessions_picker_filtered.golden", stripANSI([]byte(m.View().Content)))
+}
+
+func TestSessionsPickerNoMatchGolden(t *testing.T) {
+ rows := sampleSessions()
+ m := openAndLoad(t, newSessionsGoldenModel(t, rows), rows)
+ st := ensureActiveSessions(&m)
+ st.filter.SetValue("no such session")
+ st.syncFilter()
+ m = goldenStatusFrame(t, m)
+ compareGolden(t, "sessions_picker_nomatch.golden", stripANSI([]byte(m.View().Content)))
+}
+
func TestSessionsPickerEmptyGolden(t *testing.T) {
m := openAndLoad(t, newSessionsGoldenModel(t, nil), nil)
+ m = goldenStatusFrame(t, m)
compareGolden(t, "sessions_picker_empty.golden", stripANSI([]byte(m.View().Content)))
}
@@ -46,6 +68,7 @@ func TestSessionsPickerErrorGolden(t *testing.T) {
m := newSessionsGoldenModel(t, nil)
mm, _ := m.runSessions()
m = applyAll(mm.(Model), client.SessionsListedMsg{Err: errGolden})
+ m = goldenStatusFrame(t, m)
compareGolden(t, "sessions_picker_error.golden", stripANSI([]byte(m.View().Content)))
}
@@ -57,6 +80,7 @@ func TestSessionsMaintenanceGoldens(t *testing.T) {
m.width, m.height = 100, 40
m = applyAll(m, cleanupPlanMsg{plan: fake.cleanupPlan})
+ m = goldenStatusFrame(t, m)
compareGolden(t, "sessions_cleanup_review.golden", stripANSI([]byte(m.View().Content)))
}
@@ -64,6 +88,7 @@ func TestSessionsTranscriptLoadingGolden(t *testing.T) {
m := newSessionsGoldenModel(t, nil)
setActiveSessions(&m, sessionsState{view: sessionsTranscript, loading: true, selected: client.SessionListItem{ID: "sched-1", Title: "Nightly checks"}, inspect: true})
m.phase = phaseReplay
+ m = goldenStatusFrame(t, m)
compareGolden(t, "sessions_transcript_loading.golden", stripANSI([]byte(m.View().Content)))
}
@@ -71,9 +96,18 @@ func TestSessionsTranscriptErrorGolden(t *testing.T) {
m := newSessionsGoldenModel(t, nil)
setActiveSessions(&m, sessionsState{view: sessionsTranscript, selected: client.SessionListItem{ID: "sched-1", Title: "Nightly checks"}, inspect: true, loadErr: errGolden})
m.phase = phaseReplay
+ m = goldenStatusFrame(t, m)
compareGolden(t, "sessions_transcript_error.golden", stripANSI([]byte(m.View().Content)))
}
+func TestSessionsTranscriptLoadedGolden(t *testing.T) {
+ m := newSessionsGoldenModel(t, nil)
+ setActiveSessions(&m, sessionsState{view: sessionsTranscript, selected: client.SessionListItem{ID: "sess-test-0001"}, inspect: true})
+ m.phase = phaseReplay
+ m = goldenStatusFrame(t, m)
+ compareGolden(t, "sessions_transcript_loaded.golden", stripANSI([]byte(m.View().Content)))
+}
+
func TestSessionsTranscriptRenderedGolden(t *testing.T) {
m := newSessionsGoldenModel(t, nil)
setActiveSessions(&m, sessionsState{
@@ -82,5 +116,6 @@ func TestSessionsTranscriptRenderedGolden(t *testing.T) {
})
m.phase = phaseReplay
m.refreshView()
+ m = goldenStatusFrame(t, m)
compareGolden(t, "sessions_transcript_rendered.golden", stripANSI([]byte(m.View().Content)))
}
diff --git a/cmd/mecatui/ui/sessions_surface.go b/cmd/mecatui/ui/sessions_surface.go
index dee030bf6a..7efaacf685 100644
--- a/cmd/mecatui/ui/sessions_surface.go
+++ b/cmd/mecatui/ui/sessions_surface.go
@@ -19,6 +19,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
)
type sessionsTab int
@@ -248,8 +249,8 @@ func (s *sessionsState) applyReplayEvent(msg tea.Msg) {
c.appendReasoning(msg.Text)
case client.TurnEndMsg:
c.endReasoningStream()
- if !trivialTurn(msg) {
- c.addTurnStat(turnStatLine(msg))
+ if !renderfmt.TrivialTurn(msg) {
+ c.addTurnStat(renderfmt.TurnStatLine(msg))
}
case client.ToolCallMsg:
c.addTool(msg.ID, msg.Name, msg.Args)
@@ -1371,10 +1372,10 @@ func renderStorageHealth(th theme.Theme, st sessionsState, caps client.Capabilit
h := *st.health
bytesText, reclaimable := unavailableText, unavailableText
if h.CurrentBytesAvailable {
- bytesText = humanizeBytes(h.CurrentBytes)
+ bytesText = renderfmt.HumanizeBytes(h.CurrentBytes)
}
if h.ReclaimableBytesAvailable {
- reclaimable = humanizeBytes(h.ReclaimableBytes)
+ reclaimable = renderfmt.HumanizeBytes(h.ReclaimableBytes)
}
last, next := unavailableText, unavailableText
if h.LastSweepAvailable {
@@ -1429,7 +1430,7 @@ func renderCleanupPlan(th theme.Theme, st sessionsState, hk helpKeys) string {
th.Style("errorText").Render("Preview session cleanup — DESTRUCTIVE"), "",
fmt.Sprintf("Will delete: %d Chats: %d Child runs: %d Scheduled runs: %d", eligible.Total, cleanupKindCount(eligible, "main"), cleanupKindCount(eligible, "subagent", "parallel_branch", "team_member"), cleanupKindCount(eligible, "scheduled")),
fmt.Sprintf("Protected: %d Unknown: %d Live: %d Awaiting approval: %d", protected.Total, cleanupKindCount(protected, "unknown"), protected.ByReason["live"], protected.ByState["awaiting"]),
- "Estimated space freed: " + humanizeBytes(plan.EstimatedBytes),
+ "Estimated space freed: " + renderfmt.HumanizeBytes(plan.EstimatedBytes),
"Unknown, active, live, and awaiting sessions are protected and will not be deleted.", "",
"Type CLEAN UP to permanently delete the sessions shown above. This cannot be undone:", st.cleanupConfirm.View(), "",
th.Style("muted").Render(hk.choose + ": delete these sessions " + hk.closeOnly + ": back"),
diff --git a/cmd/mecatui/ui/skills_test.go b/cmd/mecatui/ui/skills_test.go
index ba4cf7794a..498a311ff4 100644
--- a/cmd/mecatui/ui/skills_test.go
+++ b/cmd/mecatui/ui/skills_test.go
@@ -807,6 +807,7 @@ func TestSkillsPanelScrollGolden(t *testing.T) {
}
_ = m.View()
_, _, _ = st.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown})
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "skills_scroll.golden", got)
}
@@ -820,6 +821,7 @@ func TestSkillsPanelGolden(t *testing.T) {
if st := skillsStateOf(t, m); st.view != skillsPanel {
t.Fatalf("view = %v, want skillsPanel", st.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "skills.golden", got)
}
@@ -831,6 +833,7 @@ func TestSkillsPanelFilteredGolden(t *testing.T) {
mm, cmd := m.runSkills()
m = feedCmd(t, mm.(Model), cmd)
m = typeSkillsFilter(t, m, "deep")
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "skills_filtered.golden", got)
}
@@ -842,6 +845,7 @@ func TestSkillsPanelNomatchGolden(t *testing.T) {
mm, cmd := m.runSkills()
m = feedCmd(t, mm.(Model), cmd)
m = typeSkillsFilter(t, m, "zzzzz")
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "skills_nomatch.golden", got)
}
diff --git a/cmd/mecatui/ui/soul_test.go b/cmd/mecatui/ui/soul_test.go
index b9942a142e..70e76deca7 100644
--- a/cmd/mecatui/ui/soul_test.go
+++ b/cmd/mecatui/ui/soul_test.go
@@ -300,6 +300,7 @@ func TestSoulPanelGolden(t *testing.T) {
if s := soulActive(m); s == nil || s.view != soulPanel {
t.Fatalf("modal surface = %v, want a *soulState at soulPanel", m.modal)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "soul.golden", got)
}
@@ -310,6 +311,7 @@ func TestSoulPanelEmptyDisabledGolden(t *testing.T) {
m := newSoulModel(t, &fakeSoul{}, client.Capabilities{Soul: false})
mm, cmd := m.runSoul()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "soul_empty_disabled.golden", got)
}
@@ -320,6 +322,7 @@ func TestSoulPanelEmptyEnabledGolden(t *testing.T) {
m := newSoulModel(t, &fakeSoul{}, client.Capabilities{Soul: true})
mm, cmd := m.runSoul()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "soul_empty_enabled.golden", got)
}
@@ -337,6 +340,7 @@ func TestSoulPanelProjectGolden(t *testing.T) {
m := newSoulModel(t, project, client.Capabilities{Soul: true})
mm, cmd := m.runSoul()
m = feedCmd(t, mm.(Model), cmd)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "soul_project.golden", got)
}
diff --git a/cmd/mecatui/ui/status_constants.go b/cmd/mecatui/ui/status_constants.go
new file mode 100644
index 0000000000..332d192d7e
--- /dev/null
+++ b/cmd/mecatui/ui/status_constants.go
@@ -0,0 +1,16 @@
+package ui
+
+const (
+ // stopError is the "error" terminal stop reason (proto Result.stop /
+ // session.StopError).
+ stopError = "error"
+
+ // The closed team-member stop-reason vocabulary mirrors the proto
+ // TeamMemberStopReason / client.reasonString output.
+ teamStopReasonError = "error"
+ teamStopReasonCancelled = "cancelled"
+ teamStopReasonBudget = "budget"
+
+ statusDone = "done"
+ statusFailed = "failed"
+)
diff --git a/cmd/mecatui/ui/statusline_source.go b/cmd/mecatui/ui/statusline_source.go
index 1d0a7bd051..25718ba417 100644
--- a/cmd/mecatui/ui/statusline_source.go
+++ b/cmd/mecatui/ui/statusline_source.go
@@ -8,6 +8,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/client"
customization "github.com/stacklok/mecatl/cmd/mecatui/customization"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
)
type statusLineChangedMsg struct {
@@ -58,11 +59,11 @@ func (m Model) statusLineWaitCmd() tea.Cmd {
}
func usageAtom(raw int64) customization.UsageAtom {
- return customization.UsageAtom{Raw: raw, Human: humanizeTokens(raw)}
+ return customization.UsageAtom{Raw: raw, Human: renderfmt.HumanizeTokens(raw)}
}
func contextAtom(raw int64) customization.ContextAtom {
- return customization.ContextAtom{Raw: raw, Human: humanizeTokens(raw)}
+ return customization.ContextAtom{Raw: raw, Human: renderfmt.HumanizeTokens(raw)}
}
func contextOccupancyAtom(raw int64, known, estimated bool) customization.ContextAtom {
@@ -137,7 +138,7 @@ type statusLineGeometry struct {
// It uses renderer-owned header safety/navigation and footer activity lanes, but
// does not render or submit anything.
func (m Model) statusLineGeometry() statusLineGeometry {
- badge, badgeWidth, _ := m.postureBadgeRender()
+ badge, badgeWidth := m.postureBadgeRender()
tail := m.scrollIndicator()
if tail == "" {
tail = m.changedFilesIndicator()
diff --git a/cmd/mecatui/ui/statusline_test.go b/cmd/mecatui/ui/statusline_test.go
index b381a8870c..d45fc2d7e3 100644
--- a/cmd/mecatui/ui/statusline_test.go
+++ b/cmd/mecatui/ui/statusline_test.go
@@ -94,6 +94,7 @@ func TestStatusLine_Scenario2_HeaderSystemIndicatorsSurviveOverride(t *testing.T
m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette()))
m.width = 100
m.generatedStatusLine.Header = customization.Render(``, nil).Header
+ m.deps.StatusSource = &statusSourceFake{changed: make(chan struct{})}
m.caps.Posture = postureAuto
m.conv.recordFileChange("changed.go")
header := stripANSIstr(m.renderHeader())
@@ -133,7 +134,7 @@ func TestStatusLine_Scenario5_DefaultCompatibility(t *testing.T) {
Input: customization.UsageAtom{Human: "4K"},
Output: customization.UsageAtom{Human: "1K"},
},
- Context: customization.Context{Used: customization.ContextAtom{Raw: 2_000, Human: "2K"}, Window: customization.ContextAtom{Raw: 10_000, Human: "10K"}, Percent: 20},
+ Context: customization.Context{Used: customization.ContextAtom{Raw: 2_000, Human: "2K"}, Window: customization.ContextAtom{Raw: 10_000, Human: "10K"}, Percent: 20, Known: true},
Terminal: customization.Terminal{HeaderAvailCols: 80, FooterAvailCols: 80},
})
select {
diff --git a/cmd/mecatui/ui/steer_test.go b/cmd/mecatui/ui/steer_test.go
index 92af6a819b..23c38052b8 100644
--- a/cmd/mecatui/ui/steer_test.go
+++ b/cmd/mecatui/ui/steer_test.go
@@ -738,6 +738,7 @@ func TestSteer_CardGolden(t *testing.T) {
mm, _ := m.Update(client.SteerOutcomeMsg{Outcome: client.SteerAccepted, Text: "also check b.go"})
m = mm.(Model)
m.refreshView()
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "steer_card.golden", got)
}
diff --git a/cmd/mecatui/ui/team.go b/cmd/mecatui/ui/team.go
index cf062ebe32..e3092c2349 100644
--- a/cmd/mecatui/ui/team.go
+++ b/cmd/mecatui/ui/team.go
@@ -10,6 +10,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
)
// teamView is the active agent-team overlay (none = closed). The overlay is a
@@ -42,6 +43,18 @@ type teamState struct {
detail *bounded.Viewport
}
+// teamWorkingCounts classifies a team's member lanes into (working, total). total
+// is the lane count; working is the number of lanes NOT idle (genuinely in a turn).
+func teamWorkingCounts(lanes []teamLane) (working, total int) {
+ total = len(lanes)
+ for i := range lanes {
+ if !lanes[i].idle {
+ working++
+ }
+ }
+ return working, total
+}
+
func newTeamState() teamState {
return teamState{view: teamRoster, roster: new(bounded.List), detail: new(bounded.Viewport)}
}
@@ -349,9 +362,9 @@ func teamRosterWork(ln *teamLane, teamDone bool) string {
}
func teamRosterRuntime(ln *teamLane) string {
- parts := []string{"↑" + humanizeTokens(ln.usage.InputTokens) + " ↓" + humanizeTokens(ln.usage.OutputTokens)}
+ parts := []string{"↑" + renderfmt.HumanizeTokens(ln.usage.InputTokens) + " ↓" + renderfmt.HumanizeTokens(ln.usage.OutputTokens)}
if ln.ctxWindow > 0 {
- parts = append(parts, renderContextMeterPlain(ln.ctxUsed, ln.ctxWindow))
+ parts = append(parts, renderfmt.RenderContextMeterPlain(ln.ctxUsed, ln.ctxWindow))
}
if routed := subagentModelLabel(ln.routedCategory, ln.routedModel, ln.routingReason, ln.model); routed != "" {
parts = append(parts, routed)
@@ -364,19 +377,6 @@ func teamRosterLine(_ theme.Theme, ln *teamLane, nameW int, teamDone bool) strin
return teamRosterTitle(ln, nameW, teamDone) + " · " + teamRosterWork(ln, teamDone) + " · " + teamRosterRuntime(ln)
}
-// renderContextMeterPlain is the ANSI-free representation required before generic
-// roster wrapping; renderContextMeter's styled bar must not be sanitized as raw text.
-func renderContextMeterPlain(used, window int64) string {
- if used < 0 {
- used = 0
- }
- if window <= 0 {
- return "ctx " + humanizeTokens(used)
- }
- frac := ctxFraction(used, window)
- return "ctx " + ctxBar(frac) + " " + ctxLabel(frac) + " · " + humanizeTokens(used) + "/" + humanizeTokens(window)
-}
-
// maxTeamRoleLen caps how many runes of a member's role show on a roster row so
// a verbose role never blows out the row width.
const maxTeamRoleLen = 20
@@ -394,8 +394,8 @@ func teamRosterSubhead(b *teamOverlaySnapshot) string {
}
line := fmt.Sprintf("%s · ↑%s ↓%s · stop:%s",
plural(b.teamRounds, "round"),
- humanizeTokens(b.teamUsage.InputTokens),
- humanizeTokens(b.teamUsage.OutputTokens),
+ renderfmt.HumanizeTokens(b.teamUsage.InputTokens),
+ renderfmt.HumanizeTokens(b.teamUsage.OutputTokens),
subagentStopLabel(b.teamStop))
if n := teamStoppedCount(b); n > 0 {
line += fmt.Sprintf(" · %d stopped", n)
@@ -443,7 +443,7 @@ func prepareTeamFocusAt(th theme.Theme, b *teamOverlaySnapshot, member string, d
// known (same gating as the roster row: no window → no meter).
subhead := teamLaneLine(ln, 0, b.teamDone)
if ln.ctxWindow > 0 {
- subhead += " · " + renderContextMeter(th, ln.ctxUsed, ln.ctxWindow)
+ subhead += " · " + renderfmt.RenderContextMeter(th, ln.ctxUsed, ln.ctxWindow)
}
out.WriteString(muted.Render(wrapFocusMetadataAtWidth(subhead, bodyWidth)))
if detail := routingDecisionDetail(ln.routingDecision, ln.model, ln.routingReason); detail != "" {
diff --git a/cmd/mecatui/ui/team_overlay_test.go b/cmd/mecatui/ui/team_overlay_test.go
index 0d6d9f263f..1e2d4b7442 100644
--- a/cmd/mecatui/ui/team_overlay_test.go
+++ b/cmd/mecatui/ui/team_overlay_test.go
@@ -639,6 +639,7 @@ func TestAgentsRosterStoppedGolden(t *testing.T) {
})
mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF6})
m = mm.(Model)
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "team_roster_stopped.golden", got)
@@ -972,7 +973,7 @@ func TestTeamRosterRowUsesIdentityWorkAndRuntimeLines(t *testing.T) {
}
// TestAgentsRosterContextMeter asserts each roster lane shows the per-member
-// context band (the footer's renderContextMeter vocabulary) once a turn.end has
+// context band (the shared renderfmt.RenderContextMeter vocabulary) once a turn.end has
// carried a known window: a low-pressure member reads "ctx … NN%" with no ⚠, a
// danger-band member appends the ⚠ marker (which survives ANSI stripping), and a
// member whose window is still unknown shows its ↑/↓ usage but NO ctx/% meter.
@@ -1013,7 +1014,7 @@ func TestAgentsRosterContextMeter(t *testing.T) {
if !strings.Contains(low, "ctx ") || !strings.Contains(low, "20%") {
t.Errorf("low-pressure lane should show 'ctx … 20%%', got %q", low)
}
- if strings.Contains(low, ctxDangerMark) {
+ if strings.Contains(low, " ⚠") {
t.Errorf("low-pressure lane must NOT show the ⚠ marker, got %q", low)
}
if !strings.Contains(low, "40K/200K") {
@@ -1021,10 +1022,10 @@ func TestAgentsRosterContextMeter(t *testing.T) {
}
danger := rosterLine("danger")
- if !strings.Contains(danger, "95%") || !strings.Contains(danger, ctxDangerMark) {
+ if !strings.Contains(danger, "95%") || !strings.Contains(danger, " ⚠") {
t.Errorf("danger lane should show '95%% ⚠' (⚠ surviving ANSI strip), got %q", danger)
}
- if !strings.Contains(danger, ctxGlyphDanger) {
+ if !strings.Contains(danger, "█") {
t.Errorf("danger lane should use the danger fill glyph, got %q", danger)
}
@@ -1400,6 +1401,7 @@ func TestAgentsRosterGolden(t *testing.T) {
if m.team.view != teamRoster {
t.Fatalf("view = %v, want teamRoster", m.team.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "team_roster.golden", got)
@@ -1430,6 +1432,7 @@ func TestAgentsRosterMidRunIdleGolden(t *testing.T) {
if m.team.view != teamRoster {
t.Fatalf("view = %v, want teamRoster", m.team.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "team_roster_midrun_idle.golden", got)
@@ -1448,6 +1451,7 @@ func TestAgentsTasksView(t *testing.T) {
if m.team.view != teamTasks {
t.Fatalf("view = %v, want teamTasks", m.team.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "team_tasks.golden", got)
}
@@ -1464,6 +1468,7 @@ func TestAgentsFindingsView(t *testing.T) {
if m.team.view != teamFindings {
t.Fatalf("view = %v, want teamFindings", m.team.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "team_findings.golden", got)
}
@@ -1484,6 +1489,7 @@ func TestAgentsRosterWindowedGolden(t *testing.T) {
mm, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
m = mm.(Model)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
assertFitsViewport(t, got, m.width)
compareGolden(t, "team_roster_windowed.golden", got)
@@ -1504,6 +1510,7 @@ func TestAgentsFocusGolden(t *testing.T) {
if m.team.view != teamFocus || m.team.member != "scout" {
t.Fatalf("focus = %v/%q, want focus/scout", m.team.view, m.team.member)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "team_focus.golden", got)
}
@@ -1582,6 +1589,7 @@ func TestAgentsFocusWindowedGolden(t *testing.T) {
if m.team.view != teamFocus {
t.Fatalf("view = %v, want teamFocus", m.team.view)
}
+ m = goldenStatusFrame(t, m)
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "team_focus_windowed.golden", got)
}
diff --git a/cmd/mecatui/ui/team_test.go b/cmd/mecatui/ui/team_test.go
index c81046eeb1..a3ea02bf0f 100644
--- a/cmd/mecatui/ui/team_test.go
+++ b/cmd/mecatui/ui/team_test.go
@@ -500,3 +500,57 @@ func TestTeamMemberCreatesLaneWhenRosterMissed(t *testing.T) {
t.Errorf("a cold member should create its own lane, got %+v", lanes)
}
}
+
+// TestTeamWorkingCounts locks the (working, total) classification the footer
+// k/N segment derives from a team's lanes: total is the lane count, working is the
+// count of lanes NOT idle — the SAME !ln.idle predicate teamLaneState uses for the
+// non-terminal roster glyph. An idle lane (finished its round, awaiting the next)
+// is NOT counted as working.
+func TestTeamWorkingCounts(t *testing.T) {
+ cases := []struct {
+ name string
+ lanes []teamLane
+ wantWk, wantTot int
+ }{
+ {"empty", nil, 0, 0},
+ {"all working", []teamLane{{}, {}, {}}, 3, 3},
+ {"all idle", []teamLane{{idle: true}, {idle: true}}, 0, 2},
+ {"mixed", []teamLane{{idle: true}, {}, {idle: true}, {}}, 2, 4},
+ {"single working", []teamLane{{}}, 1, 1},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ wk, tot := teamWorkingCounts(tc.lanes)
+ if wk != tc.wantWk || tot != tc.wantTot {
+ t.Errorf("teamWorkingCounts = (%d, %d), want (%d, %d)", wk, tot, tc.wantWk, tc.wantTot)
+ }
+ })
+ }
+}
+
+// TestTeamCountsIdleAsNotWorking pins the bug fix at the lane level: a live team
+// where one member has fired a per-round result (idle) and another is working must
+// count 1/2 working — NOT 2/2 (the old !ln.done predicate counted an idle member as
+// working) and NOT 0/2 (idle is not terminal). The lane is asserted idle (not a
+// fabricated terminal flag) to pin the cause.
+func TestTeamCountsIdleAsNotWorking(t *testing.T) {
+ c := &conversation{}
+ c.addTool("t1", "Team", `{}`)
+ c.startTeamCard("t1", "", roster())
+ // lead works; scout finishes its round (idle), team still live.
+ c.updateTeamCardMember(member("lead", "tool.call", client.TeamMsg{ToolName: "Edit"}))
+ c.updateTeamCardMember(member("scout", "tool.call", client.TeamMsg{ToolName: "Grep"}))
+ c.updateTeamCardMember(member("scout", "result", client.TeamMsg{}))
+
+ lanes := c.testTeamOverlay(0).teamLanes
+ if !lanes[1].idle {
+ t.Fatalf("scout lane must be idle after its per-round result, got %+v", lanes[1])
+ }
+ if lanes[0].idle {
+ t.Fatalf("lead lane must be working (not idle), got %+v", lanes[0])
+ }
+ wk, tot := teamWorkingCounts(lanes)
+ if wk != 1 || tot != 2 {
+ t.Errorf("teamWorkingCounts = (%d, %d), want (1, 2) — idle scout must not count as working", wk, tot)
+ }
+}
diff --git a/cmd/mecatui/ui/testdata/agents_inventory.golden b/cmd/mecatui/ui/testdata/agents_inventory.golden
index 0c38a0bc3e..565a3ce05e 100644
--- a/cmd/mecatui/ui/testdata/agents_inventory.golden
+++ b/cmd/mecatui/ui/testdata/agents_inventory.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/agents_inventory_empty_disabled.golden b/cmd/mecatui/ui/testdata/agents_inventory_empty_disabled.golden
index ad4da225db..d201f0d36d 100644
--- a/cmd/mecatui/ui/testdata/agents_inventory_empty_disabled.golden
+++ b/cmd/mecatui/ui/testdata/agents_inventory_empty_disabled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/agents_inventory_empty_enabled.golden b/cmd/mecatui/ui/testdata/agents_inventory_empty_enabled.golden
index de7bb1a51d..46cec4749e 100644
--- a/cmd/mecatui/ui/testdata/agents_inventory_empty_enabled.golden
+++ b/cmd/mecatui/ui/testdata/agents_inventory_empty_enabled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/agents_inventory_scroll.golden b/cmd/mecatui/ui/testdata/agents_inventory_scroll.golden
index 48b7d74d40..ceebd00946 100644
--- a/cmd/mecatui/ui/testdata/agents_inventory_scroll.golden
+++ b/cmd/mecatui/ui/testdata/agents_inventory_scroll.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/agents_teams_tab.golden b/cmd/mecatui/ui/testdata/agents_teams_tab.golden
index e81e7f1185..bfd9a25d50 100644
--- a/cmd/mecatui/ui/testdata/agents_teams_tab.golden
+++ b/cmd/mecatui/ui/testdata/agents_teams_tab.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⟳ 4/4 working · f6 ⛭ subagents 1◐ 0✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⛭ subagents 1◐ 0✓ ⟳ team- · 4/4 working ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/askargs_modal_longshell.golden b/cmd/mecatui/ui/testdata/askargs_modal_longshell.golden
index 14de6916ab..2e4176e956 100644
--- a/cmd/mecatui/ui/testdata/askargs_modal_longshell.golden
+++ b/cmd/mecatui/ui/testdata/askargs_modal_longshell.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- ⚠ awaiting approval ctx 0 · ↑0 ↓0 cache 0%
+ ⚠ awaiting approval ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/askargs_view_pretty.golden b/cmd/mecatui/ui/testdata/askargs_view_pretty.golden
index f50da2de5d..1b6f53a14c 100644
--- a/cmd/mecatui/ui/testdata/askargs_view_pretty.golden
+++ b/cmd/mecatui/ui/testdata/askargs_view_pretty.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
Ask args: Shell
@@ -25,5 +25,5 @@ scroll: ↑/↓ · pgup/pgdn · home/end · mouse wheel · r raw|pretty · esc b
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- ⚠ awaiting approval ctx 0 · ↑0 ↓0 cache 0%
+ ⚠ awaiting approval ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/askargs_view_raw.golden b/cmd/mecatui/ui/testdata/askargs_view_raw.golden
index e3915d7301..9376347940 100644
--- a/cmd/mecatui/ui/testdata/askargs_view_raw.golden
+++ b/cmd/mecatui/ui/testdata/askargs_view_raw.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
Ask args: Shell
@@ -25,5 +25,5 @@ scroll: ↑/↓ · pgup/pgdn · home/end · mouse wheel · r raw|pretty · esc b
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- ⚠ awaiting approval ctx 0 · ↑0 ↓0 cache 0%
+ ⚠ awaiting approval ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/askargs_view_scrolled.golden b/cmd/mecatui/ui/testdata/askargs_view_scrolled.golden
index 77128802ca..2553364780 100644
--- a/cmd/mecatui/ui/testdata/askargs_view_scrolled.golden
+++ b/cmd/mecatui/ui/testdata/askargs_view_scrolled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
echo step-xxxxa-
echo step-xxxxb-y
@@ -25,5 +25,5 @@ scroll: ↑/↓ · pgup/pgdn · home/end · mouse wheel · r raw|pretty · esc b
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- ⚠ awaiting approval ctx 0 · ↑0 ↓0 cache 0%
+ ⚠ awaiting approval ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/effort_picker.golden b/cmd/mecatui/ui/testdata/effort_picker.golden
index 48fbe57f83..850ec8d795 100644
--- a/cmd/mecatui/ui/testdata/effort_picker.golden
+++ b/cmd/mecatui/ui/testdata/effort_picker.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · gpt-5 · medium · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+g select all · ctrl+y copy · ctrl+u clear · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/footer_ctx_meter.golden b/cmd/mecatui/ui/testdata/footer_ctx_meter.golden
deleted file mode 100644
index 8c28c48fd8..0000000000
--- a/cmd/mecatui/ui/testdata/footer_ctx_meter.golden
+++ /dev/null
@@ -1 +0,0 @@
-connected ctx ▒▒░░░░░░ 20% · 40K/200K · ↑0 ↓0 cache 0%
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/footer_ctx_unknown.golden b/cmd/mecatui/ui/testdata/footer_ctx_unknown.golden
deleted file mode 100644
index f11768eff2..0000000000
--- a/cmd/mecatui/ui/testdata/footer_ctx_unknown.golden
+++ /dev/null
@@ -1 +0,0 @@
-connected ctx 40K · ↑0 ↓0 cache 0%
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/footer_fleet.golden b/cmd/mecatui/ui/testdata/footer_fleet.golden
deleted file mode 100644
index d79e2ea556..0000000000
--- a/cmd/mecatui/ui/testdata/footer_fleet.golden
+++ /dev/null
@@ -1,8 +0,0 @@
-no subagents:
-connected ctx 0 · ↑0 ↓0 cache 0%
-
-3 running:
-connected ⛭ subagents 3◐ 0✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
-
-mixed 2 running + 1 done:
-connected ⛭ subagents 2◐ 1✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/footer_parallel.golden b/cmd/mecatui/ui/testdata/footer_parallel.golden
deleted file mode 100644
index 97c89b7b99..0000000000
--- a/cmd/mecatui/ui/testdata/footer_parallel.golden
+++ /dev/null
@@ -1,8 +0,0 @@
-parallel only (judge, 3 done):
-connected ⑂ parallel 0◐ 1✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
-
-parallel + subagent fleet:
-connected ⑂ parallel 0◐ 1✓ · f6 ⛭ subagents 1◐ 0✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
-
-parallel + sub, narrow (medium tier):
-connected ⑂ parallel 0◐ 1✓ · f6 ⛭ subagents 1◐ 0✓ · f6 ctx 0
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/help_all_on.golden b/cmd/mecatui/ui/testdata/help_all_on.golden
index 1ed59100a5..aed887c921 100644
--- a/cmd/mecatui/ui/testdata/help_all_on.golden
+++ b/cmd/mecatui/ui/testdata/help_all_on.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/help_embedded.golden b/cmd/mecatui/ui/testdata/help_embedded.golden
index 411ef162d9..e62dfccdf9 100644
--- a/cmd/mecatui/ui/testdata/help_embedded.golden
+++ b/cmd/mecatui/ui/testdata/help_embedded.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/jev_routing_user_journey.golden b/cmd/mecatui/ui/testdata/jev_routing_user_journey.golden
index 3aa93c9b5f..6e4dbfcd22 100644
--- a/cmd/mecatui/ui/testdata/jev_routing_user_journey.golden
+++ b/cmd/mecatui/ui/testdata/jev_routing_user_journey.golden
@@ -8,7 +8,7 @@
│ subagent · 0ms · ↑0 ↓0 · 0 tools · stop:done │
╰───────────────────────────────────────────────────────────────────────────────────────────────╯
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -76,5 +76,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
- team done · 1 round ⑂ parallel 0◐ 1✓ · f6 ⛭ subagents 0◐ 2✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
+ team done · 1 round ⑂ parallel 0◐ 1✓ ⛭ subagents 0◐ 2✓ ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_err_input.golden b/cmd/mecatui/ui/testdata/mcp_err_input.golden
index 1479990cb6..1be63fcaf1 100644
--- a/cmd/mecatui/ui/testdata/mcp_err_input.golden
+++ b/cmd/mecatui/ui/testdata/mcp_err_input.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_err_not_configured.golden b/cmd/mecatui/ui/testdata/mcp_err_not_configured.golden
index fa63f4d8ee..0b4dbbcfe7 100644
--- a/cmd/mecatui/ui/testdata/mcp_err_not_configured.golden
+++ b/cmd/mecatui/ui/testdata/mcp_err_not_configured.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_err_server.golden b/cmd/mecatui/ui/testdata/mcp_err_server.golden
index c3aeac08f9..a834e6d838 100644
--- a/cmd/mecatui/ui/testdata/mcp_err_server.golden
+++ b/cmd/mecatui/ui/testdata/mcp_err_server.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_panel.golden b/cmd/mecatui/ui/testdata/mcp_panel.golden
index e38af2c7e7..2c9050e1a5 100644
--- a/cmd/mecatui/ui/testdata/mcp_panel.golden
+++ b/cmd/mecatui/ui/testdata/mcp_panel.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_panel_groups.golden b/cmd/mecatui/ui/testdata/mcp_panel_groups.golden
index 9c5eea8dc7..0a26681290 100644
--- a/cmd/mecatui/ui/testdata/mcp_panel_groups.golden
+++ b/cmd/mecatui/ui/testdata/mcp_panel_groups.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_prompt_args.golden b/cmd/mecatui/ui/testdata/mcp_prompt_args.golden
index 91afedf58d..beff24ad56 100644
--- a/cmd/mecatui/ui/testdata/mcp_prompt_args.golden
+++ b/cmd/mecatui/ui/testdata/mcp_prompt_args.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_prompts.golden b/cmd/mecatui/ui/testdata/mcp_prompts.golden
index 154ffaf566..f8dbac22aa 100644
--- a/cmd/mecatui/ui/testdata/mcp_prompts.golden
+++ b/cmd/mecatui/ui/testdata/mcp_prompts.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_resource_preview.golden b/cmd/mecatui/ui/testdata/mcp_resource_preview.golden
index e7f747d689..d07311a314 100644
--- a/cmd/mecatui/ui/testdata/mcp_resource_preview.golden
+++ b/cmd/mecatui/ui/testdata/mcp_resource_preview.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/mcp_resources.golden b/cmd/mecatui/ui/testdata/mcp_resources.golden
index bbe5fc58c2..dd72d33e1b 100644
--- a/cmd/mecatui/ui/testdata/mcp_resources.golden
+++ b/cmd/mecatui/ui/testdata/mcp_resources.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models.golden b/cmd/mecatui/ui/testdata/models.golden
index e726bc1e78..d6c7dfffed 100644
--- a/cmd/mecatui/ui/testdata/models.golden
+++ b/cmd/mecatui/ui/testdata/models.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_codex_healthy.golden b/cmd/mecatui/ui/testdata/models_codex_healthy.golden
index 9b78dbb9c6..05f2c4b4db 100644
--- a/cmd/mecatui/ui/testdata/models_codex_healthy.golden
+++ b/cmd/mecatui/ui/testdata/models_codex_healthy.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · gpt-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden b/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden
index ea9a86c372..7bf38d1fd9 100644
--- a/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden
+++ b/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · gpt-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_disabled.golden b/cmd/mecatui/ui/testdata/models_disabled.golden
index 46a81fc646..5d1283ef71 100644
--- a/cmd/mecatui/ui/testdata/models_disabled.golden
+++ b/cmd/mecatui/ui/testdata/models_disabled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · gpt-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_empty.golden b/cmd/mecatui/ui/testdata/models_empty.golden
index 6e2c27bcda..1f57a6dbae 100644
--- a/cmd/mecatui/ui/testdata/models_empty.golden
+++ b/cmd/mecatui/ui/testdata/models_empty.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · gpt-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_filtered.golden b/cmd/mecatui/ui/testdata/models_filtered.golden
index 349426a359..4cb66636c6 100644
--- a/cmd/mecatui/ui/testdata/models_filtered.golden
+++ b/cmd/mecatui/ui/testdata/models_filtered.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_gateway_empty.golden b/cmd/mecatui/ui/testdata/models_gateway_empty.golden
index 35a88a66c4..cb31529146 100644
--- a/cmd/mecatui/ui/testdata/models_gateway_empty.golden
+++ b/cmd/mecatui/ui/testdata/models_gateway_empty.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · gpt-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_global_default.golden b/cmd/mecatui/ui/testdata/models_global_default.golden
index 4639a3b160..e0708ceb29 100644
--- a/cmd/mecatui/ui/testdata/models_global_default.golden
+++ b/cmd/mecatui/ui/testdata/models_global_default.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_mixed_deployment_empty.golden b/cmd/mecatui/ui/testdata/models_mixed_deployment_empty.golden
index 9969f96045..edf12bc176 100644
--- a/cmd/mecatui/ui/testdata/models_mixed_deployment_empty.golden
+++ b/cmd/mecatui/ui/testdata/models_mixed_deployment_empty.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_nomatch.golden b/cmd/mecatui/ui/testdata/models_nomatch.golden
index 269cb646a8..e137849405 100644
--- a/cmd/mecatui/ui/testdata/models_nomatch.golden
+++ b/cmd/mecatui/ui/testdata/models_nomatch.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_scrolled.golden b/cmd/mecatui/ui/testdata/models_scrolled.golden
index 3f25bbea48..8840262a63 100644
--- a/cmd/mecatui/ui/testdata/models_scrolled.golden
+++ b/cmd/mecatui/ui/testdata/models_scrolled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · gpt-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden b/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden
index 2fbbf428e2..3684151a08 100644
--- a/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden
+++ b/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · GPT-5 · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/parallel_group_focus.golden b/cmd/mecatui/ui/testdata/parallel_group_focus.golden
index 1e3167efb3..20c0a584a9 100644
--- a/cmd/mecatui/ui/testdata/parallel_group_focus.golden
+++ b/cmd/mecatui/ui/testdata/parallel_group_focus.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⑂ parallel 0◐ 1✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⑂ parallel 0◐ 3✓ ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/parallel_roster.golden b/cmd/mecatui/ui/testdata/parallel_roster.golden
index 9003ed9904..dbf1ed2ed1 100644
--- a/cmd/mecatui/ui/testdata/parallel_roster.golden
+++ b/cmd/mecatui/ui/testdata/parallel_roster.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⑂ parallel 0◐ 1✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⑂ parallel 0◐ 3✓ ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/paste_placeholder.golden b/cmd/mecatui/ui/testdata/paste_placeholder.golden
index 28f3fefafa..09b83cfe3e 100644
--- a/cmd/mecatui/ui/testdata/paste_placeholder.golden
+++ b/cmd/mecatui/ui/testdata/paste_placeholder.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -76,5 +76,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+g select all · ctrl+y copy · ctrl+u clear · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_cleanup_review.golden b/cmd/mecatui/ui/testdata/sessions_cleanup_review.golden
index fb88f6592b..e149ad7e37 100644
--- a/cmd/mecatui/ui/testdata/sessions_cleanup_review.golden
+++ b/cmd/mecatui/ui/testdata/sessions_cleanup_review.golden
@@ -1,4 +1,4 @@
- mecatui · session connecting… · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
Chats Scheduled runs Child runs Other ▸ Maintenance
@@ -36,5 +36,5 @@ enter: delete these sessions esc: back
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- ⣾ connecting… ctx 0 · ↑0 ↓0 cache 0%
+ ⣾ connecting… ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_picker.golden b/cmd/mecatui/ui/testdata/sessions_picker.golden
index 73128ac6c7..e9ae1da335 100644
--- a/cmd/mecatui/ui/testdata/sessions_picker.golden
+++ b/cmd/mecatui/ui/testdata/sessions_picker.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
▸ Chats Scheduled runs Child runs Other
@@ -36,5 +36,5 @@ enter: continue y: copy ID v: view f: fork r: rename d: delete tab: switch
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_picker_empty.golden b/cmd/mecatui/ui/testdata/sessions_picker_empty.golden
index 8041ca0628..27d9fa5d74 100644
--- a/cmd/mecatui/ui/testdata/sessions_picker_empty.golden
+++ b/cmd/mecatui/ui/testdata/sessions_picker_empty.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
▸ Chats Scheduled runs Child runs Other
@@ -36,5 +36,5 @@ tab: switch esc: close
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_picker_error.golden b/cmd/mecatui/ui/testdata/sessions_picker_error.golden
index 20c85f7966..f89110d252 100644
--- a/cmd/mecatui/ui/testdata/sessions_picker_error.golden
+++ b/cmd/mecatui/ui/testdata/sessions_picker_error.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
▸ Chats Scheduled runs Child runs Other
@@ -36,5 +36,5 @@ r: retry esc: close
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_picker_filtered.golden b/cmd/mecatui/ui/testdata/sessions_picker_filtered.golden
index 170fed30f6..e9ae1da335 100644
--- a/cmd/mecatui/ui/testdata/sessions_picker_filtered.golden
+++ b/cmd/mecatui/ui/testdata/sessions_picker_filtered.golden
@@ -1,15 +1,40 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
-▸ Sessions Children
+▸ Chats Scheduled runs Child runs Other
+
+▶✓ 5m ago 12t Fix the flaky CI sess-aaa (gpt-5)
+
+enter: continue y: copy ID v: view f: fork r: rename d: delete tab: switch esc: close
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-▶ ✓ 5m ago 12t sess-aaa (gpt-5)
-enter: continue tab: switch esc: close
│
│ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help)
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_picker_nomatch.golden b/cmd/mecatui/ui/testdata/sessions_picker_nomatch.golden
index 8d5aaf5c51..b63f807f09 100644
--- a/cmd/mecatui/ui/testdata/sessions_picker_nomatch.golden
+++ b/cmd/mecatui/ui/testdata/sessions_picker_nomatch.golden
@@ -1,14 +1,40 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
-▸ Sessions Children
+▸ Chats Scheduled runs Child runs Other
-no matches — clear filter to see all
+no matches — clear search to see all
tab: switch esc: close
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
│
│ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help)
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_transcript_error.golden b/cmd/mecatui/ui/testdata/sessions_transcript_error.golden
index 3894b21b86..f4afed86f0 100644
--- a/cmd/mecatui/ui/testdata/sessions_transcript_error.golden
+++ b/cmd/mecatui/ui/testdata/sessions_transcript_error.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
conversation unavailable
@@ -10,5 +10,5 @@ r: Retry esc: Back
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_transcript_loaded.golden b/cmd/mecatui/ui/testdata/sessions_transcript_loaded.golden
index 9f41408544..f5fdaa67c4 100644
--- a/cmd/mecatui/ui/testdata/sessions_transcript_loaded.golden
+++ b/cmd/mecatui/ui/testdata/sessions_transcript_loaded.golden
@@ -1,13 +1,12 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
-session sess-test-0001 · read-only transcript
-(no events in this session)
-esc: back
+Inspecting session · read-only
+(empty conversation)esc: Back
│
│ Ask mecatl to do something… (enter to send · shift+enter for newline · ? for help)
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_transcript_loading.golden b/cmd/mecatui/ui/testdata/sessions_transcript_loading.golden
index f9526de297..08c508a1d6 100644
--- a/cmd/mecatui/ui/testdata/sessions_transcript_loading.golden
+++ b/cmd/mecatui/ui/testdata/sessions_transcript_loading.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
loading conversation
@@ -10,5 +10,5 @@ esc: Back
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden b/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden
index d3118f5673..64a1841acf 100644
--- a/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden
+++ b/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
Inspecting Nightly checks · read-only
▌ you
@@ -37,5 +37,5 @@ Inspecting Nightly checks · read-only
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/skills.golden b/cmd/mecatui/ui/testdata/skills.golden
index 73537140dd..ef93617132 100644
--- a/cmd/mecatui/ui/testdata/skills.golden
+++ b/cmd/mecatui/ui/testdata/skills.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/skills_filtered.golden b/cmd/mecatui/ui/testdata/skills_filtered.golden
index c976f8a14e..ea129b39e9 100644
--- a/cmd/mecatui/ui/testdata/skills_filtered.golden
+++ b/cmd/mecatui/ui/testdata/skills_filtered.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/skills_nomatch.golden b/cmd/mecatui/ui/testdata/skills_nomatch.golden
index 4a4a5e5f4a..59c5c4c38a 100644
--- a/cmd/mecatui/ui/testdata/skills_nomatch.golden
+++ b/cmd/mecatui/ui/testdata/skills_nomatch.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/skills_scroll.golden b/cmd/mecatui/ui/testdata/skills_scroll.golden
index 995261c977..fbffd949b6 100644
--- a/cmd/mecatui/ui/testdata/skills_scroll.golden
+++ b/cmd/mecatui/ui/testdata/skills_scroll.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/soul.golden b/cmd/mecatui/ui/testdata/soul.golden
index e87860f16d..d72e527151 100644
--- a/cmd/mecatui/ui/testdata/soul.golden
+++ b/cmd/mecatui/ui/testdata/soul.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/soul_empty_disabled.golden b/cmd/mecatui/ui/testdata/soul_empty_disabled.golden
index 99a5742313..a006a14931 100644
--- a/cmd/mecatui/ui/testdata/soul_empty_disabled.golden
+++ b/cmd/mecatui/ui/testdata/soul_empty_disabled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/soul_empty_enabled.golden b/cmd/mecatui/ui/testdata/soul_empty_enabled.golden
index 70b2edda6f..c74b9858cf 100644
--- a/cmd/mecatui/ui/testdata/soul_empty_enabled.golden
+++ b/cmd/mecatui/ui/testdata/soul_empty_enabled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/soul_project.golden b/cmd/mecatui/ui/testdata/soul_project.golden
index b4b6850313..45240161f1 100644
--- a/cmd/mecatui/ui/testdata/soul_project.golden
+++ b/cmd/mecatui/ui/testdata/soul_project.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/steer_card.golden b/cmd/mecatui/ui/testdata/steer_card.golden
index ba7d1330f5..238b20c722 100644
--- a/cmd/mecatui/ui/testdata/steer_card.golden
+++ b/cmd/mecatui/ui/testdata/steer_card.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mode default
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
▌ you
│ first
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- ⣾ thinking… ctx 0 · ↑0 ↓0 cache 0%
+ ⣾ thinking… ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
enter queue · ctrl+u clear · esc cancel · ? help · / commands · ctrl+g select all · ctrl+y copy · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/subagent_focus.golden b/cmd/mecatui/ui/testdata/subagent_focus.golden
index eb46e92b1c..61678299c5 100644
--- a/cmd/mecatui/ui/testdata/subagent_focus.golden
+++ b/cmd/mecatui/ui/testdata/subagent_focus.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⛭ subagents 3◐ 2✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⛭ subagents 3◐ 2✓ ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/subagent_focus_background.golden b/cmd/mecatui/ui/testdata/subagent_focus_background.golden
index 8d22c4a0de..740f96dbb1 100644
--- a/cmd/mecatui/ui/testdata/subagent_focus_background.golden
+++ b/cmd/mecatui/ui/testdata/subagent_focus_background.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- background subagent #r-g7b7 done — result ready for the agent ⛭ subagents 4◐ 3✓ · f6 ctx 0
+ background subagent #r-g7b7 done — result ready for the agent ⛭ 4◐ 3✓ ctx ▒▒▒▒░░░░ 50%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/subagent_roster.golden b/cmd/mecatui/ui/testdata/subagent_roster.golden
index de2d677264..51193b5d05 100644
--- a/cmd/mecatui/ui/testdata/subagent_roster.golden
+++ b/cmd/mecatui/ui/testdata/subagent_roster.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⛭ subagents 3◐ 2✓ · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⛭ subagents 3◐ 2✓ ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/subagent_roster_background.golden b/cmd/mecatui/ui/testdata/subagent_roster_background.golden
index 3a05d3e9b9..bbf533258a 100644
--- a/cmd/mecatui/ui/testdata/subagent_roster_background.golden
+++ b/cmd/mecatui/ui/testdata/subagent_roster_background.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- background subagent #r-g7b7 done — result ready for the agent ⛭ subagents 4◐ 3✓ · f6 ctx 0
+ background subagent #r-g7b7 done — result ready for the agent ⛭ 4◐ 3✓ ctx ▒▒▒▒░░░░ 50%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/team_findings.golden b/cmd/mecatui/ui/testdata/team_findings.golden
index 381350c05b..1212c75e4c 100644
--- a/cmd/mecatui/ui/testdata/team_findings.golden
+++ b/cmd/mecatui/ui/testdata/team_findings.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⟳ 2/2 working · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⟳ team- · 2/2 working ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/team_focus.golden b/cmd/mecatui/ui/testdata/team_focus.golden
index 9f91d441b0..26e1f45499 100644
--- a/cmd/mecatui/ui/testdata/team_focus.golden
+++ b/cmd/mecatui/ui/testdata/team_focus.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⟳ 4/4 working · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⟳ team- · 4/4 working ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/team_focus_windowed.golden b/cmd/mecatui/ui/testdata/team_focus_windowed.golden
index 338ffd915e..ee3212d6e0 100644
--- a/cmd/mecatui/ui/testdata/team_focus_windowed.golden
+++ b/cmd/mecatui/ui/testdata/team_focus_windowed.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⟳ 1/1 working · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⟳ team- · 1/1 working ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/team_roster.golden b/cmd/mecatui/ui/testdata/team_roster.golden
index 2b6e483a6e..2f2962e1bd 100644
--- a/cmd/mecatui/ui/testdata/team_roster.golden
+++ b/cmd/mecatui/ui/testdata/team_roster.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⟳ 4/4 working · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⟳ team- · 4/4 working ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/team_roster_midrun_idle.golden b/cmd/mecatui/ui/testdata/team_roster_midrun_idle.golden
index f3b8152beb..1d7f9b112c 100644
--- a/cmd/mecatui/ui/testdata/team_roster_midrun_idle.golden
+++ b/cmd/mecatui/ui/testdata/team_roster_midrun_idle.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⟳ 1/2 working · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⟳ team- · 1/2 working ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/team_roster_stopped.golden b/cmd/mecatui/ui/testdata/team_roster_stopped.golden
index 9daa3f5d3c..c2c2383300 100644
--- a/cmd/mecatui/ui/testdata/team_roster_stopped.golden
+++ b/cmd/mecatui/ui/testdata/team_roster_stopped.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/team_roster_windowed.golden b/cmd/mecatui/ui/testdata/team_roster_windowed.golden
index c72e96ad95..bbde12b4b0 100644
--- a/cmd/mecatui/ui/testdata/team_roster_windowed.golden
+++ b/cmd/mecatui/ui/testdata/team_roster_windowed.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⟳ 20/20 working · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⟳ team- · 20/20 working ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/team_tasks.golden b/cmd/mecatui/ui/testdata/team_tasks.golden
index 1ff8d1aac8..b9a700343b 100644
--- a/cmd/mecatui/ui/testdata/team_tasks.golden
+++ b/cmd/mecatui/ui/testdata/team_tasks.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ⟳ 2/2 working · f6 ctx 0 · ↑0 ↓0 cache 0%
+ connected ⟳ team- · 2/2 working ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/usermodel.golden b/cmd/mecatui/ui/testdata/usermodel.golden
index e1efa3de9e..4d58190515 100644
--- a/cmd/mecatui/ui/testdata/usermodel.golden
+++ b/cmd/mecatui/ui/testdata/usermodel.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/usermodel_empty_disabled.golden b/cmd/mecatui/ui/testdata/usermodel_empty_disabled.golden
index 4bf7a2c61e..c54b3ac5ec 100644
--- a/cmd/mecatui/ui/testdata/usermodel_empty_disabled.golden
+++ b/cmd/mecatui/ui/testdata/usermodel_empty_disabled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/usermodel_empty_enabled.golden b/cmd/mecatui/ui/testdata/usermodel_empty_enabled.golden
index 41d44b1ac6..b502011882 100644
--- a/cmd/mecatui/ui/testdata/usermodel_empty_enabled.golden
+++ b/cmd/mecatui/ui/testdata/usermodel_empty_enabled.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/view_aztec_ansi.golden b/cmd/mecatui/ui/testdata/view_aztec_ansi.golden
index 45d47c2abf..9714422ac3 100644
--- a/cmd/mecatui/ui/testdata/view_aztec_ansi.golden
+++ b/cmd/mecatui/ui/testdata/view_aztec_ansi.golden
@@ -1,4 +1,4 @@
- [1;38;2;31;179;154mmecatui · session sess-test-00 · mock-model · mode [1;38;2;233;185;73mdefault[m · 127.0.0.1:8080[m
+ [1;38;2;31;179;154m[38;2;31;179;154mmecatui · Golden session · fixture/fixture-model/default[m[38;2;233;185;73m · mode default[m[m
[38;2;46;64;57m────────────────────────────────────────────────────────────────────────────────────────────────────[m
[38;2;233;185;73m┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓[m
@@ -26,5 +26,5 @@
[38;2;233;185;73m│[m[48;2;21;32;28m [m[48;2;21;32;28m[48;2;21;32;28m[48;2;21;32;28m[38;2;233;185;73;48;2;21;32;28m[37;48;2;21;32;28m[m[m[30;48;2;21;32;28m[48;2;21;32;28m [m[m[48;2;21;32;28m [m
[38;2;233;185;73m│[m[48;2;21;32;28m [m[48;2;21;32;28m[48;2;21;32;28m[48;2;21;32;28m[38;2;233;185;73;48;2;21;32;28m[37;48;2;21;32;28m[m[m[30;48;2;21;32;28m[48;2;21;32;28m [m[m[48;2;21;32;28m [m
[38;2;46;64;57m────────────────────────────────────────────────────────────────────────────────────────────────────[m
- [38;2;138;153;143m[1;38;2;233;185;73m⚠ awaiting approval[m ctx 1.2K · ↑0 ↓0 cache 0%[m
+ [38;2;138;153;143m[1;38;2;233;185;73m⚠ awaiting approval[m [38;2;76;195;138mctx ▒▒▒▒░░░░ 50%[m[38;2;231;226;211m · 2K/4K · ↑0 ↓0 cache 0%[m[m
[3;38;2;138;153;143m? help · / commands · ctrl+c quit[m
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/view_stripped.golden b/cmd/mecatui/ui/testdata/view_stripped.golden
index 5d5cf326c6..9c6d53a6ed 100644
--- a/cmd/mecatui/ui/testdata/view_stripped.golden
+++ b/cmd/mecatui/ui/testdata/view_stripped.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@@ -26,5 +26,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- ⚠ awaiting approval ctx 1.2K · ↑0 ↓0 cache 0%
+ ⚠ awaiting approval ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/zerostate_all_on.golden b/cmd/mecatui/ui/testdata/zerostate_all_on.golden
index 2b42987a9c..bad94f596c 100644
--- a/cmd/mecatui/ui/testdata/zerostate_all_on.golden
+++ b/cmd/mecatui/ui/testdata/zerostate_all_on.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -76,5 +76,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+g select all · ctrl+y copy · ctrl+u clear · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/testdata/zerostate_embedded.golden b/cmd/mecatui/ui/testdata/zerostate_embedded.golden
index 2b42987a9c..bad94f596c 100644
--- a/cmd/mecatui/ui/testdata/zerostate_embedded.golden
+++ b/cmd/mecatui/ui/testdata/zerostate_embedded.golden
@@ -1,4 +1,4 @@
- mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080
+ mecatui · Golden session · fixture/fixture-model/default · mode default
────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -76,5 +76,5 @@
│
│
────────────────────────────────────────────────────────────────────────────────────────────────────
- connected ctx 0 · ↑0 ↓0 cache 0%
+ connected ctx ▒▒▒▒░░░░ 50% · 2K/4K · ↑0 ↓0 cache 0%
? help · / commands · ctrl+g select all · ctrl+y copy · ctrl+u clear · ctrl+c quit
\ No newline at end of file
diff --git a/cmd/mecatui/ui/tool_block.go b/cmd/mecatui/ui/tool_block.go
index 033def3430..b25a6d16c8 100644
--- a/cmd/mecatui/ui/tool_block.go
+++ b/cmd/mecatui/ui/tool_block.go
@@ -7,6 +7,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/blocks"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback"
)
@@ -302,11 +303,11 @@ func (r *renderer) subagentPresentationLiveLine(p subagentCardPresentation) stri
if p.current != "" {
current = terminaltext.Sanitize(p.current)
}
- return fmt.Sprintf("subagent · %s · ↑%s ↓%s · %s · %s trace", current, humanizeTokens(p.usage.InputTokens), humanizeTokens(p.usage.OutputTokens), plural(p.toolCount, "tool"), r.marks.expandTools)
+ return fmt.Sprintf("subagent · %s · ↑%s ↓%s · %s · %s trace", current, renderfmt.HumanizeTokens(p.usage.InputTokens), renderfmt.HumanizeTokens(p.usage.OutputTokens), plural(p.toolCount, "tool"), r.marks.expandTools)
}
func subagentPresentationResolvedLine(p subagentCardPresentation) string {
- return fmt.Sprintf("subagent · %s · ↑%s ↓%s · %s · stop:%s", humanizeDuration(p.durationMS), humanizeTokens(p.usage.InputTokens), humanizeTokens(p.usage.OutputTokens), plural(p.toolCount, "tool"), subagentStopLabel(p.stop))
+ return fmt.Sprintf("subagent · %s · ↑%s ↓%s · %s · stop:%s", renderfmt.HumanizeDuration(p.durationMS), renderfmt.HumanizeTokens(p.usage.InputTokens), renderfmt.HumanizeTokens(p.usage.OutputTokens), plural(p.toolCount, "tool"), subagentStopLabel(p.stop))
}
func (r *renderer) renderTeamPresentation(p teamCardPresentation, expand bool, bodyWidth int) string {
@@ -360,7 +361,7 @@ func (r *renderer) teamPresentationHeader(p teamCardPresentation, expand bool) s
}
func teamPresentationResolvedLine(p teamCardPresentation) string {
- line := fmt.Sprintf("team · %s · ↑%s ↓%s · stop:%s", plural(p.rounds, "round"), humanizeTokens(p.usage.InputTokens), humanizeTokens(p.usage.OutputTokens), subagentStopLabel(p.stop))
+ line := fmt.Sprintf("team · %s · ↑%s ↓%s · stop:%s", plural(p.rounds, "round"), renderfmt.HumanizeTokens(p.usage.InputTokens), renderfmt.HumanizeTokens(p.usage.OutputTokens), subagentStopLabel(p.stop))
stopped := 0
for _, lane := range p.lanes {
if lane.stopped {
diff --git a/cmd/mecatui/ui/ui_test.go b/cmd/mecatui/ui/ui_test.go
index 3d8ab84fb4..140624e956 100644
--- a/cmd/mecatui/ui/ui_test.go
+++ b/cmd/mecatui/ui/ui_test.go
@@ -18,6 +18,7 @@ import (
"google.golang.org/grpc/status"
"github.com/stacklok/mecatl/cmd/mecatui/client"
+ customization "github.com/stacklok/mecatl/cmd/mecatui/customization"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1"
)
@@ -213,6 +214,9 @@ var ansiRE = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07`)
func stripANSI(b []byte) []byte { return ansiRE.ReplaceAll(b, nil) }
+// stripANSIstr is a string convenience over stripANSI for assertions.
+func stripANSIstr(s string) string { return string(stripANSI([]byte(s))) }
+
// ev wraps an Event for a script.
func ev(e *mecatlv1.Event) *mecatlv1.ConverseResponse {
return &mecatlv1.ConverseResponse{Event: e}
@@ -434,6 +438,41 @@ func driveTo(t *testing.T, th theme.Theme) Model {
return m
}
+// stockStatusFrame installs the shipped status source on an already-built model,
+// submits its canonical snapshot, and reduces the source listener result.
+func stockStatusFrame(t *testing.T, m Model) Model {
+ t.Helper()
+ return statusFrameWithInput(t, m, m.statusLineSnapshot())
+}
+
+func statusFrameWithInput(t *testing.T, m Model, input customization.Input) Model {
+ t.Helper()
+ source := customization.NewDefaultSource(0)
+ t.Cleanup(func() { _ = source.Close(context.Background()) })
+ m.deps.StatusSource = source
+ source.Submit(input)
+
+ updated, _ := m.Update(waitStatusMessage(t, m.statusLineWaitCmd()))
+ return updated.(Model)
+}
+
+// goldenStatusFrame exercises the shipped templates with representative, fixed
+// status input while leaving each golden's conversation and UI state intact.
+func goldenStatusFrame(t *testing.T, m Model) Model {
+ t.Helper()
+ input := m.statusLineSnapshot()
+ input.Session.Title = "Golden session"
+ input.Model.ProviderID = "fixture"
+ input.Model.DisplayName = "fixture-model"
+ input.Model.Route = "default"
+ input.Context = customization.Context{
+ Used: customization.ContextAtom{Raw: 2048, Human: "2K"},
+ Window: customization.ContextAtom{Raw: 4096, Human: "4K"},
+ Percent: 50, Known: true,
+ }
+ return statusFrameWithInput(t, m, input)
+}
+
// applyAll feeds a sequence of msgs to a Model, discarding commands.
func applyAll(m Model, msgs ...tea.Msg) Model {
for _, msg := range msgs {
@@ -460,10 +499,28 @@ func askFrameMsgs() []tea.Msg {
}
}
+// TestStockStatusFixture proves the reusable golden fixture consumes a source
+// notification through the listener/reducer, while ordinary models retain the
+// deliberate nil-source baseline.
+func TestStockStatusFixture(t *testing.T) {
+ baseline := driveTo(t, theme.New("aztec", theme.AztecPalette()))
+ if baseline.deps.StatusSource != nil || baseline.generatedStatusLine.Header.Present || baseline.generatedStatusLine.Footer.Present {
+ t.Fatalf("nil-source baseline = source:%T result:%#v", baseline.deps.StatusSource, baseline.generatedStatusLine)
+ }
+
+ m := stockStatusFrame(t, baseline)
+ if !m.generatedStatusLine.Header.Present || statusSpansText(m.generatedStatusLine.Header.Spans) == "" {
+ t.Fatalf("generated header = %#v", m.generatedStatusLine.Header)
+ }
+ if !m.generatedStatusLine.Footer.Present || statusSpansText(m.generatedStatusLine.Footer.Spans) == "" {
+ t.Fatalf("generated footer = %#v", m.generatedStatusLine.Footer)
+ }
+}
+
// TestViewGoldenStripped locks the awaiting-approval frame (ANSI stripped) — the
// readable structural golden a reviewer can eyeball.
func TestViewGoldenStripped(t *testing.T) {
- m := driveTo(t, theme.New("aztec", theme.AztecPalette()))
+ m := goldenStatusFrame(t, driveTo(t, theme.New("aztec", theme.AztecPalette())))
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "view_stripped.golden", got)
}
@@ -471,7 +528,7 @@ func TestViewGoldenStripped(t *testing.T) {
// TestViewGoldenAztecANSI locks the same frame WITH ANSI so an Aztec palette
// regression (a changed escape) is caught.
func TestViewGoldenAztecANSI(t *testing.T) {
- m := driveTo(t, theme.New("aztec", theme.AztecPalette()))
+ m := goldenStatusFrame(t, driveTo(t, theme.New("aztec", theme.AztecPalette())))
got := []byte(m.View().Content)
compareGolden(t, "view_aztec_ansi.golden", got)
}
diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go
index 4b0c455e48..3c2caa0598 100644
--- a/cmd/mecatui/ui/update.go
+++ b/cmd/mecatui/ui/update.go
@@ -18,6 +18,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
"github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
"github.com/stacklok/mecatl/cmd/mecatui/ui/welcome"
)
@@ -1304,8 +1305,8 @@ func (m Model) updateStreamEvent(msg tea.Msg) (tea.Model, tea.Cmd) {
m.contextEstimated = msg.Estimated
}
m.conv.endReasoningStream()
- if !trivialTurn(msg) {
- m.conv.addTurnStat(turnStatLine(msg))
+ if !renderfmt.TrivialTurn(msg) {
+ m.conv.addTurnStat(renderfmt.TurnStatLine(msg))
}
mm, cmd := m.afterEvent()
// Footer context-meter self-heal (issue #66): if the meter's denominator is
@@ -4229,7 +4230,7 @@ func (m Model) endRun(stop string) Model {
// cursor-blink cmd we don't thread back here; the next keypress re-arms the blink.
_ = m.prompt.Focus()
if stop != "" {
- text, slot := stopReasonLabel(stop)
+ text, slot := renderfmt.StopReasonLabel(stop)
m.statusMsg = m.deps.Theme.Style(slot).Render(text)
}
m.refreshView()
@@ -4972,7 +4973,7 @@ func (m Model) resumeQueue() (tea.Model, tea.Cmd) {
// done or merely hit a SIZE bound, so feeding the next staged prompt simply
// continues the work the user lined up:
//
-// - "" — treated as a clean end_turn throughout (cf. stopReasonLabel).
+// - "" — treated as a clean end_turn throughout (cf. renderfmt.StopReasonLabel).
// - end_turn — the model finished without requesting more tools.
// - max_turns / max_tool_calls / budget — the run hit a per-run budget. The model was
// healthy; it just ran out of room. A queued follow-up ("continue", or the next
@@ -4988,7 +4989,7 @@ func (m Model) resumeQueue() (tea.Model, tea.Cmd) {
// - error / "closed" — the run broke or the stream ended abnormally.
//
// The vocabulary is the session.StopReason set plus the "closed" StreamClosed
-// sentinel; stopReasonLabel renders the same set for the footer.
+// sentinel; renderfmt.StopReasonLabel renders the same set for the footer.
func shouldDrain(stop string) bool {
switch stop {
case "", "end_turn", "max_turns", "max_tool_calls", "budget":
diff --git a/cmd/mecatui/ui/usermodel_test.go b/cmd/mecatui/ui/usermodel_test.go
index 6dd20fcaa2..ad500bf1e5 100644
--- a/cmd/mecatui/ui/usermodel_test.go
+++ b/cmd/mecatui/ui/usermodel_test.go
@@ -178,16 +178,19 @@ func TestUserModelDetailHistoryScrollKeepsExactData(t *testing.T) {
func TestUserModelPanelGolden(t *testing.T) {
m, _ := openedUserModel(t, newUserModelModel(t, sampleUserModel(), client.Capabilities{UserModel: true}))
+ m = goldenStatusFrame(t, m)
compareGolden(t, "usermodel.golden", stripANSI([]byte(m.View().Content)))
}
func TestUserModelPanelEmptyDisabledGolden(t *testing.T) {
m := newUserModelModel(t, &fakeUserModel{}, client.Capabilities{})
s := &userModelState{deps: (&m).surfaceDeps(), list: &bounded.List{}, viewport: &bounded.Viewport{}}
m.modal = s
+ m = goldenStatusFrame(t, m)
compareGolden(t, "usermodel_empty_disabled.golden", stripANSI([]byte(m.View().Content)))
}
func TestUserModelPanelEmptyEnabledGolden(t *testing.T) {
m, _ := openedUserModel(t, newUserModelModel(t, &fakeUserModel{}, client.Capabilities{UserModel: true}))
+ m = goldenStatusFrame(t, m)
compareGolden(t, "usermodel_empty_enabled.golden", stripANSI([]byte(m.View().Content)))
}
diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go
index 8bef25a593..a587a028fd 100644
--- a/cmd/mecatui/ui/view.go
+++ b/cmd/mecatui/ui/view.go
@@ -11,6 +11,7 @@ import (
"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
"github.com/stacklok/mecatl/cmd/mecatui/theme"
+ "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt"
)
// centerCard frames body in the askCard style and centers it over the
@@ -126,68 +127,36 @@ func (m Model) renderBody() string {
}
}
-// renderHeader is the top bar: session id · model · mode · server.
+// renderHeader composes source-selected identity beside renderer-owned safety chrome.
func (m Model) renderHeader() string {
- sid := m.sessionID
- if sid == "" {
- sid = "connecting…"
- } else {
- sid = client.SessionHandle(sid)
- }
- // next: badge — the pendingNext (apply-on-next-create) selection, shown ONLY when
- // it is set AND differs from the effective model this session runs on (same model
- // ⇒ nothing to preview). It is the FIRST segment shed under width pressure: the
- // line is assembled WITH it, and dropped to the without-it form when the with-it
- // line would overflow the header width.
- nextBadge := m.headerNextBadge()
- withBadge := m.headerIdentityParts(sid, nextBadge)
- line := strings.Join(withBadge, " · ")
- if nextBadge != "" && lipgloss.Width(line) > m.widthOr()-headerIdentityPad {
- line = strings.Join(m.headerIdentityParts(sid, ""), " · ")
+ line := ""
+ if handle := client.SessionHandle(m.sessionID); m.deps.StatusSource == nil && handle != "" {
+ line = "session " + handle
}
- if m.deps.DebugTarget != "" && lipgloss.Width(line) > m.widthOr()-headerIdentityPad {
- line = strings.Join(m.debugEssentialHeaderParts(sid), " · ")
+ if m.deps.DebugTarget != "" {
+ if line != "" {
+ line = m.debugHeaderTarget() + " · " + line
+ } else {
+ line = m.debugHeaderTarget()
+ }
}
- // Right-align ONE muted indicator on the header line when it fits beside the
- // identity segment; otherwise drop it (so the indicator never forces a wrap — the
- // identity line itself still wraps when it alone exceeds the width). The header is
- // the least-crowded bar — the footer is already busy with the context meter
- // and usage facets. The scroll-position indicator takes precedence over the
- // changed-files indicator while the user is scrolled up, so it is visible
- // exactly when it matters; at the bottom it is "" and the changed-files cue
- // shows (so the steady-state at-bottom frame is byte-identical to before).
tail := m.scrollIndicator()
if tail == "" {
tail = m.changedFilesIndicator()
}
- // Operator-posture badge: right-aligned CHROME (NOT the per-session `mode`
- // segment, which is PermissionMode). It surfaces the SERVER-WIDE automation
- // posture for auto/yolo ONLY — strict/trusted render NO badge, so the steady-state
- // frame (and the goldens) are byte-identical to before this feature. postureBadgeRender
- // returns the fully-styled badge (auto → inline warning text; yolo → plain emoji bolt +
- // clean danger pill) plus its visible width, so fitHeader only does layout. When a
- // scroll/changed-files tail is also present the badge sits to its LEFT so the warning is
- // never hidden by scrolling.
- badge, badgeW, hasBadge := m.postureBadgeRender()
- if hasBadge || tail != "" {
- if lineSurface := m.generatedStatusLine.Header; m.deps.DebugTarget == "" && lineSurface.Present && statusSurfaceFits(lineSurface, m.statusLineGeometry().headerAvailable) {
- line = renderStatusSurface(m.deps.Theme, lineSurface, m.statusLineGeometry().headerAvailable, false)
- line = m.fitHeader(line, badge, badgeW, tail, m.widthOr())
- line = m.appendDebugPrivacyNotice(line)
- return m.deps.Theme.Style("header").Width(m.widthOr()).Render(line)
- }
- if m.deps.DebugTarget == "" && m.deps.StatusSource != nil {
- line = m.fitHeader("", badge, badgeW, tail, m.widthOr())
- return m.deps.Theme.Style("header").Width(m.widthOr()).Render(line)
+ badge, badgeW := m.postureBadgeRender()
+ available := m.statusHeaderAvailable(badge, badgeW, tail)
+ if surface := m.generatedStatusLine.Header; m.deps.StatusSource != nil && surface.Present && statusSurfaceFits(surface, available) && statusSpansText(surface.Spans) != "" {
+ generated := renderStatusSurface(m.deps.Theme, surface, available, false)
+ if line != "" {
+ line += " · "
}
+ line += generated
+ }
+ if badge != "" || tail != "" {
line = m.fitHeader(line, badge, badgeW, tail, m.widthOr())
- } else if lineSurface := m.generatedStatusLine.Header; m.deps.DebugTarget == "" && lineSurface.Present && statusSurfaceFits(lineSurface, m.statusLineGeometry().headerAvailable) {
- line = renderStatusSurface(m.deps.Theme, lineSurface, m.statusLineGeometry().headerAvailable, false)
- } else if m.deps.DebugTarget == "" && m.deps.StatusSource != nil {
- line = ""
}
- header := m.deps.Theme.Style("header").Width(m.widthOr()).Render(m.appendDebugPrivacyNotice(line))
- return header
+ return m.deps.Theme.Style("header").Width(m.widthOr()).Render(m.appendDebugPrivacyNotice(line))
}
// appendDebugPrivacyNotice keeps the debugger's consent disclosure inside the
@@ -206,11 +175,7 @@ func (m Model) appendDebugPrivacyNotice(line string) string {
}
func (m Model) debugHeaderTarget() string {
- return m.deps.Theme.Style("warning").Bold(true).Render("DEBUG target " + client.SessionHandle(m.deps.DebugTarget))
-}
-
-func (m Model) debugEssentialHeaderParts(sid string) []string {
- return []string{"mecatui", m.debugHeaderTarget(), "session " + sid}
+ return m.deps.Theme.Style("warning").Bold(true).Render("⚠ DEBUG target " + client.SessionHandle(m.deps.DebugTarget))
}
// Operator-posture tier names (the m.caps.Posture vocabulary, server-wide). Named
@@ -244,7 +209,7 @@ const yoloBoltPrefix = "⚡️ "
// postureBadgeRender builds the right-aligned operator-posture chrome badge — the STYLED
// string ready to drop into the header AND its visible (plain) cell width for the
// fit/shed math. It surfaces the SERVER-WIDE automation posture (m.caps.Posture) for the
-// allow-all tiers ONLY — strict/trusted/unknown render NO badge (present=false), so the
+// allow-all tiers ONLY — strict/trusted/unknown render NO badge, so the
// steady-state frame and the goldens stay byte-identical. It is DISTINCT from the
// per-session `mode` segment.
//
@@ -258,26 +223,26 @@ const yoloBoltPrefix = "⚡️ "
// Padding(0,1) adds 2 cells lipgloss.Width(yoloPillText) does count (the text already
// carries its spaces), so the pill's visible width is lipgloss.Width(yoloPillText)+2; the
// optional bolt prefix adds lipgloss.Width(yoloBoltPrefix) (⚡️ width-2 + space).
-func (m Model) postureBadgeRender() (styled string, plainWidth int, present bool) {
+func (m Model) postureBadgeRender() (styled string, plainWidth int) {
switch m.caps.Posture {
case postureAuto:
- return m.deps.Theme.Style("warning").Render(autoBadgeText), lipgloss.Width(autoBadgeText), true
+ return m.deps.Theme.Style("warning").Render(autoBadgeText), lipgloss.Width(autoBadgeText)
case postureYolo:
pill := m.deps.Theme.Style("dangerPill").Render(yoloPillText)
// The pill's visible width = its text cells + the 2 padding cells the style adds.
w := lipgloss.Width(yoloPillText) + 2
if m.emojiOK {
// PLAIN bolt prefix (no style) so VTE shows its natural emoji colour.
- return yoloBoltPrefix + pill, lipgloss.Width(yoloBoltPrefix) + w, true
+ return yoloBoltPrefix + pill, lipgloss.Width(yoloBoltPrefix) + w
}
- return pill, w, true
+ return pill, w
case postureStrict, postureTrusted:
// The non-allow-all tiers carry no badge — the steady-state frame stays
// byte-identical (the goldens are captured at strict).
- return "", 0, false
+ return "", 0
default:
// Unknown / older-server posture: no badge.
- return "", 0, false
+ return "", 0
}
}
@@ -313,87 +278,6 @@ func (m Model) headerModelLabel() string {
return m.deps.Model
}
-// headerIdentityPad is the slack subtracted from the header width when deciding
-// whether the next: badge fits: the header's 1-cell padding each side (2) plus the
-// fitHeader gap+indicator headroom, so the badge is dropped a touch EARLY rather than
-// fighting the right-aligned indicator for the last cells.
-const headerIdentityPad = 4
-
-// headerIdentityParts builds the header identity segments. withNext is the next:
-// badge ("" to omit it). Order: mecatui · [DEBUG target] · session · model ·
-// [next: …] · mode · ws: · socket. The debug target is immutable,
-// always uses its complete fixed handle, and precedes the debugger session identity.
-func (m Model) headerIdentityParts(sid, withNext string) []string {
- parts := []string{"mecatui"}
- if m.deps.DebugTarget != "" {
- parts = append(parts, m.debugHeaderTarget())
- }
- parts = append(parts, "session "+sid)
- // Model segment: the EFFECTIVE model the server resolved THIS session to (set once
- // on SessionReadyMsg). The header only CHOOSES which known string to display; it
- // never resolves a default itself. While connecting there is NO model segment.
- if name := m.headerModelLabel(); name != "" {
- seg := truncate(terminaltext.Sanitize(name), maxModelLen)
- // Downstream-provider suffix (issue #480): when the serving provider routed
- // this session's latest turn to a DOWNSTREAM provider (openrouter today), append
- // "/ " so the operator sees e.g. "Kimi K3/Google". Shown ONLY when a route
- // has actually been reported this session (m.providerRoute non-empty) — empty on
- // a cache hit, before the first turn, and for any non-routed provider, so the
- // bare model segment shows with no stale/fabricated suffix.
- if m.providerRoute != "" {
- seg += "/" + terminaltext.Sanitize(m.providerRoute)
- }
- // Reasoning-effort suffix (ADR 0055): the EFFECTIVE effort the server resolved
- // THIS session to, appended as a subtle ` · ` so it rides WITH the model
- // segment (and sheds with it under width pressure). Shown ONLY when non-empty
- // (auto/unset echoes "" and so never renders).
- if eff := effortHeaderSuffix(m.resolvedSessionModel.ReasoningEffort); eff != "" {
- seg += " · " + eff
- }
- parts = append(parts, seg)
- }
- // ToolHive gateway disclosure (issue #262, R6.3): a persistent, muted "via
- // ToolHive gateway" segment whenever the ACTIVE session's provider is
- // toolhive — disclosure-only (no acknowledgment required), riding the same
- // segment slice so the EXISTING width-shedding/fitHeader math applies
- // unchanged (it sheds like any other low-priority segment under pressure).
- if isToolhiveProviderID(m.resolvedSessionModel.ProviderID) {
- parts = append(parts, m.deps.Theme.Style("muted").Render("via ToolHive gateway"))
- } else if row, ok := availableNotDefaultStatus(m.modelCatalog.statuses); ok {
- // Sibling (N1): when an intent-driven provider is detected-and-reachable
- // but NOT the active default, show a muted " gateway
- // available" segment. Mutually exclusive with the active-case branch
- // above by construction: availableNotDefaultStatus is false when the
- // gateway IS the default, so the two never both render. Vendor-neutral —
- // the provider id comes from the status row, not a hardcoded "toolhive".
- parts = append(parts, m.deps.Theme.Style("muted").
- Render(terminaltext.Sanitize(row.ProviderID)+" gateway available"))
- }
- if withNext != "" {
- parts = append(parts, withNext)
- }
- mode := m.activeMode
- if mode == "" {
- mode = m.deps.Mode
- }
- if m.pendingMode != "" {
- mode = m.pendingMode + " pending"
- }
- // Mode segment: the active/pending permission mode is colour-coded as the
- // persistent visual cue for the same state the input box advertises.
- if mode != "" {
- parts = append(parts, m.renderHeaderMode(mode))
- }
- // Placement metadata is display-only; never derive or expose a server path.
- if label := m.activePlacement.Label; label != "" {
- parts = append(parts, m.deps.Theme.Style("muted").Render("place:"+terminaltext.Sanitize(label)))
- }
- if m.deps.Server != "" {
- parts = append(parts, m.deps.Server)
- }
- return parts
-}
-
func (m Model) inputMode() string {
return client.ModeString(client.ModeFromString(m.desiredMode()))
}
@@ -417,39 +301,6 @@ func (m *Model) applyModeInputStyle() {
m.prompt.SetColors(accent.GetForeground(), m.deps.Theme.Color("bgPanel"), m.deps.Theme.Color("text"))
}
-func (m Model) renderHeaderMode(mode string) string {
- clean := terminaltext.Sanitize(mode)
- return "mode " + modeAccentStyle(m.deps.Theme, clean).Render(clean)
-}
-
-// headerNextBadge is the muted "next: " header badge previewing the
-// pendingNext (apply-on-next-create) selection. It shows ONLY when there is a KNOWN
-// resolved session model to contrast against (m.resolvedSessionModel set) AND the pendingNext
-// resolves to a DIFFERENT (provider, model). Both guards matter: when no resolved
-// session model is known yet (connecting / older server) the model SEGMENT already shows the
-// pendingNext, so a "next:" badge would just duplicate it; and a same-model next is
-// nothing to preview. The display name is resolved from the ListModels inventory by
-// (provider_id, model_id), falling back to the raw id — the same lookup the
-// resolved-session-model label uses. Returns "" when there is no distinct next model.
-func (m Model) headerNextBadge() string {
- next := m.createModelSelection
- eff := m.resolvedSessionModel
- if next.ModelID == "" || eff.ModelID == "" {
- return ""
- }
- if next.ModelID == eff.ModelID && next.ProviderID == eff.ProviderID {
- return ""
- }
- label := next.ModelID
- for _, mi := range m.modelCatalog.models {
- if mi.ProviderID == next.ProviderID && mi.ID == next.ModelID && mi.DisplayName != "" {
- label = mi.DisplayName
- break
- }
- }
- return "next: " + truncate(terminaltext.Sanitize(label), maxModelLen)
-}
-
// scrollIndicator returns the muted "↑ NN%" header cue shown only when the view
// is anchored rather than following the tail.
func (m Model) scrollIndicator() string {
@@ -496,6 +347,9 @@ func (m Model) statusHeaderAvailable(badge string, badgeW int, tail string) int
if lanes > 0 {
lanes += headerGapPad
}
+ if m.deps.DebugTarget != "" {
+ lanes += lipgloss.Width(m.debugHeaderTarget()) + 5 // separator before generated identity
+ }
return max(0, m.widthOr()-2-lanes)
}
@@ -627,18 +481,11 @@ func (m Model) renderFooter() string {
}
width := m.widthOr()
+ line := left
available := m.statusLineGeometry().footerAvailable
- if surface := m.generatedStatusLine.Footer; surface.Present && statusSurfaceFits(surface, available) {
- custom := renderStatusSurface(m.deps.Theme, surface, available, true)
- line := left + strings.Repeat(" ", footerGapPad) + custom
- footer := m.deps.Theme.Style("footer").Width(width).Render(line)
- return footer + "\n" + m.deps.Theme.Style("muted").Render(help)
+ if surface := m.generatedStatusLine.Footer; m.deps.StatusSource != nil && surface.Present && statusSurfaceFits(surface, available) && statusSpansText(surface.Spans) != "" {
+ line += strings.Repeat(" ", footerGapPad) + renderStatusSurface(m.deps.Theme, surface, available, true)
}
- if m.deps.StatusSource != nil {
- footer := m.deps.Theme.Style("footer").Width(width).Render(left)
- return footer + "\n" + m.deps.Theme.Style("muted").Render(help)
- }
- line := m.fitFooter(left, width)
footer := m.deps.Theme.Style("footer").Width(width).Render(line)
return footer + "\n" + m.deps.Theme.Style("muted").Render(help)
}
@@ -702,145 +549,6 @@ func (m Model) selectionStatus() string {
// right-aligned usage segment so they never touch.
const footerGapPad = 2
-// contextWindow returns the denominator for the footer context meter:
-//
-// 1. m.resolvedSessionModel.ContextWindow > 0 — the window the SERVER resolved for THIS
-// session's model (echoed on SessionReadyMsg, refreshed on every model switch /
-// GetSession). It is now LIVE-FIRST server-side, so a live-only model heals to
-// its real window. Exact operator configuration precedes live metadata; the global
-// escape hatch is mecatui embedded mode's (or an external mecated's)
-// --context-window-override, which moves this echoed denominator and the engine
-// trigger together. The client never recomputes the window.
-// 2. 0 — unknown; the meter renderers degrade to the bare "ctx " current size.
-func (m Model) contextWindow() int64 {
- if m.resolvedSessionModel.ContextWindow > 0 {
- return m.resolvedSessionModel.ContextWindow
- }
- return 0
-}
-
-// fitFooter right-aligns the richest usage segment that fits beside the left
-// status, shedding facets before the context signal — context % is the single
-// most valuable signal, so it survives longest. When a team is LIVE a team-summary
-// segment is PREPENDED to the right side; it is LOWER priority than the context
-// meter (it's an advertisement, context % is the headline safety signal), so it is
-// the FIRST thing dropped as width tightens. The chord below is the LIVE
-// Agents binding (issue #457) — "f6" by default, rebound via keymap. Tiers,
-// richest to poorest (defaults shown):
-//
-// "⟳ team-x · 2/3 working · f6 agents ctx ▒▒▒▒▒·· 70% · 140K/200K · ↑7.9K ↓345 cache 88%"
-// "⟳ team-x · 2/3 working · f6 agents ctx ▒▒▒▒▒·· 70% · 140K/200K"
-// "⟳ 2/3 working · f6 ctx ▒▒▒▒▒·· 70%" (team→medium, ctx→compact)
-// "⟳ 2/3 ctx 70%" (team→compact, ctx→minimal)
-// "ctx 70%" (team DROPPED, ctx wins)
-// then the existing ctx-only fallbacks, then left status alone.
-//
-// When no team is live the team segment is empty and the candidate list collapses
-// to EXACTLY the historical ctx-only list — keeping the no-team footer
-// byte-identical (existing goldens unaffected).
-//
-// When the window is unknown every meter tier collapses to "ctx 7.9K", so the
-// tiers naturally narrow to just that, then to nothing.
-func (m Model) fitFooter(left string, width int) string {
- th := m.deps.Theme
- window := m.contextWindow()
- contextKnown := !m.contextUnknown || m.contextTokens > 0
- meter := renderContextMeterState(th, m.contextTokens, window, contextKnown, m.contextEstimated)
- meterCompact := renderContextMeterCompact(th, m.contextTokens, window)
- meterMinimal := renderContextMeterMinimal(th, m.contextTokens, window)
- if !contextKnown {
- meterCompact, meterMinimal = meter, meter
- } else if m.contextEstimated {
- meterCompact = "ctx ~" + strings.TrimPrefix(meterCompact, "ctx ")
- meterMinimal = "ctx ~" + strings.TrimPrefix(meterMinimal, "ctx ")
- }
-
- // The agents prefix is the combined team + subagent-fleet advertisement, prepended
- // to the right side at three tiers (full/medium/compact). Each is built from up to
- // two sub-segments joined by sep:
- // - the team segment, non-empty ONLY for a LIVE team (liveTeamBlock — not teamDone).
- // This DELIBERATELY differs from the f6 overlay's gate: the footer is a
- // live-activity advertisement and hides once the team is done, whereas the
- // overlay opens on the last-seen team done-or-not (so the user can still review a
- // finished roster). The two are meant to disagree in the done state — don't unify.
- // - the fleet segment, non-empty whenever ≥1 subagent has STARTED this session
- // (hasSubagents). Unlike the team segment this stays visible after the children
- // finish (the "3◐ 1✓" counts still inform), matching the F2 "3/4 done" cue.
- const sep = " " // gap between the agents prefix and the ctx segment, and between sub-segments
- // agentsMark is the LIVE Agents chord (issue #457) so the footer advertisement
- // reflects a rebound open-overlay key.
- agentsMark := m.helpKeyMarkings().agents
- var teamFull, teamMedium, teamCompact string
- if b := m.conv.liveTeamBlock(); b != nil {
- working, total := teamWorkingCounts(b.teamLanes)
- teamFull = teamFooterFull(th, b.teamID, working, total, agentsMark)
- teamMedium = th.Style("spinner").Render(teamFooterMedium(b.teamID, working, total, agentsMark))
- teamCompact = th.Style("spinner").Render(teamFooterCompact(working, total))
- }
- var subFull, subMedium, subCompact string
- if m.conv.hasSubagents() {
- running, done := m.conv.subagentFleetCounts()
- subFull = subagentFooterFull(th, running, done, agentsMark)
- subMedium = th.Style("spinner").Render(subagentFooterMedium(running, done, agentsMark))
- subCompact = th.Style("spinner").Render(subagentFooterCompact(running, done))
- }
- // The Parallel segment, non-empty whenever ≥1 Parallel run has STARTED this session
- // (hasParallel) — like the fleet segment it stays visible after the run finishes (the
- // "1◐ 2✓" counts still inform), matching the subagent-fleet footer behaviour.
- var parFull, parMedium, parCompact string
- if m.conv.hasParallel() {
- running, done := m.conv.parallelGroupCounts()
- parFull = parallelFooterFull(th, running, done, agentsMark)
- parMedium = th.Style("spinner").Render(parallelFooterMedium(running, done, agentsMark))
- parCompact = th.Style("spinner").Render(parallelFooterCompact(running, done))
- }
- agentsFull := joinSeg(sep, teamFull, parFull, subFull)
- agentsMedium := joinSeg(sep, teamMedium, parMedium, subMedium)
- agentsCompact := joinSeg(sep, teamCompact, parCompact, subCompact)
-
- var candidates []string
- if agentsFull != "" {
- // Richest-to-poorest cross-product. The agents prefix sheds before the ctx
- // meter: the last agents-bearing tier (compact + ctx-minimal) is followed by the
- // agents-LESS ctx-minimal so context wins when width is tight.
- candidates = append(candidates,
- agentsFull+sep+meter+" · "+renderUsageFacets(m.usage),
- agentsFull+sep+meter,
- agentsMedium+sep+meterCompact,
- agentsCompact+sep+meterMinimal,
- )
- }
- candidates = append(candidates,
- meter+" · "+renderUsageFacets(m.usage),
- meter,
- meterCompact,
- meterMinimal,
- )
- leftW := lipgloss.Width(left)
- for _, seg := range candidates {
- gap := width - leftW - lipgloss.Width(seg) - footerGapPad
- if gap >= 0 {
- return left + strings.Repeat(" ", gap) + seg
- }
- }
- return left
-}
-
-// joinSeg joins the non-empty segments with sep, so a footer prefix built from up to
-// two optional sub-segments (team + subagent fleet) collapses cleanly: with neither
-// it is "", with one it is that segment alone (no leading/trailing sep), with both it
-// is "". This keeps the no-agents footer byte-identical to the historical
-// ctx-only footer (agentsFull == "" drops the whole prefix branch).
-func joinSeg(sep string, segs ...string) string {
- parts := make([]string, 0, len(segs))
- for _, s := range segs {
- if s != "" {
- parts = append(parts, s)
- }
- }
- return strings.Join(parts, sep)
-}
-
// queuePreviewLimit is the number of staged follow-ups previewed in the queue
// card; the rest are summarised as a "+K more" line so a deep queue stays compact.
const queuePreviewLimit = 3
@@ -897,7 +605,7 @@ func (m Model) renderQueue() string {
muted := th.Style("muted")
var b strings.Builder
if m.queuePaused != "" {
- reason, _ := stopReasonLabel(m.queuePaused)
+ reason, _ := renderfmt.StopReasonLabel(m.queuePaused)
b.WriteString(th.Style("ctxWarn").Render(fmt.Sprintf("⏸ %d queued · paused: %s", n, reason)))
} else {
// The edit hint only applies when EditBack is actionable, which
@@ -1122,10 +830,6 @@ func (m Model) renderFatalAtHeight(height int) string {
return card
}
-// maxModelLen caps the model name shown in the header so a long provider-scoped
-// id (e.g. "anthropic/claude-opus-4-...") can't blow out the header width.
-const maxModelLen = 24
-
// effortHeaderSuffix returns the reasoning-effort token to show beside the model in
// the header, or "" when nothing should render (ADR 0055). It hides the unset state
// honestly: the server echoes "" for an unset/auto effort, and an explicit "auto"
diff --git a/cmd/mecatui/ui/zerostate_test.go b/cmd/mecatui/ui/zerostate_test.go
index 7e1f93b6eb..33fc8917c6 100644
--- a/cmd/mecatui/ui/zerostate_test.go
+++ b/cmd/mecatui/ui/zerostate_test.go
@@ -44,7 +44,7 @@ func zeroStateModel(t *testing.T, caps client.Capabilities) Model {
// offers "?", "/" (built-ins always exist), "f6" (teams on) and "ctrl+t",
// and notes that memory is on.
func TestZeroStateEmbeddedGolden(t *testing.T) {
- m := zeroStateModel(t, embeddedCaps())
+ m := goldenStatusFrame(t, zeroStateModel(t, embeddedCaps()))
if !m.conv.isEmpty() {
t.Fatal("conversation should be empty for the zero-state")
}
@@ -55,7 +55,7 @@ func TestZeroStateEmbeddedGolden(t *testing.T) {
// TestZeroStateAllOnGolden locks the welcome card under an all-on server: it adds
// the "/" line (commands on).
func TestZeroStateAllOnGolden(t *testing.T) {
- m := zeroStateModel(t, allOnCaps())
+ m := goldenStatusFrame(t, zeroStateModel(t, allOnCaps()))
got := stripANSI([]byte(m.View().Content))
compareGolden(t, "zerostate_all_on.golden", got)
}
diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md
index 512f4431cf..cfbb16b67a 100644
--- a/docs/acceptance/README.md
+++ b/docs/acceptance/README.md
@@ -316,7 +316,8 @@ PR after verification. There is no cleanup or status-only PR.
Status: draft.
- [Mecatui canonical status surfaces](mecatui-canonical-status-surfaces.md) — removes
duplicate no-source status generators, preserves minimal session identity and
- mandatory debug chrome, and makes stock context readings truthful. Status: proposed.
+ mandatory debug chrome, and makes stock context readings truthful. Status: landed
+ in this implementation candidate; authoritative on merge.
- [Mecatui diagnostic-log retention](mecatui-diaglog-retention.md) — startup
retention of a bounded recent tail for the default and override diagnostic
sinks, with atomic failure preservation and fail-closed path handling. Status:
diff --git a/docs/acceptance/mecatui-canonical-status-surfaces.md b/docs/acceptance/mecatui-canonical-status-surfaces.md
index 15d7556204..1b6b32522c 100644
--- a/docs/acceptance/mecatui-canonical-status-surfaces.md
+++ b/docs/acceptance/mecatui-canonical-status-surfaces.md
@@ -4,7 +4,7 @@
**Work classification:** Bounded — removing duplicate presentation paths also changes the no-source UI and composes debug safety chrome with generated header content; these observable choices need an acceptance contract but do not change the status-source boundary.
**Decision record:** None — preserve the renderer/source ownership recorded in historical [ADR 0247](../adr/0247-mecatui-status-line.md) and the current [ADR 0289 command boundary](../adr/0289-hardened-status-command-boundary.md); neither changes, so no new durable architecture decision is needed.
**Phase:** mecatui presentation cleanup
-**Status:** proposed, 2026-10-03. Operator chose a minimal no-source identity, source-backed ordinary status, and mandatory debug warnings.
+**Status:** landed, 2026-10-04. Implementation candidate verified; authoritative when its PR merges.
**Delivery:** Split. The no-source and debug behavior plus removal of old tests deserve a separate behavioral review before implementation.
**Expected tasks:** deferred to orchestration.
diff --git a/user-docs/mecatui/status-line.md b/user-docs/mecatui/status-line.md
index 002d3cca34..c0b909710b 100644
--- a/user-docs/mecatui/status-line.md
+++ b/user-docs/mecatui/status-line.md
@@ -19,7 +19,9 @@ With no `status_customization:` entry, `mecatui` uses its shipped responsive
templates. The header includes the active session title at every width and the
remote target in its full variant. Keyboard help, the header
posture/scroll/changed-file indicators, and the footer activity lane remain part
-of the client interface; customization cannot remove them.
+of the client interface; customization cannot remove them. In a debug session,
+the header also keeps a `⚠ DEBUG target` cue ahead of generated content and a
+privacy disclosure below it, even if a custom header is empty.
## Choose a source
@@ -96,6 +98,11 @@ that matches the status template variant:
- `contextMeterCompact .Context` for `compact`
- `contextMeterMinimal .Context` for `minimal`
+All three functions display `ctx ?` when occupancy is unknown instead of
+presenting a zero-percent pressure reading. When the occupancy is estimated,
+they mark the percentage with `~`. The full variant also shows the known context
+window when occupancy is unknown.
+
For example, this footer uses the corresponding meter at each width:
```yaml