From ff97a03458f4b9a910f509c1279a20f50a9caa84 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 20:42:30 -0700 Subject: [PATCH 01/17] fix(mecatui): mark uncertain context status Co-authored-by: Mecatl --- cmd/mecatui/customization/defaults_test.go | 70 ++++++++++++++++++++++ cmd/mecatui/customization/template.go | 34 +++++++---- cmd/mecatui/ui/statusline_test.go | 2 +- 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/cmd/mecatui/customization/defaults_test.go b/cmd/mecatui/customization/defaults_test.go index 19823345a1..8ffd7f8bae 100644 --- a/cmd/mecatui/customization/defaults_test.go +++ b/cmd/mecatui/customization/defaults_test.go @@ -118,6 +118,76 @@ func TestDefaultTitleTemplateElidesWideSessionTitle(t *testing.T) { } } +func TestCanonicalStatus_Scenario3_UnknownContextDoesNotClaimZeroPressure(t *testing.T) { + for _, tc := range []struct { + name, want string + meter func(templateContext) templateText + }{ + {name: "full", want: "ctx ?/200K", meter: contextMeter}, + {name: "compact", want: "ctx ?", meter: contextMeterCompact}, + {name: "minimal", want: "ctx ?", meter: contextMeterMinimal}, + } { + t.Run(tc.name, func(t *testing.T) { + footer := stockContextFooter(tc.meter, Context{ + Used: ContextAtom{Human: "?"}, + Window: ContextAtom{Raw: 200_000, Human: "200K"}, + }) + if got := statusSurfaceText(footer); got != tc.want { + t.Fatalf("footer = %q, want unknown context %q", got, tc.want) + } + 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 + meter func(templateContext) templateText + }{ + {name: "full", meter: contextMeter}, + {name: "compact", meter: contextMeterCompact}, + {name: "minimal", meter: contextMeterMinimal}, + } { + t.Run(tc.name, func(t *testing.T) { + estimated := stockContextFooter(tc.meter, Context{ + Used: ContextAtom{Raw: 75_000, Human: "~75K"}, + Window: ContextAtom{Raw: 100_000, Human: "100K"}, + Percent: 75, + Known: true, + Estimated: true, + }) + if text := statusSurfaceText(estimated); !strings.Contains(text, "~75%") { + t.Fatalf("estimated footer = %q, want visibly estimated percentage", text) + } + + known := stockContextFooter(tc.meter, Context{ + Used: ContextAtom{Raw: 90_000, Human: "90K"}, + Window: ContextAtom{Raw: 100_000, Human: "100K"}, + Percent: 90, + Known: true, + }) + if text := statusSurfaceText(known); strings.Contains(text, "~90%") || !strings.Contains(text, "90%") || !strings.Contains(text, "⚠") { + t.Fatalf("known footer = %q, want ordinary pressured percentage", text) + } + for _, span := range known.Spans { + if span.Token == TokenWarning { + return + } + } + t.Fatalf("known footer = %#v, want warning pressure token", known) + }) + } +} + +func stockContextFooter(meter func(templateContext) templateText, statusContext Context) Surface { + return Render("
"+string(meter(newTemplateInput(Input{Context: statusContext}).Context))+"
", testPalette{}).Footer +} + func TestDefaultStatusHeadersElideSessionTitleByVariant(t *testing.T) { const title = "界界界界界界界界界界界界界界界界界界界界" for _, tc := range []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/statusline_test.go b/cmd/mecatui/ui/statusline_test.go index b381a8870c..33f253521d 100644 --- a/cmd/mecatui/ui/statusline_test.go +++ b/cmd/mecatui/ui/statusline_test.go @@ -133,7 +133,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 { From 8ab74c5d4cb06e6998c736d61627339b442442c3 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 20:42:57 -0700 Subject: [PATCH 02/17] docs(plan): mark canonical status implementation in progress Co-authored-by: Mecatl --- docs/acceptance/mecatui-canonical-status-surfaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/mecatui-canonical-status-surfaces.md b/docs/acceptance/mecatui-canonical-status-surfaces.md index 15d7556204..b127fa3f60 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:** in-progress, 2026-10-04. Approved by merged Plan / Interface PR #2070; implementation underway. **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. From 3c9fb91a501fffc86c6baa94cf2ff54de67b337c Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 20:56:40 -0700 Subject: [PATCH 03/17] test(mecatui): add stock status golden fixture Co-authored-by: Mecatl --- .../ui/testdata/view_aztec_ansi.golden | 4 +-- cmd/mecatui/ui/testdata/view_stripped.golden | 2 +- cmd/mecatui/ui/ui_test.go | 36 +++++++++++++++++-- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/cmd/mecatui/ui/testdata/view_aztec_ansi.golden b/cmd/mecatui/ui/testdata/view_aztec_ansi.golden index 45d47c2abf..a49689630e 100644 --- a/cmd/mecatui/ui/testdata/view_aztec_ansi.golden +++ b/cmd/mecatui/ui/testdata/view_aztec_ansi.golden @@ -1,4 +1,4 @@ - mecatui · session sess-test-00 · mock-model · mode default · 127.0.0.1:8080 + mecatui · mock-model · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │    │    ──────────────────────────────────────────────────────────────────────────────────────────────────── - ⚠ awaiting approval ctx 1.2K · ↑0 ↓0 cache 0% + ⚠ awaiting approval ctx 1.2K · ↑0 ↓0 cache 0% ? help · / commands · ctrl+c quit \ 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..1db8c3e3db 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 · mock-model · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ diff --git a/cmd/mecatui/ui/ui_test.go b/cmd/mecatui/ui/ui_test.go index 3d8ab84fb4..30a8dd3132 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" ) @@ -434,6 +435,19 @@ 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() + 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())) + return updated.(Model) +} + // 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 +474,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 := stockStatusFrame(t, driveTo(t, theme.New("aztec", theme.AztecPalette()))) got := stripANSI([]byte(m.View().Content)) compareGolden(t, "view_stripped.golden", got) } @@ -471,7 +503,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 := stockStatusFrame(t, driveTo(t, theme.New("aztec", theme.AztecPalette()))) got := []byte(m.View().Content) compareGolden(t, "view_aztec_ansi.golden", got) } From 79dfbf8099c7ef53bf4fc95748e3871841e17d0b Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 21:03:42 -0700 Subject: [PATCH 04/17] test(mecatui): retire legacy footer coverage Co-authored-by: Mecatl --- cmd/mecatui/ui/agents_overlay_test.go | 240 ------------------ cmd/mecatui/ui/background_subagent_test.go | 24 -- cmd/mecatui/ui/capabilities_test.go | 49 ---- cmd/mecatui/ui/footer_bench_test.go | 121 --------- cmd/mecatui/ui/footer_heal_test.go | 54 ++-- cmd/mecatui/ui/footer_test.go | 153 ----------- cmd/mecatui/ui/integration_test.go | 172 ------------- cmd/mecatui/ui/keymark_liveness_test.go | 155 ----------- .../resumable_session_status_metrics_test.go | 56 ---- .../ui/testdata/footer_ctx_meter.golden | 1 - .../ui/testdata/footer_ctx_unknown.golden | 1 - cmd/mecatui/ui/testdata/footer_fleet.golden | 8 - .../ui/testdata/footer_parallel.golden | 8 - 13 files changed, 16 insertions(+), 1026 deletions(-) delete mode 100644 cmd/mecatui/ui/footer_bench_test.go delete mode 100644 cmd/mecatui/ui/testdata/footer_ctx_meter.golden delete mode 100644 cmd/mecatui/ui/testdata/footer_ctx_unknown.golden delete mode 100644 cmd/mecatui/ui/testdata/footer_fleet.golden delete mode 100644 cmd/mecatui/ui/testdata/footer_parallel.golden diff --git a/cmd/mecatui/ui/agents_overlay_test.go b/cmd/mecatui/ui/agents_overlay_test.go index 36875ba10a..d4e0cbc37f 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 @@ -1560,43 +1355,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/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/capabilities_test.go b/cmd/mecatui/ui/capabilities_test.go index 72bfcb2c1e..b3acd56ccf 100644 --- a/cmd/mecatui/ui/capabilities_test.go +++ b/cmd/mecatui/ui/capabilities_test.go @@ -154,59 +154,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/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..8aa0a0a138 100644 --- a/cmd/mecatui/ui/footer_heal_test.go +++ b/cmd/mecatui/ui/footer_heal_test.go @@ -1,7 +1,6 @@ package ui import ( - "strings" "testing" tea "charm.land/bubbletea/v2" @@ -39,9 +38,6 @@ 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 @@ -58,11 +54,8 @@ func TestFooterHealRaceThenHeal(t *testing.T) { m := healModel(t, conv) // 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 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 got := m.resolvedSessionModel.ContextWindow; got != 0 { + t.Fatalf("resolved context window = %d, want 0 (raced create echoed a 0 window)", got) } // 2) A turn boundary fires the heal refetch. Drive the REAL reducer with a @@ -81,21 +74,15 @@ func TestFooterHealRaceThenHeal(t *testing.T) { t.Fatalf("GetSession called %d times after one turn-end with unknown window, want 1", n) } - // 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 back through the reducer and retain the resolved window. 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)) + status := m.statusLineSnapshot() + if status.Context.Window.Raw != 1_050_000 || status.Context.Used.Raw != 40_000 || !status.Context.Known { + t.Fatalf("status snapshot = %#v, want healed known context", status.Context) } // 4) Identity untouched (only the denominator self-corrected). @@ -130,11 +117,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 +141,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 +150,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 +193,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 +219,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 +233,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 +250,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..f261dd6110 100644 --- a/cmd/mecatui/ui/footer_test.go +++ b/cmd/mecatui/ui/footer_test.go @@ -73,95 +73,6 @@ func TestPctString(t *testing.T) { } } -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, @@ -485,67 +396,3 @@ func TestFooterCountsIdleAsNotWorking(t *testing.T) { 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/integration_test.go b/cmd/mecatui/ui/integration_test.go index afdd1b3fad..7d673e4e59 100644 --- a/cmd/mecatui/ui/integration_test.go +++ b/cmd/mecatui/ui/integration_test.go @@ -15,58 +15,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 @@ -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 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/resumable_session_status_metrics_test.go b/cmd/mecatui/ui/resumable_session_status_metrics_test.go index e377a2dc67..e5253c90e7 100644 --- a/cmd/mecatui/ui/resumable_session_status_metrics_test.go +++ b/cmd/mecatui/ui/resumable_session_status_metrics_test.go @@ -1,40 +1,12 @@ package ui import ( - "strings" "testing" "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/theme" ) -func TestResumableSessionStatusMetrics_Scenario3_StartupResumeRestoresStatus(t *testing.T) { - resume := &client.ResumeSelection{ - Row: client.SessionListItem{ID: "resumed-main", Title: "Restored chat"}, - Snapshot: client.SessionSnapshot{ - ResolvedModel: client.ResolvedModel{ContextWindow: 200_000}, - Usage: client.Usage{InputTokens: 120_000, OutputTokens: 4_000, CacheReadTokens: 90_000}, - ContextOccupancy: &client.ContextOccupancy{InputTokens: 40_000, Estimated: true}, - }, - } - m := New(Deps{Resume: resume, Theme: theme.New("aztec", theme.AztecPalette()), NoAltScreen: true}) - - 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%"} { - if !strings.Contains(footer, want) { - t.Fatalf("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) - } -} - func TestResumableSessionStatusMetrics_Scenario3_StartupResumeSubmitsSnapshotStatus(t *testing.T) { resume := &client.ResumeSelection{ Row: client.SessionListItem{ID: "resumed-main", Title: "Restored chat"}, @@ -187,31 +159,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/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 From 2240f0d1a09d7f7a6ba475df0f34cc195ef731ab Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 21:07:00 -0700 Subject: [PATCH 05/17] test(mecatui): restore resumable status acceptance proofs Co-authored-by: Mecatl --- .../resumable_session_status_metrics_test.go | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/cmd/mecatui/ui/resumable_session_status_metrics_test.go b/cmd/mecatui/ui/resumable_session_status_metrics_test.go index e5253c90e7..a07ca4a2d3 100644 --- a/cmd/mecatui/ui/resumable_session_status_metrics_test.go +++ b/cmd/mecatui/ui/resumable_session_status_metrics_test.go @@ -1,12 +1,103 @@ package ui import ( + "strings" "testing" + tea "charm.land/bubbletea/v2" + "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/theme" ) +func TestResumableSessionStatusMetrics_Scenario3_StartupResumeRestoresStatus(t *testing.T) { + resume := &client.ResumeSelection{ + Row: client.SessionListItem{ID: "resumed-main", Title: "Restored chat"}, + Snapshot: client.SessionSnapshot{ + ResolvedModel: client.ResolvedModel{ContextWindow: 200_000}, + Usage: client.Usage{InputTokens: 120_000, OutputTokens: 4_000, CacheReadTokens: 90_000}, + ContextOccupancy: &client.ContextOccupancy{InputTokens: 40_000, Estimated: true}, + }, + } + 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) + } + 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("stock footer = %q, want %q before a prompt", footer, want) + } + } + + m = applyAll(m, client.TurnEndMsg{Usage: client.Usage{InputTokens: 50_000}, Estimated: true}) + 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) + } +} + func TestResumableSessionStatusMetrics_Scenario3_StartupResumeSubmitsSnapshotStatus(t *testing.T) { resume := &client.ResumeSelection{ Row: client.SessionListItem{ID: "resumed-main", Title: "Restored chat"}, From a665b147b2da748d519335410986a100e82f54f4 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 21:12:54 -0700 Subject: [PATCH 06/17] test(mecatui): migrate legacy header assertions Co-authored-by: Mecatl --- cmd/mecatui/ui/capabilities_test.go | 22 +++--- cmd/mecatui/ui/effort_test.go | 24 ++---- cmd/mecatui/ui/mode_test.go | 8 +- cmd/mecatui/ui/models_enrich_test.go | 106 +-------------------------- cmd/mecatui/ui/models_test.go | 62 +++++----------- cmd/mecatui/ui/selection_test.go | 14 ++-- 6 files changed, 49 insertions(+), 187 deletions(-) diff --git a/cmd/mecatui/ui/capabilities_test.go b/cmd/mecatui/ui/capabilities_test.go index b3acd56ccf..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) } } diff --git a/cmd/mecatui/ui/effort_test.go b/cmd/mecatui/ui/effort_test.go index b2322f3525..8437bb9430 100644 --- a/cmd/mecatui/ui/effort_test.go +++ b/cmd/mecatui/ui/effort_test.go @@ -533,25 +533,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/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_test.go b/cmd/mecatui/ui/models_test.go index d514802756..c4910a7a81 100644 --- a/cmd/mecatui/ui/models_test.go +++ b/cmd/mecatui/ui/models_test.go @@ -1696,38 +1696,21 @@ 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") + if got := m.statusLineSnapshot().Model.ProviderID; got != "openai" { + t.Fatalf("status snapshot provider = %q, want openai", got) } 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) + if got := m.statusLineSnapshot().Model.ProviderID; got != "toolhive" { + t.Fatalf("status snapshot provider = %q, want toolhive", got) } - - // 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 @@ -1774,28 +1757,23 @@ func TestHeaderGatewayAvailableSegment(t *testing.T) { } } -// 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 +1781,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. 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()) From 1604dc268f1162a97d30f98fa764609c61db15f3 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 21:38:17 -0700 Subject: [PATCH 07/17] feat(mecatui): render canonical status surfaces with immutable debug chrome Co-authored-by: Mecatl --- .../customization/source_correction_test.go | 4 +- .../ui/bounded_scroll_adoption_test.go | 1 + cmd/mecatui/ui/canonical_status_test.go | 196 ++++++++++ cmd/mecatui/ui/debug_session_test.go | 4 +- cmd/mecatui/ui/footer.go | 200 +--------- cmd/mecatui/ui/footer_test.go | 23 -- cmd/mecatui/ui/integration_test.go | 27 +- cmd/mecatui/ui/models_test.go | 44 --- .../ui/palette_bounded_list_scenario_test.go | 4 +- cmd/mecatui/ui/statusline_test.go | 1 + .../ui/testdata/agents_inventory.golden | 4 +- .../agents_inventory_empty_disabled.golden | 4 +- .../agents_inventory_empty_enabled.golden | 4 +- .../testdata/agents_inventory_scroll.golden | 4 +- .../ui/testdata/agents_teams_tab.golden | 4 +- .../testdata/askargs_modal_longshell.golden | 4 +- .../ui/testdata/askargs_view_pretty.golden | 4 +- .../ui/testdata/askargs_view_raw.golden | 4 +- .../ui/testdata/askargs_view_scrolled.golden | 4 +- cmd/mecatui/ui/testdata/effort_picker.golden | 4 +- cmd/mecatui/ui/testdata/help_all_on.golden | 4 +- cmd/mecatui/ui/testdata/help_embedded.golden | 4 +- .../testdata/jev_routing_user_journey.golden | 4 +- cmd/mecatui/ui/testdata/mcp_err_input.golden | 4 +- .../ui/testdata/mcp_err_not_configured.golden | 4 +- cmd/mecatui/ui/testdata/mcp_err_server.golden | 4 +- cmd/mecatui/ui/testdata/mcp_panel.golden | 4 +- .../ui/testdata/mcp_panel_groups.golden | 4 +- .../ui/testdata/mcp_prompt_args.golden | 4 +- cmd/mecatui/ui/testdata/mcp_prompts.golden | 4 +- .../ui/testdata/mcp_resource_preview.golden | 4 +- cmd/mecatui/ui/testdata/mcp_resources.golden | 4 +- cmd/mecatui/ui/testdata/models.golden | 4 +- .../ui/testdata/models_codex_healthy.golden | 4 +- .../testdata/models_codex_unauthorized.golden | 4 +- .../ui/testdata/models_disabled.golden | 4 +- cmd/mecatui/ui/testdata/models_empty.golden | 4 +- .../ui/testdata/models_filtered.golden | 4 +- .../ui/testdata/models_gateway_empty.golden | 4 +- .../ui/testdata/models_global_default.golden | 4 +- .../models_mixed_deployment_empty.golden | 4 +- cmd/mecatui/ui/testdata/models_nomatch.golden | 4 +- .../ui/testdata/models_scrolled.golden | 4 +- .../models_toolhive_unreachable.golden | 4 +- .../ui/testdata/parallel_group_focus.golden | 4 +- .../ui/testdata/parallel_roster.golden | 4 +- .../ui/testdata/paste_placeholder.golden | 4 +- .../testdata/sessions_cleanup_review.golden | 4 +- .../ui/testdata/sessions_picker.golden | 4 +- .../ui/testdata/sessions_picker_empty.golden | 4 +- .../ui/testdata/sessions_picker_error.golden | 4 +- .../testdata/sessions_transcript_error.golden | 4 +- .../sessions_transcript_loading.golden | 4 +- .../sessions_transcript_rendered.golden | 4 +- cmd/mecatui/ui/testdata/skills.golden | 4 +- .../ui/testdata/skills_filtered.golden | 4 +- cmd/mecatui/ui/testdata/skills_nomatch.golden | 4 +- cmd/mecatui/ui/testdata/skills_scroll.golden | 4 +- cmd/mecatui/ui/testdata/soul.golden | 4 +- .../ui/testdata/soul_empty_disabled.golden | 4 +- .../ui/testdata/soul_empty_enabled.golden | 4 +- cmd/mecatui/ui/testdata/soul_project.golden | 4 +- cmd/mecatui/ui/testdata/steer_card.golden | 4 +- cmd/mecatui/ui/testdata/subagent_focus.golden | 4 +- .../testdata/subagent_focus_background.golden | 4 +- .../ui/testdata/subagent_roster.golden | 4 +- .../subagent_roster_background.golden | 4 +- cmd/mecatui/ui/testdata/team_findings.golden | 4 +- cmd/mecatui/ui/testdata/team_focus.golden | 4 +- .../ui/testdata/team_focus_windowed.golden | 4 +- cmd/mecatui/ui/testdata/team_roster.golden | 4 +- .../testdata/team_roster_midrun_idle.golden | 4 +- .../ui/testdata/team_roster_stopped.golden | 4 +- .../ui/testdata/team_roster_windowed.golden | 4 +- cmd/mecatui/ui/testdata/team_tasks.golden | 4 +- cmd/mecatui/ui/testdata/usermodel.golden | 4 +- .../testdata/usermodel_empty_disabled.golden | 4 +- .../testdata/usermodel_empty_enabled.golden | 4 +- .../ui/testdata/zerostate_all_on.golden | 4 +- .../ui/testdata/zerostate_embedded.golden | 4 +- cmd/mecatui/ui/view.go | 343 ++---------------- 81 files changed, 379 insertions(+), 748 deletions(-) create mode 100644 cmd/mecatui/ui/canonical_status_test.go 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/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..22196fe017 --- /dev/null +++ b/cmd/mecatui/ui/canonical_status_test.go @@ -0,0 +1,196 @@ +package ui + +import ( + "context" + "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 _, width := range []int{120, 48} { + source := customization.NewDefaultSource(0) + t.Cleanup(func() { _ = source.Close(context.Background()) }) + m := canonicalFrame(source, 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, "ctx") { + t.Fatalf("width %d stock surfaces: header %q footer %q result %#v", width, header, footer, m.generatedStatusLine) + } + if strings.Count(header, "canonical-model") != 1 || strings.Count(footer, "ctx") != 1 || lipgloss.Width(strings.Split(header, "\n")[0]) > width || lipgloss.Width(strings.Split(footer, "\n")[1]) > width { + t.Fatalf("width %d duplicate or oversized renderer content: %q / %q", 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_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_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/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/footer.go b/cmd/mecatui/ui/footer.go index e1739e9df4..54709c949f 100644 --- a/cmd/mecatui/ui/footer.go +++ b/cmd/mecatui/ui/footer.go @@ -9,42 +9,18 @@ import ( "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. +// Context pressure thresholds and glyphs also serve team roster meters. const ( ctxWarnFraction = 0.60 ctxDangerFraction = 0.85 - ctxBarWidth = 8 // glyph cells in the meter bar + ctxBarWidth = 8 + ctxGlyphOk = "▒" + ctxGlyphWarn = "▓" + ctxGlyphDanger = "█" + ctxGlyphEmpty = "░" + ctxDangerMark = " ⚠" ) -// 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 @@ -134,8 +110,7 @@ func ctxGlyph(frac float64) string { } } -// 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% ⚠"). +// ctxLabel formats the percentage with the non-colour danger cue. func ctxLabel(frac float64) string { label := pctString(frac) if frac >= ctxDangerFraction { @@ -144,40 +119,8 @@ func ctxLabel(frac float64) string { return label } -// renderContextMeter renders the FULL-fidelity context segment for a known -// numerator. renderContextMeterState additionally handles legacy snapshots where -// the numerator is honestly unknown. +// renderContextMeter formats context for the team roster subhead. 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 } @@ -186,21 +129,8 @@ func renderContextMeterCompact(th theme.Theme, used, window int64) string { } 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)) + meter := style.Render(ctxBar(frac) + " " + ctxLabel(frac)) + return "ctx " + meter + " · " + humanizeTokens(used) + "/" + humanizeTokens(window) } // ctxBar builds the meter bar: filled cells use the per-band glyph, the rest the @@ -345,22 +275,6 @@ func stopReasonLabel(stop string) (text, slot string) { } } -// 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 @@ -378,95 +292,3 @@ func teamWorkingCounts(lanes []teamLane) (working, total int) { } 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_test.go b/cmd/mecatui/ui/footer_test.go index f261dd6110..39292e4ed9 100644 --- a/cmd/mecatui/ui/footer_test.go +++ b/cmd/mecatui/ui/footer_test.go @@ -73,29 +73,6 @@ func TestPctString(t *testing.T) { } } -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))) } diff --git a/cmd/mecatui/ui/integration_test.go b/cmd/mecatui/ui/integration_test.go index 7d673e4e59..465e9d9497 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" @@ -53,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) @@ -219,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). diff --git a/cmd/mecatui/ui/models_test.go b/cmd/mecatui/ui/models_test.go index c4910a7a81..04ffaed428 100644 --- a/cmd/mecatui/ui/models_test.go +++ b/cmd/mecatui/ui/models_test.go @@ -1713,50 +1713,6 @@ func TestToolhiveProviderReachesStatusSnapshot(t *testing.T) { } } -// 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) - } - - // 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) - } -} - // TestProviderRouteReachesStatusSnapshot proves routed downstream provider state is // submitted to the selected source and cleared at the next-turn boundary. func TestProviderRouteReachesStatusSnapshot(t *testing.T) { 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/statusline_test.go b/cmd/mecatui/ui/statusline_test.go index 33f253521d..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(`
CUSTOM
`, nil).Header + m.deps.StatusSource = &statusSourceFake{changed: make(chan struct{})} m.caps.Posture = postureAuto m.conv.recordFileChange("changed.go") header := stripANSIstr(m.renderHeader()) diff --git a/cmd/mecatui/ui/testdata/agents_inventory.golden b/cmd/mecatui/ui/testdata/agents_inventory.golden index 0c38a0bc3e..cc70c35486 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..c971b15e6a 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..971f768b24 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..6361e906a0 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..46482b2eec 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⟳ 4/4 working · f6 ⛭ subagents 1◐ 0✓ · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..188187591f 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - ⚠ awaiting approval ctx 0 · ↑0 ↓0 cache 0% + ⚠ awaiting approval ? 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..85d28a0e00 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── 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 ? 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..20a8c56609 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── 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 ? 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..da48ef90c7 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── 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 ? 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..188b4156cb 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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/help_all_on.golden b/cmd/mecatui/ui/testdata/help_all_on.golden index 1ed59100a5..00499f29b8 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..754f4faa3f 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..faa07fe972 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── @@ -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 ? 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..5840746a8f 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..ffadb1e6db 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..e424b566cc 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..58f03c1df6 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..f086e68a98 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..3026507654 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..14288d99ed 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..3eaf8436bd 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..97904c3040 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..c9ead64633 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..8dab30507c 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..3bc7e6513b 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..8c87cf92a7 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..cf875ebe30 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..6cd7a52227 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..806f64e89c 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..eeba09e096 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..b7f276b227 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..59e8e9f216 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..8952c2ca2f 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..665c42bebf 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..700111e303 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⑂ parallel 0◐ 1✓ · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..cba72ab22b 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⑂ parallel 0◐ 1✓ · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..4249eb3bc7 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -76,5 +76,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..fa4ea93bac 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 + ──────────────────────────────────────────────────────────────────────────────────────────────────── 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… ? 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..e4536c4d46 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ▸ 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 ? 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..9014b98fd2 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ▸ Chats Scheduled runs Child runs Other @@ -36,5 +36,5 @@ tab: switch esc: close │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..e371589051 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ▸ Chats Scheduled runs Child runs Other @@ -36,5 +36,5 @@ r: retry esc: close │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..9072ad88a9 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── conversation unavailable @@ -10,5 +10,5 @@ r: Retry esc: Back │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..5573df46c8 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── loading conversation @@ -10,5 +10,5 @@ esc: Back │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..f614fd5833 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── Inspecting Nightly checks · read-only ▌ you @@ -37,5 +37,5 @@ Inspecting Nightly checks · read-only │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..ba43416337 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..101d0ae358 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..3465586bc7 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..d6973e8fe8 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..8840f2a390 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..c48b57782e 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..02dd15ee4f 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..6f9dd53d96 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..4b4fbb4426 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ▌ you │ first @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - ⣾ thinking… ctx 0 · ↑0 ↓0 cache 0% + ⣾ thinking… 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..39aac7f1e1 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⛭ subagents 3◐ 2✓ · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..8cf54b46e2 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -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 ? 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..7cbd503edd 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⛭ subagents 3◐ 2✓ · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..58095e9e16 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -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 ? 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..5d9a2d37e6 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⟳ 2/2 working · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..ae09071376 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⟳ 4/4 working · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..ee0fba089e 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⟳ 1/1 working · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..46482b2eec 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⟳ 4/4 working · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..d9f76e24ff 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⟳ 1/2 working · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..f4d1416a70 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..28e0c4e57d 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⟳ 20/20 working · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..dd8ab62391 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ⟳ 2/2 working · f6 ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..9b57ace0f7 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..2e33eba6e4 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..29be9115d9 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..15a157ac68 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -76,5 +76,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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..15a157ac68 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 + session sess-test-00 ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -76,5 +76,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected ctx 0 · ↑0 ↓0 cache 0% + connected ? 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/view.go b/cmd/mecatui/ui/view.go index 8bef25a593..ea1af03e6c 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -126,68 +126,32 @@ 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 != "" { + 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 +170,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 @@ -313,87 +273,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 +296,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 +342,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 +476,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 +544,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 From 6e816e5ccc2ee6ad9175fc5e75ea40c43483e201 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 21:42:53 -0700 Subject: [PATCH 08/17] fix(mecatui): keep debug session identity and verify command width Co-authored-by: Mecatl --- cmd/mecatui/ui/canonical_status_test.go | 56 +++++++++++++++++++++++++ cmd/mecatui/ui/view.go | 6 ++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/cmd/mecatui/ui/canonical_status_test.go b/cmd/mecatui/ui/canonical_status_test.go index 22196fe017..3736779d6f 100644 --- a/cmd/mecatui/ui/canonical_status_test.go +++ b/cmd/mecatui/ui/canonical_status_test.go @@ -2,6 +2,7 @@ package ui import ( "context" + "fmt" "strings" "testing" @@ -78,6 +79,32 @@ func TestCanonicalStatus_Scenario1_NoSourceMinimalIdentity(t *testing.T) { } } +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) @@ -155,6 +182,35 @@ func TestCanonicalStatus_Scenario2_DebugDisclosureSurvivesMissingSourceAndFatal( } } +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 '
%s
' ;; *) printf '
wrong-budget
' ;; 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) diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index ea1af03e6c..cad37dbb0c 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -133,7 +133,11 @@ func (m Model) renderHeader() string { line = "session " + handle } if m.deps.DebugTarget != "" { - line = m.debugHeaderTarget() + if line != "" { + line = m.debugHeaderTarget() + " · " + line + } else { + line = m.debugHeaderTarget() + } } tail := m.scrollIndicator() if tail == "" { From 37ac1bd7d90e79af0dbb0bfc36a18d5f350da532 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 21:46:19 -0700 Subject: [PATCH 09/17] docs(mecatui): explain canonical status and debug chrome Co-authored-by: Mecatl --- user-docs/mecatui/status-line.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From 76ed0ad33a8e87a0f6e57ee74f33b1d48f2004f6 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 21:56:13 -0700 Subject: [PATCH 10/17] fix(mecatui): remove unused status helper outputs Co-authored-by: Mecatl --- cmd/mecatui/ui/statusline_source.go | 2 +- cmd/mecatui/ui/view.go | 20 ++++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/cmd/mecatui/ui/statusline_source.go b/cmd/mecatui/ui/statusline_source.go index 1d0a7bd051..827bd56ec0 100644 --- a/cmd/mecatui/ui/statusline_source.go +++ b/cmd/mecatui/ui/statusline_source.go @@ -137,7 +137,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/view.go b/cmd/mecatui/ui/view.go index cad37dbb0c..904aa2ad0b 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -143,7 +143,7 @@ func (m Model) renderHeader() string { if tail == "" { tail = m.changedFilesIndicator() } - badge, badgeW, _ := m.postureBadgeRender() + 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) @@ -208,7 +208,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. // @@ -222,26 +222,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 } } @@ -829,10 +829,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" From ea9644e0033cc6183dc908f0f75dacc36f6bbc5b Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 22:17:00 -0700 Subject: [PATCH 11/17] docs(plan): mark canonical status candidate landed Co-authored-by: Mecatl --- docs/acceptance/README.md | 3 ++- docs/acceptance/mecatui-canonical-status-surfaces.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 b127fa3f60..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:** in-progress, 2026-10-04. Approved by merged Plan / Interface PR #2070; implementation underway. +**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. From bd69587004998c454652723c10734712d9500062 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 22:32:05 -0700 Subject: [PATCH 12/17] test(mecatui): strengthen canonical status coverage Co-authored-by: Mecatl --- cmd/mecatui/customization/defaults_test.go | 83 +++++++++++-------- cmd/mecatui/ui/canonical_status_test.go | 39 +++++---- cmd/mecatui/ui/footer_heal_test.go | 23 +++-- .../resumable_session_status_metrics_test.go | 4 +- cmd/mecatui/ui/update.go | 1 + 5 files changed, 95 insertions(+), 55 deletions(-) diff --git a/cmd/mecatui/customization/defaults_test.go b/cmd/mecatui/customization/defaults_test.go index 8ffd7f8bae..7bd509512a 100644 --- a/cmd/mecatui/customization/defaults_test.go +++ b/cmd/mecatui/customization/defaults_test.go @@ -120,20 +120,29 @@ func TestDefaultTitleTemplateElidesWideSessionTitle(t *testing.T) { func TestCanonicalStatus_Scenario3_UnknownContextDoesNotClaimZeroPressure(t *testing.T) { for _, tc := range []struct { - name, want string - meter func(templateContext) templateText + name string + footerCols int + want string + delegation Delegation }{ - {name: "full", want: "ctx ?/200K", meter: contextMeter}, - {name: "compact", want: "ctx ?", meter: contextMeterCompact}, - {name: "minimal", want: "ctx ?", meter: contextMeterMinimal}, + {name: "full", footerCols: 120, want: "ctx ?/200K"}, + {name: "compact", footerCols: 20, want: "ctx ?"}, + {name: "minimal", footerCols: 11, want: "ctx ?", delegation: Delegation{Team: LiveTeam{Total: 1}}}, } { t.Run(tc.name, func(t *testing.T) { - footer := stockContextFooter(tc.meter, Context{ - Used: ContextAtom{Human: "?"}, - Window: ContextAtom{Raw: 200_000, Human: "200K"}, + footer := stockFooter(t, Input{ + Context: Context{Used: ContextAtom{Human: "?"}, Window: ContextAtom{Raw: 200_000, Human: "200K"}}, + Delegation: tc.delegation, + Terminal: Terminal{FooterAvailCols: tc.footerCols}, }) - if got := statusSurfaceText(footer); got != tc.want { - t.Fatalf("footer = %q, want unknown context %q", got, tc.want) + if got := statusSurfaceText(footer); !strings.Contains(got, tc.want) { + t.Fatalf("footer = %q, want unknown context containing %q", got, tc.want) + } + if tc.name == "minimal" && strings.Contains(statusSurfaceText(footer), "⟳") { + t.Fatalf("footer = %q, retained compact delegation content instead of selecting minimal", statusSurfaceText(footer)) + } + if strings.Contains(statusSurfaceText(footer), "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 { @@ -146,46 +155,52 @@ func TestCanonicalStatus_Scenario3_UnknownContextDoesNotClaimZeroPressure(t *tes func TestCanonicalStatus_Scenario3_EstimatedContextMarkedAtEveryWidth(t *testing.T) { for _, tc := range []struct { - name string - meter func(templateContext) templateText + name string + footerCols int }{ - {name: "full", meter: contextMeter}, - {name: "compact", meter: contextMeterCompact}, - {name: "minimal", meter: contextMeterMinimal}, + {name: "full", footerCols: 120}, + {name: "compact", footerCols: 20}, + {name: "minimal", footerCols: 12}, } { t.Run(tc.name, func(t *testing.T) { - estimated := stockContextFooter(tc.meter, Context{ - Used: ContextAtom{Raw: 75_000, Human: "~75K"}, - Window: ContextAtom{Raw: 100_000, Human: "100K"}, - Percent: 75, - Known: true, - Estimated: true, + 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 := stockContextFooter(tc.meter, Context{ - Used: ContextAtom{Raw: 90_000, Human: "90K"}, - Window: ContextAtom{Raw: 100_000, Human: "100K"}, - Percent: 90, - Known: true, + 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%") || !strings.Contains(text, "⚠") { - t.Fatalf("known footer = %q, want ordinary pressured percentage", text) + if text := statusSurfaceText(known); strings.Contains(text, "~90%") || !strings.Contains(text, "90%") { + t.Fatalf("known footer = %q, want ordinary percentage", text) } - for _, span := range known.Spans { - if span.Token == TokenWarning { - return + if tc.name != "minimal" { + for _, span := range known.Spans { + if span.Token == TokenWarning { + return + } } + t.Fatalf("known footer = %#v, want warning pressure token", known) } - t.Fatalf("known footer = %#v, want warning pressure token", known) }) } } -func stockContextFooter(meter func(templateContext) templateText, statusContext Context) Surface { - return Render("
"+string(meter(newTemplateInput(Input{Context: statusContext}).Context))+"
", testPalette{}).Footer +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) { diff --git a/cmd/mecatui/ui/canonical_status_test.go b/cmd/mecatui/ui/canonical_status_test.go index 3736779d6f..65ba604561 100644 --- a/cmd/mecatui/ui/canonical_status_test.go +++ b/cmd/mecatui/ui/canonical_status_test.go @@ -26,20 +26,31 @@ func canonicalFrame(source customization.Source, width int) Model { } func TestCanonicalStatus_Scenario1_StockSourceOwnsSurfaces(t *testing.T) { - for _, width := range []int{120, 48} { - source := customization.NewDefaultSource(0) - t.Cleanup(func() { _ = source.Close(context.Background()) }) - m := canonicalFrame(source, 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, "ctx") { - t.Fatalf("width %d stock surfaces: header %q footer %q result %#v", width, header, footer, m.generatedStatusLine) - } - if strings.Count(header, "canonical-model") != 1 || strings.Count(footer, "ctx") != 1 || lipgloss.Width(strings.Split(header, "\n")[0]) > width || lipgloss.Width(strings.Split(footer, "\n")[1]) > width { - t.Fatalf("width %d duplicate or oversized renderer content: %q / %q", width, header, footer) - } + 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", 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) + } + }) } } diff --git a/cmd/mecatui/ui/footer_heal_test.go b/cmd/mecatui/ui/footer_heal_test.go index 8aa0a0a138..9ae8e4c6c9 100644 --- a/cmd/mecatui/ui/footer_heal_test.go +++ b/cmd/mecatui/ui/footer_heal_test.go @@ -1,11 +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" ) @@ -51,7 +54,14 @@ 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.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.resolvedSessionModel.ContextWindow; got != 0 { @@ -61,7 +71,7 @@ func TestFooterHealRaceThenHeal(t *testing.T) { // 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") @@ -74,15 +84,18 @@ func TestFooterHealRaceThenHeal(t *testing.T) { t.Fatalf("GetSession called %d times after one turn-end with unknown window, want 1", n) } - // 3) Feed the heal result back through the reducer and retain the resolved window. + // 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) } - status := m.statusLineSnapshot() - if status.Context.Window.Raw != 1_050_000 || status.Context.Used.Raw != 40_000 || !status.Context.Known { - t.Fatalf("status snapshot = %#v, want healed known context", status.Context) + 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, "~") { + t.Fatalf("healed stock footer = %q, want source-rendered estimated occupancy with healed denominator", footer) } // 4) Identity untouched (only the denominator self-corrected). diff --git a/cmd/mecatui/ui/resumable_session_status_metrics_test.go b/cmd/mecatui/ui/resumable_session_status_metrics_test.go index a07ca4a2d3..5aa3afdd6a 100644 --- a/cmd/mecatui/ui/resumable_session_status_metrics_test.go +++ b/cmd/mecatui/ui/resumable_session_status_metrics_test.go @@ -154,8 +154,8 @@ func TestResumableSessionStatusMetrics_Scenario3_StartupRefreshSubmitsPersistedO updated, _ = m.Update(refreshed) 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()) + if m.contextTokens != 40_000 || !m.contextEstimated || !ok || source.inputCount() != 3 || 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 after the healed denominator was resubmitted", input, source.inputCount()) } } diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 4b0c455e48..f55fd31023 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -1211,6 +1211,7 @@ func (m Model) onResolvedModelMsg(msg client.ResolvedModelMsg) (Model, tea.Cmd, } if (&m).setResolvedSessionModel(msg.Resolved) { m.refreshView() + m.submitStatusLine() } if msg.AdoptContextOccupancy && m.startupAdopted && m.resolvedSessionModel.ContextWindow == 0 && msg.StartupResumeRefreshAttempt < startupResumeRefreshRetries { return m, m.startupResumeRefreshRetryCmd(msg.StartupResumeRefreshAttempt + 1), true From 02f61086d82c3e8d7b348bf9b4e8cac4e18fe5ee Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 22:35:22 -0700 Subject: [PATCH 13/17] test(mecatui): settle pre-heal status before asserting refreshed footer Co-authored-by: Mecatl --- cmd/mecatui/ui/footer_heal_test.go | 19 +++++++++++++++---- .../resumable_session_status_metrics_test.go | 4 ++-- cmd/mecatui/ui/update.go | 1 - 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/cmd/mecatui/ui/footer_heal_test.go b/cmd/mecatui/ui/footer_heal_test.go index 9ae8e4c6c9..e21617dca0 100644 --- a/cmd/mecatui/ui/footer_heal_test.go +++ b/cmd/mecatui/ui/footer_heal_test.go @@ -42,10 +42,10 @@ func healModel(t *testing.T, conv *fakeConv) Model { } // 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{}, @@ -58,6 +58,7 @@ func TestFooterHealRaceThenHeal(t *testing.T) { 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())) @@ -67,6 +68,9 @@ func TestFooterHealRaceThenHeal(t *testing.T) { if got := m.resolvedSessionModel.ContextWindow; got != 0 { t.Fatalf("resolved context window = %d, want 0 (raced create echoed a 0 window)", got) } + 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 @@ -83,6 +87,13 @@ 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 through the real reducer, then let the configured // source publish and the UI listener apply the refreshed surface. @@ -94,7 +105,7 @@ func TestFooterHealRaceThenHeal(t *testing.T) { 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, "~") { + 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) } diff --git a/cmd/mecatui/ui/resumable_session_status_metrics_test.go b/cmd/mecatui/ui/resumable_session_status_metrics_test.go index 5aa3afdd6a..d806ea7d67 100644 --- a/cmd/mecatui/ui/resumable_session_status_metrics_test.go +++ b/cmd/mecatui/ui/resumable_session_status_metrics_test.go @@ -154,8 +154,8 @@ func TestResumableSessionStatusMetrics_Scenario3_StartupRefreshSubmitsPersistedO updated, _ = m.Update(refreshed) m = updated.(Model) input, ok := source.lastInput() - if m.contextTokens != 40_000 || !m.contextEstimated || !ok || source.inputCount() != 3 || 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 after the healed denominator was resubmitted", input, source.inputCount()) + 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 after the healed denominator was submitted once", input, source.inputCount()) } } diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index f55fd31023..4b0c455e48 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -1211,7 +1211,6 @@ func (m Model) onResolvedModelMsg(msg client.ResolvedModelMsg) (Model, tea.Cmd, } if (&m).setResolvedSessionModel(msg.Resolved) { m.refreshView() - m.submitStatusLine() } if msg.AdoptContextOccupancy && m.startupAdopted && m.resolvedSessionModel.ContextWindow == 0 && msg.StartupResumeRefreshAttempt < startupResumeRefreshRetries { return m, m.startupResumeRefreshRetryCmd(msg.StartupResumeRefreshAttempt + 1), true From 9f9a8ace445ad3f78ee1d92f6938f47f0daf8678 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 22:51:53 -0700 Subject: [PATCH 14/17] test(mecatui): strengthen canonical status assertions Co-authored-by: Mecatl --- cmd/mecatui/customization/defaults_test.go | 18 ++++++++++++------ cmd/mecatui/ui/canonical_status_test.go | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cmd/mecatui/customization/defaults_test.go b/cmd/mecatui/customization/defaults_test.go index 7bd509512a..fac79e303b 100644 --- a/cmd/mecatui/customization/defaults_test.go +++ b/cmd/mecatui/customization/defaults_test.go @@ -124,10 +124,11 @@ func TestCanonicalStatus_Scenario3_UnknownContextDoesNotClaimZeroPressure(t *tes footerCols int want string delegation Delegation + wantTeam bool }{ {name: "full", footerCols: 120, want: "ctx ?/200K"}, - {name: "compact", footerCols: 20, want: "ctx ?"}, - {name: "minimal", footerCols: 11, want: "ctx ?", delegation: Delegation{Team: LiveTeam{Total: 1}}}, + {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{ @@ -135,13 +136,18 @@ func TestCanonicalStatus_Scenario3_UnknownContextDoesNotClaimZeroPressure(t *tes Delegation: tc.delegation, Terminal: Terminal{FooterAvailCols: tc.footerCols}, }) - if got := statusSurfaceText(footer); !strings.Contains(got, tc.want) { + got := statusSurfaceText(footer) + if !strings.Contains(got, tc.want) { t.Fatalf("footer = %q, want unknown context containing %q", got, tc.want) } - if tc.name == "minimal" && strings.Contains(statusSurfaceText(footer), "⟳") { - t.Fatalf("footer = %q, retained compact delegation content instead of selecting minimal", statusSurfaceText(footer)) + 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(statusSurfaceText(footer), "ctx 0%") { + if strings.Contains(got, "ctx 0%") { t.Fatalf("footer = %q, fabricated zero pressure for unknown context", statusSurfaceText(footer)) } for _, span := range footer.Spans { diff --git a/cmd/mecatui/ui/canonical_status_test.go b/cmd/mecatui/ui/canonical_status_test.go index 65ba604561..aaff471425 100644 --- a/cmd/mecatui/ui/canonical_status_test.go +++ b/cmd/mecatui/ui/canonical_status_test.go @@ -31,7 +31,7 @@ func TestCanonicalStatus_Scenario1_StockSourceOwnsSurfaces(t *testing.T) { width int }{ {name: "wide full", width: 120, wantFooter: "100K/200K"}, - {name: "narrow compact", width: 48, wantFooter: "ctx", absentFooter: "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) From 04ba65071876f15f24f93d7636d9267a62e3d5d5 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sun, 4 Oct 2026 12:20:21 -0700 Subject: [PATCH 15/17] test(mecatui): render all full-frame goldens with status templates Co-authored-by: Mecatl --- cmd/mecatui/ui/agents_inventory_test.go | 4 +++ cmd/mecatui/ui/agents_overlay_test.go | 7 ++++ cmd/mecatui/ui/args_view_test.go | 4 +++ cmd/mecatui/ui/effort_test.go | 1 + cmd/mecatui/ui/help_test.go | 2 ++ .../ui/jev_routing_user_journey_test.go | 1 + cmd/mecatui/ui/mcp_test.go | 9 +++++ cmd/mecatui/ui/models_test.go | 12 +++++++ cmd/mecatui/ui/paste_large_test.go | 1 + cmd/mecatui/ui/sessions_golden_test.go | 35 +++++++++++++++++++ cmd/mecatui/ui/skills_test.go | 4 +++ cmd/mecatui/ui/soul_test.go | 4 +++ cmd/mecatui/ui/steer_test.go | 1 + cmd/mecatui/ui/team_overlay_test.go | 8 +++++ .../ui/testdata/agents_inventory.golden | 4 +-- .../agents_inventory_empty_disabled.golden | 4 +-- .../agents_inventory_empty_enabled.golden | 4 +-- .../testdata/agents_inventory_scroll.golden | 4 +-- .../ui/testdata/agents_teams_tab.golden | 4 +-- .../testdata/askargs_modal_longshell.golden | 4 +-- .../ui/testdata/askargs_view_pretty.golden | 4 +-- .../ui/testdata/askargs_view_raw.golden | 4 +-- .../ui/testdata/askargs_view_scrolled.golden | 4 +-- cmd/mecatui/ui/testdata/effort_picker.golden | 4 +-- cmd/mecatui/ui/testdata/help_all_on.golden | 4 +-- cmd/mecatui/ui/testdata/help_embedded.golden | 4 +-- .../testdata/jev_routing_user_journey.golden | 4 +-- cmd/mecatui/ui/testdata/mcp_err_input.golden | 4 +-- .../ui/testdata/mcp_err_not_configured.golden | 4 +-- cmd/mecatui/ui/testdata/mcp_err_server.golden | 4 +-- cmd/mecatui/ui/testdata/mcp_panel.golden | 4 +-- .../ui/testdata/mcp_panel_groups.golden | 4 +-- .../ui/testdata/mcp_prompt_args.golden | 4 +-- cmd/mecatui/ui/testdata/mcp_prompts.golden | 4 +-- .../ui/testdata/mcp_resource_preview.golden | 4 +-- cmd/mecatui/ui/testdata/mcp_resources.golden | 4 +-- cmd/mecatui/ui/testdata/models.golden | 4 +-- .../ui/testdata/models_codex_healthy.golden | 4 +-- .../testdata/models_codex_unauthorized.golden | 4 +-- .../ui/testdata/models_disabled.golden | 4 +-- cmd/mecatui/ui/testdata/models_empty.golden | 4 +-- .../ui/testdata/models_filtered.golden | 4 +-- .../ui/testdata/models_gateway_empty.golden | 4 +-- .../ui/testdata/models_global_default.golden | 4 +-- .../models_mixed_deployment_empty.golden | 4 +-- cmd/mecatui/ui/testdata/models_nomatch.golden | 4 +-- .../ui/testdata/models_scrolled.golden | 4 +-- .../models_toolhive_unreachable.golden | 4 +-- .../ui/testdata/parallel_group_focus.golden | 4 +-- .../ui/testdata/parallel_roster.golden | 4 +-- .../ui/testdata/paste_placeholder.golden | 4 +-- .../testdata/sessions_cleanup_review.golden | 4 +-- .../ui/testdata/sessions_picker.golden | 4 +-- .../ui/testdata/sessions_picker_empty.golden | 4 +-- .../ui/testdata/sessions_picker_error.golden | 4 +-- .../testdata/sessions_picker_filtered.golden | 35 ++++++++++++++++--- .../testdata/sessions_picker_nomatch.golden | 34 +++++++++++++++--- .../testdata/sessions_transcript_error.golden | 4 +-- .../sessions_transcript_loaded.golden | 9 +++-- .../sessions_transcript_loading.golden | 4 +-- .../sessions_transcript_rendered.golden | 4 +-- cmd/mecatui/ui/testdata/skills.golden | 4 +-- .../ui/testdata/skills_filtered.golden | 4 +-- cmd/mecatui/ui/testdata/skills_nomatch.golden | 4 +-- cmd/mecatui/ui/testdata/skills_scroll.golden | 4 +-- cmd/mecatui/ui/testdata/soul.golden | 4 +-- .../ui/testdata/soul_empty_disabled.golden | 4 +-- .../ui/testdata/soul_empty_enabled.golden | 4 +-- cmd/mecatui/ui/testdata/soul_project.golden | 4 +-- cmd/mecatui/ui/testdata/steer_card.golden | 4 +-- cmd/mecatui/ui/testdata/subagent_focus.golden | 4 +-- .../testdata/subagent_focus_background.golden | 4 +-- .../ui/testdata/subagent_roster.golden | 4 +-- .../subagent_roster_background.golden | 4 +-- cmd/mecatui/ui/testdata/team_findings.golden | 4 +-- cmd/mecatui/ui/testdata/team_focus.golden | 4 +-- .../ui/testdata/team_focus_windowed.golden | 4 +-- cmd/mecatui/ui/testdata/team_roster.golden | 4 +-- .../testdata/team_roster_midrun_idle.golden | 4 +-- .../ui/testdata/team_roster_stopped.golden | 4 +-- .../ui/testdata/team_roster_windowed.golden | 4 +-- cmd/mecatui/ui/testdata/team_tasks.golden | 4 +-- cmd/mecatui/ui/testdata/usermodel.golden | 4 +-- .../testdata/usermodel_empty_disabled.golden | 4 +-- .../testdata/usermodel_empty_enabled.golden | 4 +-- .../ui/testdata/view_aztec_ansi.golden | 4 +-- cmd/mecatui/ui/testdata/view_stripped.golden | 4 +-- .../ui/testdata/zerostate_all_on.golden | 4 +-- .../ui/testdata/zerostate_embedded.golden | 4 +-- cmd/mecatui/ui/ui_test.go | 28 +++++++++++++-- cmd/mecatui/ui/usermodel_test.go | 3 ++ cmd/mecatui/ui/zerostate_test.go | 4 +-- 92 files changed, 331 insertions(+), 163 deletions(-) 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_test.go b/cmd/mecatui/ui/agents_overlay_test.go index d4e0cbc37f..fcf5721b1a 100644 --- a/cmd/mecatui/ui/agents_overlay_test.go +++ b/cmd/mecatui/ui/agents_overlay_test.go @@ -935,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) @@ -952,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) @@ -987,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) @@ -1005,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) @@ -1256,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) @@ -1291,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) @@ -1348,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) 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/effort_test.go b/cmd/mecatui/ui/effort_test.go index 8437bb9430..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) } 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/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/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/models_test.go b/cmd/mecatui/ui/models_test.go index 04ffaed428..7ff1a3b731 100644 --- a/cmd/mecatui/ui/models_test.go +++ b/cmd/mecatui/ui/models_test.go @@ -1768,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) } @@ -1798,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) } @@ -1815,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)) { @@ -1835,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) @@ -1850,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) } @@ -1862,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) } @@ -1877,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) @@ -1891,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) } @@ -1906,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) } @@ -1920,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) } @@ -1937,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) } @@ -1951,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/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/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/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/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_overlay_test.go b/cmd/mecatui/ui/team_overlay_test.go index 0d6d9f263f..e158df36fc 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) @@ -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/testdata/agents_inventory.golden b/cmd/mecatui/ui/testdata/agents_inventory.golden index cc70c35486..565a3ce05e 100644 --- a/cmd/mecatui/ui/testdata/agents_inventory.golden +++ b/cmd/mecatui/ui/testdata/agents_inventory.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 c971b15e6a..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 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 971f768b24..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 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 6361e906a0..ceebd00946 100644 --- a/cmd/mecatui/ui/testdata/agents_inventory_scroll.golden +++ b/cmd/mecatui/ui/testdata/agents_inventory_scroll.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 46482b2eec..bfd9a25d50 100644 --- a/cmd/mecatui/ui/testdata/agents_teams_tab.golden +++ b/cmd/mecatui/ui/testdata/agents_teams_tab.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 188187591f..2e4176e956 100644 --- a/cmd/mecatui/ui/testdata/askargs_modal_longshell.golden +++ b/cmd/mecatui/ui/testdata/askargs_modal_longshell.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - ⚠ awaiting approval + ⚠ 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 85d28a0e00..1b6f53a14c 100644 --- a/cmd/mecatui/ui/testdata/askargs_view_pretty.golden +++ b/cmd/mecatui/ui/testdata/askargs_view_pretty.golden @@ -1,4 +1,4 @@ - session sess-test-00 + 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 + ⚠ 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 20a8c56609..9376347940 100644 --- a/cmd/mecatui/ui/testdata/askargs_view_raw.golden +++ b/cmd/mecatui/ui/testdata/askargs_view_raw.golden @@ -1,4 +1,4 @@ - session sess-test-00 + 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 + ⚠ 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 da48ef90c7..2553364780 100644 --- a/cmd/mecatui/ui/testdata/askargs_view_scrolled.golden +++ b/cmd/mecatui/ui/testdata/askargs_view_scrolled.golden @@ -1,4 +1,4 @@ - session sess-test-00 + 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 + ⚠ 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 188b4156cb..850ec8d795 100644 --- a/cmd/mecatui/ui/testdata/effort_picker.golden +++ b/cmd/mecatui/ui/testdata/effort_picker.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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/help_all_on.golden b/cmd/mecatui/ui/testdata/help_all_on.golden index 00499f29b8..aed887c921 100644 --- a/cmd/mecatui/ui/testdata/help_all_on.golden +++ b/cmd/mecatui/ui/testdata/help_all_on.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 754f4faa3f..e62dfccdf9 100644 --- a/cmd/mecatui/ui/testdata/help_embedded.golden +++ b/cmd/mecatui/ui/testdata/help_embedded.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 faa07fe972..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 │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── @@ -76,5 +76,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - team done · 1 round + 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 5840746a8f..1be63fcaf1 100644 --- a/cmd/mecatui/ui/testdata/mcp_err_input.golden +++ b/cmd/mecatui/ui/testdata/mcp_err_input.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 ffadb1e6db..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 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 e424b566cc..a834e6d838 100644 --- a/cmd/mecatui/ui/testdata/mcp_err_server.golden +++ b/cmd/mecatui/ui/testdata/mcp_err_server.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 58f03c1df6..2c9050e1a5 100644 --- a/cmd/mecatui/ui/testdata/mcp_panel.golden +++ b/cmd/mecatui/ui/testdata/mcp_panel.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 f086e68a98..0a26681290 100644 --- a/cmd/mecatui/ui/testdata/mcp_panel_groups.golden +++ b/cmd/mecatui/ui/testdata/mcp_panel_groups.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 3026507654..beff24ad56 100644 --- a/cmd/mecatui/ui/testdata/mcp_prompt_args.golden +++ b/cmd/mecatui/ui/testdata/mcp_prompt_args.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 14288d99ed..f8dbac22aa 100644 --- a/cmd/mecatui/ui/testdata/mcp_prompts.golden +++ b/cmd/mecatui/ui/testdata/mcp_prompts.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 3eaf8436bd..d07311a314 100644 --- a/cmd/mecatui/ui/testdata/mcp_resource_preview.golden +++ b/cmd/mecatui/ui/testdata/mcp_resource_preview.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 97904c3040..dd72d33e1b 100644 --- a/cmd/mecatui/ui/testdata/mcp_resources.golden +++ b/cmd/mecatui/ui/testdata/mcp_resources.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 c9ead64633..d6c7dfffed 100644 --- a/cmd/mecatui/ui/testdata/models.golden +++ b/cmd/mecatui/ui/testdata/models.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 8dab30507c..05f2c4b4db 100644 --- a/cmd/mecatui/ui/testdata/models_codex_healthy.golden +++ b/cmd/mecatui/ui/testdata/models_codex_healthy.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 3bc7e6513b..7bf38d1fd9 100644 --- a/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden +++ b/cmd/mecatui/ui/testdata/models_codex_unauthorized.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 8c87cf92a7..5d1283ef71 100644 --- a/cmd/mecatui/ui/testdata/models_disabled.golden +++ b/cmd/mecatui/ui/testdata/models_disabled.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 cf875ebe30..1f57a6dbae 100644 --- a/cmd/mecatui/ui/testdata/models_empty.golden +++ b/cmd/mecatui/ui/testdata/models_empty.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 6cd7a52227..4cb66636c6 100644 --- a/cmd/mecatui/ui/testdata/models_filtered.golden +++ b/cmd/mecatui/ui/testdata/models_filtered.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 806f64e89c..cb31529146 100644 --- a/cmd/mecatui/ui/testdata/models_gateway_empty.golden +++ b/cmd/mecatui/ui/testdata/models_gateway_empty.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 eeba09e096..e0708ceb29 100644 --- a/cmd/mecatui/ui/testdata/models_global_default.golden +++ b/cmd/mecatui/ui/testdata/models_global_default.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 b7f276b227..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 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 59e8e9f216..e137849405 100644 --- a/cmd/mecatui/ui/testdata/models_nomatch.golden +++ b/cmd/mecatui/ui/testdata/models_nomatch.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 8952c2ca2f..8840262a63 100644 --- a/cmd/mecatui/ui/testdata/models_scrolled.golden +++ b/cmd/mecatui/ui/testdata/models_scrolled.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 665c42bebf..3684151a08 100644 --- a/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden +++ b/cmd/mecatui/ui/testdata/models_toolhive_unreachable.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 700111e303..20c0a584a9 100644 --- a/cmd/mecatui/ui/testdata/parallel_group_focus.golden +++ b/cmd/mecatui/ui/testdata/parallel_group_focus.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 cba72ab22b..dbf1ed2ed1 100644 --- a/cmd/mecatui/ui/testdata/parallel_roster.golden +++ b/cmd/mecatui/ui/testdata/parallel_roster.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 4249eb3bc7..09b83cfe3e 100644 --- a/cmd/mecatui/ui/testdata/paste_placeholder.golden +++ b/cmd/mecatui/ui/testdata/paste_placeholder.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -76,5 +76,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 fa4ea93bac..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 · 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… + ⣾ 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 e4536c4d46..e9ae1da335 100644 --- a/cmd/mecatui/ui/testdata/sessions_picker.golden +++ b/cmd/mecatui/ui/testdata/sessions_picker.golden @@ -1,4 +1,4 @@ - session sess-test-00 + 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 + 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 9014b98fd2..27d9fa5d74 100644 --- a/cmd/mecatui/ui/testdata/sessions_picker_empty.golden +++ b/cmd/mecatui/ui/testdata/sessions_picker_empty.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ▸ Chats Scheduled runs Child runs Other @@ -36,5 +36,5 @@ tab: switch esc: close │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 e371589051..f89110d252 100644 --- a/cmd/mecatui/ui/testdata/sessions_picker_error.golden +++ b/cmd/mecatui/ui/testdata/sessions_picker_error.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ▸ Chats Scheduled runs Child runs Other @@ -36,5 +36,5 @@ r: retry esc: close │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 9072ad88a9..f4afed86f0 100644 --- a/cmd/mecatui/ui/testdata/sessions_transcript_error.golden +++ b/cmd/mecatui/ui/testdata/sessions_transcript_error.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── conversation unavailable @@ -10,5 +10,5 @@ r: Retry esc: Back │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 5573df46c8..08c508a1d6 100644 --- a/cmd/mecatui/ui/testdata/sessions_transcript_loading.golden +++ b/cmd/mecatui/ui/testdata/sessions_transcript_loading.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── loading conversation @@ -10,5 +10,5 @@ esc: Back │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 f614fd5833..64a1841acf 100644 --- a/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden +++ b/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden @@ -1,4 +1,4 @@ - session sess-test-00 + 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 + 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 ba43416337..ef93617132 100644 --- a/cmd/mecatui/ui/testdata/skills.golden +++ b/cmd/mecatui/ui/testdata/skills.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 101d0ae358..ea129b39e9 100644 --- a/cmd/mecatui/ui/testdata/skills_filtered.golden +++ b/cmd/mecatui/ui/testdata/skills_filtered.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 3465586bc7..59c5c4c38a 100644 --- a/cmd/mecatui/ui/testdata/skills_nomatch.golden +++ b/cmd/mecatui/ui/testdata/skills_nomatch.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 d6973e8fe8..fbffd949b6 100644 --- a/cmd/mecatui/ui/testdata/skills_scroll.golden +++ b/cmd/mecatui/ui/testdata/skills_scroll.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 8840f2a390..d72e527151 100644 --- a/cmd/mecatui/ui/testdata/soul.golden +++ b/cmd/mecatui/ui/testdata/soul.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 c48b57782e..a006a14931 100644 --- a/cmd/mecatui/ui/testdata/soul_empty_disabled.golden +++ b/cmd/mecatui/ui/testdata/soul_empty_disabled.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 02dd15ee4f..c74b9858cf 100644 --- a/cmd/mecatui/ui/testdata/soul_empty_enabled.golden +++ b/cmd/mecatui/ui/testdata/soul_empty_enabled.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 6f9dd53d96..45240161f1 100644 --- a/cmd/mecatui/ui/testdata/soul_project.golden +++ b/cmd/mecatui/ui/testdata/soul_project.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 4b4fbb4426..238b20c722 100644 --- a/cmd/mecatui/ui/testdata/steer_card.golden +++ b/cmd/mecatui/ui/testdata/steer_card.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ▌ you │ first @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - ⣾ thinking… + ⣾ 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 39aac7f1e1..61678299c5 100644 --- a/cmd/mecatui/ui/testdata/subagent_focus.golden +++ b/cmd/mecatui/ui/testdata/subagent_focus.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 8cf54b46e2..740f96dbb1 100644 --- a/cmd/mecatui/ui/testdata/subagent_focus_background.golden +++ b/cmd/mecatui/ui/testdata/subagent_focus_background.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - background subagent #r-g7b7 done — result ready for the agent + 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 7cbd503edd..51193b5d05 100644 --- a/cmd/mecatui/ui/testdata/subagent_roster.golden +++ b/cmd/mecatui/ui/testdata/subagent_roster.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 58095e9e16..bbf533258a 100644 --- a/cmd/mecatui/ui/testdata/subagent_roster_background.golden +++ b/cmd/mecatui/ui/testdata/subagent_roster_background.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - background subagent #r-g7b7 done — result ready for the agent + 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 5d9a2d37e6..1212c75e4c 100644 --- a/cmd/mecatui/ui/testdata/team_findings.golden +++ b/cmd/mecatui/ui/testdata/team_findings.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 ae09071376..26e1f45499 100644 --- a/cmd/mecatui/ui/testdata/team_focus.golden +++ b/cmd/mecatui/ui/testdata/team_focus.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 ee0fba089e..ee3212d6e0 100644 --- a/cmd/mecatui/ui/testdata/team_focus_windowed.golden +++ b/cmd/mecatui/ui/testdata/team_focus_windowed.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 46482b2eec..2f2962e1bd 100644 --- a/cmd/mecatui/ui/testdata/team_roster.golden +++ b/cmd/mecatui/ui/testdata/team_roster.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 d9f76e24ff..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 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 f4d1416a70..c2c2383300 100644 --- a/cmd/mecatui/ui/testdata/team_roster_stopped.golden +++ b/cmd/mecatui/ui/testdata/team_roster_stopped.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 28e0c4e57d..bbde12b4b0 100644 --- a/cmd/mecatui/ui/testdata/team_roster_windowed.golden +++ b/cmd/mecatui/ui/testdata/team_roster_windowed.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ┃ @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 dd8ab62391..b9a700343b 100644 --- a/cmd/mecatui/ui/testdata/team_tasks.golden +++ b/cmd/mecatui/ui/testdata/team_tasks.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 9b57ace0f7..4d58190515 100644 --- a/cmd/mecatui/ui/testdata/usermodel.golden +++ b/cmd/mecatui/ui/testdata/usermodel.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 2e33eba6e4..c54b3ac5ec 100644 --- a/cmd/mecatui/ui/testdata/usermodel_empty_disabled.golden +++ b/cmd/mecatui/ui/testdata/usermodel_empty_disabled.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 29be9115d9..b502011882 100644 --- a/cmd/mecatui/ui/testdata/usermodel_empty_enabled.golden +++ b/cmd/mecatui/ui/testdata/usermodel_empty_enabled.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -26,5 +26,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 a49689630e..9714422ac3 100644 --- a/cmd/mecatui/ui/testdata/view_aztec_ansi.golden +++ b/cmd/mecatui/ui/testdata/view_aztec_ansi.golden @@ -1,4 +1,4 @@ - mecatui · mock-model · mode default + 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/view_stripped.golden b/cmd/mecatui/ui/testdata/view_stripped.golden index 1db8c3e3db..9c6d53a6ed 100644 --- a/cmd/mecatui/ui/testdata/view_stripped.golden +++ b/cmd/mecatui/ui/testdata/view_stripped.golden @@ -1,4 +1,4 @@ - mecatui · mock-model · mode default + 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 15a157ac68..bad94f596c 100644 --- a/cmd/mecatui/ui/testdata/zerostate_all_on.golden +++ b/cmd/mecatui/ui/testdata/zerostate_all_on.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -76,5 +76,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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 15a157ac68..bad94f596c 100644 --- a/cmd/mecatui/ui/testdata/zerostate_embedded.golden +++ b/cmd/mecatui/ui/testdata/zerostate_embedded.golden @@ -1,4 +1,4 @@ - session sess-test-00 + mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── @@ -76,5 +76,5 @@ │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── - connected + 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/ui_test.go b/cmd/mecatui/ui/ui_test.go index 30a8dd3132..ad36a98c19 100644 --- a/cmd/mecatui/ui/ui_test.go +++ b/cmd/mecatui/ui/ui_test.go @@ -438,16 +438,38 @@ func driveTo(t *testing.T, th theme.Theme) Model { // 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(m.statusLineSnapshot()) + 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 { @@ -495,7 +517,7 @@ func TestStockStatusFixture(t *testing.T) { // TestViewGoldenStripped locks the awaiting-approval frame (ANSI stripped) — the // readable structural golden a reviewer can eyeball. func TestViewGoldenStripped(t *testing.T) { - m := stockStatusFrame(t, 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) } @@ -503,7 +525,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 := stockStatusFrame(t, 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/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/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) } From 8667e930991adb2c0cc571b060c610b36621ad61 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sun, 4 Oct 2026 16:30:03 -0700 Subject: [PATCH 16/17] refactor(mecatui): extract stateless UI render formatters Co-authored-by: Mecatl --- cmd/mecatui/ui/agents_overlay.go | 13 +- cmd/mecatui/ui/argsummary_test.go | 5 +- cmd/mecatui/ui/footer.go | 294 ------------------ cmd/mecatui/ui/footer_test.go | 61 +++- .../ui/internal/renderfmt/renderfmt.go | 247 +++++++++++++++ cmd/mecatui/ui/models_surface.go | 3 +- cmd/mecatui/ui/noprogress_test.go | 9 +- cmd/mecatui/ui/plan_approval_test.go | 7 +- cmd/mecatui/ui/render.go | 50 +-- cmd/mecatui/ui/sessions_surface.go | 11 +- cmd/mecatui/ui/status_constants.go | 16 + cmd/mecatui/ui/statusline_source.go | 5 +- cmd/mecatui/ui/team.go | 36 +-- cmd/mecatui/ui/team_overlay_test.go | 8 +- cmd/mecatui/ui/tool_block.go | 7 +- cmd/mecatui/ui/update.go | 11 +- cmd/mecatui/ui/view.go | 3 +- 17 files changed, 376 insertions(+), 410 deletions(-) delete mode 100644 cmd/mecatui/ui/footer.go create mode 100644 cmd/mecatui/ui/internal/renderfmt/renderfmt.go create mode 100644 cmd/mecatui/ui/status_constants.go 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/argsummary_test.go b/cmd/mecatui/ui/argsummary_test.go index 82ff4a0093..216781e9a3 100644 --- a/cmd/mecatui/ui/argsummary_test.go +++ b/cmd/mecatui/ui/argsummary_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt" ) // mustJSON marshals v to a compact JSON args string for a tool block. @@ -249,8 +250,8 @@ func TestHumanizeBytesSI(t *testing.T) { {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) + 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/footer.go b/cmd/mecatui/ui/footer.go deleted file mode 100644 index 54709c949f..0000000000 --- a/cmd/mecatui/ui/footer.go +++ /dev/null @@ -1,294 +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 pressure thresholds and glyphs also serve team roster meters. -const ( - ctxWarnFraction = 0.60 - ctxDangerFraction = 0.85 - ctxBarWidth = 8 - ctxGlyphOk = "▒" - ctxGlyphWarn = "▓" - ctxGlyphDanger = "█" - ctxGlyphEmpty = "░" - ctxDangerMark = " ⚠" -) - -// 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 formats the percentage with the non-colour danger cue. -func ctxLabel(frac float64) string { - label := pctString(frac) - if frac >= ctxDangerFraction { - label += ctxDangerMark - } - return label -} - -// renderContextMeter formats context for the 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 := ctxFraction(used, window) - style := th.Style(ctxPressureSlot(frac)) - meter := style.Render(ctxBar(frac) + " " + ctxLabel(frac)) - return "ctx " + meter + " · " + humanizeTokens(used) + "/" + humanizeTokens(window) -} - -// 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 - } -} - -// 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 -} diff --git a/cmd/mecatui/ui/footer_test.go b/cmd/mecatui/ui/footer_test.go index 39292e4ed9..99fec1bd07 100644 --- a/cmd/mecatui/ui/footer_test.go +++ b/cmd/mecatui/ui/footer_test.go @@ -9,6 +9,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" ) func TestHumanizeTokens(t *testing.T) { @@ -29,8 +30,8 @@ func TestHumanizeTokens(t *testing.T) { {-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) + if got := renderfmt.HumanizeTokens(c.in); got != c.want { + t.Errorf("renderfmt.HumanizeTokens(%d) = %q, want %q", c.in, got, c.want) } } } @@ -47,9 +48,9 @@ func TestCacheHitRate(t *testing.T) { {"88pct", client.Usage{InputTokens: 1500, CacheReadTokens: 1320}, 0.88}, } for _, c := range cases { - got := cacheHitRate(c.u) + got := renderfmt.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) + t.Errorf("%s: renderfmt.CacheHitRate = %v, want %v", c.name, got, c.want) } } } @@ -67,8 +68,36 @@ func TestPctString(t *testing.T) { {-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) + 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) } } } @@ -119,15 +148,15 @@ func TestTurnStatLine(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - got := turnStatLine(client.TurnEndMsg{Usage: c.usage, DurationMs: c.durationMs}) + got := renderfmt.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) + t.Errorf("renderfmt.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) + t.Errorf("renderfmt.TurnStatLine = %q, must NOT contain %q", got, sub) } } }) @@ -136,10 +165,10 @@ func TestTurnStatLine(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(turnStatLine) → blockTurnStat → renderBlockFresh), +// 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 trivialTurn gate, the conversation append, and the block render all wired). +// 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, @@ -270,19 +299,19 @@ func TestStopReasonLabel(t *testing.T) { {"some_future_reason", "some_future_reason", "muted"}, } for _, c := range cases { - text, slot := stopReasonLabel(c.stop) + text, slot := renderfmt.StopReasonLabel(c.stop) if text != c.text { - t.Errorf("stopReasonLabel(%q) text = %q, want %q", c.stop, text, c.text) + t.Errorf("renderfmt.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) + t.Errorf("renderfmt.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 @@ -314,7 +343,7 @@ func TestResultMsgStopReachesFooter(t *testing.T) { // 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") + text, _ := renderfmt.StopReasonLabel("evil\x1b[2Jreason") if strings.ContainsRune(text, 0x1b) { t.Errorf("unknown stop reason should be sanitized, got %q", text) } 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/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/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/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/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/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 827bd56ec0..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 { 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 e158df36fc..1e2d4b7442 100644 --- a/cmd/mecatui/ui/team_overlay_test.go +++ b/cmd/mecatui/ui/team_overlay_test.go @@ -973,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. @@ -1014,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") { @@ -1022,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) } 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/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/view.go b/cmd/mecatui/ui/view.go index 904aa2ad0b..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 @@ -604,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 From 6af75796747aac1f1c7b9627ffabf7e4305de7e0 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sun, 4 Oct 2026 16:48:40 -0700 Subject: [PATCH 17/17] test(mecatui): colocate formatter and team tests with owners Co-authored-by: Mecatl --- cmd/mecatui/ui/argsummary_test.go | 28 -- cmd/mecatui/ui/footer_test.go | 271 ------------------ cmd/mecatui/ui/integration_test.go | 20 ++ .../ui/internal/renderfmt/renderfmt_test.go | 229 +++++++++++++++ cmd/mecatui/ui/team_test.go | 54 ++++ cmd/mecatui/ui/ui_test.go | 3 + 6 files changed, 306 insertions(+), 299 deletions(-) create mode 100644 cmd/mecatui/ui/internal/renderfmt/renderfmt_test.go diff --git a/cmd/mecatui/ui/argsummary_test.go b/cmd/mecatui/ui/argsummary_test.go index 216781e9a3..cedf8cf46f 100644 --- a/cmd/mecatui/ui/argsummary_test.go +++ b/cmd/mecatui/ui/argsummary_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/stacklok/mecatl/cmd/mecatui/client" - "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt" ) // mustJSON marshals v to a compact JSON args string for a tool block. @@ -229,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 := renderfmt.HumanizeBytes(c.n); got != c.want { - t.Errorf("renderfmt.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/footer_test.go b/cmd/mecatui/ui/footer_test.go index 99fec1bd07..f5b33d2554 100644 --- a/cmd/mecatui/ui/footer_test.go +++ b/cmd/mecatui/ui/footer_test.go @@ -9,180 +9,8 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/theme" - "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) - } - } -} - -// 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 := 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) - } - } - }) - } -} - -// 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) - } -} - // 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) { @@ -274,41 +102,6 @@ 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 := 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) - } - } -} - // 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 → renderfmt.StopReasonLabel → m.statusMsg, and the rendered footer @@ -338,67 +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, _ := renderfmt.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) - } -} diff --git a/cmd/mecatui/ui/integration_test.go b/cmd/mecatui/ui/integration_test.go index 465e9d9497..f8e0ef3b12 100644 --- a/cmd/mecatui/ui/integration_test.go +++ b/cmd/mecatui/ui/integration_test.go @@ -414,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_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/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/ui_test.go b/cmd/mecatui/ui/ui_test.go index ad36a98c19..140624e956 100644 --- a/cmd/mecatui/ui/ui_test.go +++ b/cmd/mecatui/ui/ui_test.go @@ -214,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}