diff --git a/cmd/mecatui/client/session.go b/cmd/mecatui/client/session.go index 230d3a0378..278b2c29c7 100644 --- a/cmd/mecatui/client/session.go +++ b/cmd/mecatui/client/session.go @@ -25,6 +25,9 @@ type SessionSnapshot struct { // Usage is the canonical cumulative main-session ledger. It is distinct from // ContextOccupancy, which is only the latest context-meter display state. Usage Usage + // AuxiliaryUsage is the sum of every non-main canonical usage bucket (title + // generation, compaction, reflection, routing, reviewers, guardrails, judges). + AuxiliaryUsage Usage // ContextOccupancy is nil when a legacy or pre-turn snapshot has no known // context-meter numerator. ContextOccupancy *ContextOccupancy @@ -54,6 +57,22 @@ func contextOccupancyFrom(occupancy *mecatlv1.ContextOccupancy) *ContextOccupanc return &ContextOccupancy{InputTokens: occupancy.GetInputTokens(), Estimated: occupancy.GetEstimated()} } +func auxiliaryUsageFrom(buckets map[string]*mecatlv1.TokenUsage) Usage { + var total Usage + for kind, bucket := range buckets { + if kind == "main" { + continue + } + u := usageFrom(bucket.GetTotal()) + total.InputTokens += u.InputTokens + total.OutputTokens += u.OutputTokens + total.CacheReadTokens += u.CacheReadTokens + total.CacheWriteTokens += u.CacheWriteTokens + total.ReasoningTokens += u.ReasoningTokens + } + return total +} + func snapshotFrom(s *mecatlv1.Session) SessionSnapshot { return snapshotFromWithGlobalCapabilities(s, Capabilities{}) } @@ -70,6 +89,7 @@ func snapshotFromWithGlobalCapabilities(s *mecatlv1.Session, global Capabilities CreatedAt: s.GetCreatedAtUnix(), ResolvedModel: resolvedModelFrom(s.GetResolvedModel()), Usage: usageFrom(s.GetTokenUsage()["main"].GetTotal()), + AuxiliaryUsage: auxiliaryUsageFrom(s.GetTokenUsage()), ContextOccupancy: contextOccupancyFrom(s.GetLatestContextOccupancy()), Title: titleFromProto(s), TitleProvenance: titleProvenanceFromProto(s), diff --git a/cmd/mecatui/client/session_test.go b/cmd/mecatui/client/session_test.go index e9ce1c1967..f51d5c6fe2 100644 --- a/cmd/mecatui/client/session_test.go +++ b/cmd/mecatui/client/session_test.go @@ -157,18 +157,23 @@ func TestSnapshotFromReadsTitle(t *testing.T) { func TestSnapshotFromProjectsMainUsageAndOptionalContextOccupancy(t *testing.T) { snap := snapshotFrom(&mecatlv1.Session{ TokenUsage: map[string]*mecatlv1.TokenUsage{ - "main": {Total: &mecatlv1.Usage{InputTokens: 120_000, OutputTokens: 4_000, CacheReadTokens: 90_000}}, + "main": {Total: &mecatlv1.Usage{InputTokens: 120_000, OutputTokens: 4_000, CacheReadTokens: 90_000}}, + "session_title": {Total: &mecatlv1.Usage{InputTokens: 300, OutputTokens: 10}}, + "guardrail": {Total: &mecatlv1.Usage{InputTokens: 700, OutputTokens: 5, CacheReadTokens: 200}}, }, LatestContextOccupancy: &mecatlv1.ContextOccupancy{InputTokens: 40_000, Estimated: true}, }) if snap.Usage != (Usage{InputTokens: 120_000, OutputTokens: 4_000, CacheReadTokens: 90_000}) { t.Fatalf("main usage = %+v", snap.Usage) } + if snap.AuxiliaryUsage != (Usage{InputTokens: 1_000, OutputTokens: 15, CacheReadTokens: 200}) { + t.Fatalf("auxiliary usage = %+v", snap.AuxiliaryUsage) + } if snap.ContextOccupancy == nil || *snap.ContextOccupancy != (ContextOccupancy{InputTokens: 40_000, Estimated: true}) { t.Fatalf("context occupancy = %+v", snap.ContextOccupancy) } legacy := snapshotFrom(&mecatlv1.Session{TokenUsage: map[string]*mecatlv1.TokenUsage{"main": {Total: &mecatlv1.Usage{InputTokens: 120_000}}}}) - if legacy.ContextOccupancy != nil || legacy.Usage.InputTokens != 120_000 { + if legacy.ContextOccupancy != nil || legacy.Usage.InputTokens != 120_000 || legacy.AuxiliaryUsage != (Usage{}) { t.Fatalf("legacy snapshot = %+v", legacy) } } diff --git a/cmd/mecatui/exit_handoff.go b/cmd/mecatui/exit_handoff.go index b126516f0f..22e1151cee 100644 --- a/cmd/mecatui/exit_handoff.go +++ b/cmd/mecatui/exit_handoff.go @@ -13,6 +13,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" ) @@ -39,31 +40,54 @@ func finishFinalSessionHandoff(w io.Writer, final tea.Model, runErr error, inter cancel() } cleanup() - if !shouldWriteFinalSessionHandoff(final, runErr, interrupted) || !writeFinalSessionHandoff(w, final) || !embedded || !ok { + if !shouldWriteFinalSessionHandoff(final, runErr, interrupted) || !ok { return } id := reporter.ActiveSessionID() + // Connected mode, and an ID unsafe to print as one terminal line, keep the + // JSON-quoted record; an embedded safe ID gets the aligned human summary instead. + if !embedded || id == "" || !utf8.ValidString(id) || !safeHandoffID(id) { + writeFinalSessionHandoff(w, final) + return + } var human strings.Builder + human.WriteByte('\n') + writeHandoffField(&human, "Session ID:", id) if available { if title := strings.TrimSpace(terminaltext.SanitizeSingleLine(snapshot.Title)); title != "" { - _, _ = fmt.Fprintf(&human, "Session: %s\n", title) - } - _, _ = fmt.Fprintf(&human, "Model calls: %d\nTokens (main): %d input, %d output", snapshot.Turns, snapshot.Usage.InputTokens, snapshot.Usage.OutputTokens) - if snapshot.Usage.CacheReadTokens != 0 { - _, _ = fmt.Fprintf(&human, ", %d cache read", snapshot.Usage.CacheReadTokens) + writeHandoffField(&human, "Title:", title) } - if snapshot.Usage.CacheWriteTokens != 0 { - _, _ = fmt.Fprintf(&human, ", %d cache write", snapshot.Usage.CacheWriteTokens) + writeHandoffField(&human, "Model calls:", fmt.Sprint(snapshot.Turns)) + writeHandoffField(&human, "Tokens (main):", handoffTokens(snapshot.Usage)) + if snapshot.AuxiliaryUsage != (client.Usage{}) { + writeHandoffField(&human, "Tokens (aux):", handoffTokens(snapshot.AuxiliaryUsage)) } - human.WriteByte('\n') - } - if safeHandoffID(id) { - _, _ = fmt.Fprintf(&human, "Resume: mecatui --resume '%s'\n", strings.ReplaceAll(id, "'", "'\"'\"'")) - human.WriteString("Or: mecatui --resume-latest (may select a different chat)\n") } + writeHandoffField(&human, "Resume:", "mecatui --resume '"+strings.ReplaceAll(id, "'", "'\"'\"'")+"'") + writeHandoffField(&human, "", "mecatui --resume-latest (may select a different chat)") _, _ = io.WriteString(w, human.String()) } +// handoffLabelWidth aligns values after the widest label, "Tokens (main): ". +const handoffLabelWidth = len("Tokens (main): ") + +func writeHandoffField(b *strings.Builder, label, value string) { + _, _ = fmt.Fprintf(b, "%-*s%s\n", handoffLabelWidth, label, value) +} + +// handoffTokens humanizes one usage value; cache counts are labelled +// components of input, never summed with it. +func handoffTokens(u client.Usage) string { + s := renderfmt.HumanizeTokens(u.InputTokens) + " input, " + renderfmt.HumanizeTokens(u.OutputTokens) + " output" + if u.CacheReadTokens != 0 { + s += ", " + renderfmt.HumanizeTokens(u.CacheReadTokens) + " cache read" + } + if u.CacheWriteTokens != 0 { + s += ", " + renderfmt.HumanizeTokens(u.CacheWriteTokens) + " cache write" + } + return s +} + func safeHandoffID(id string) bool { for _, r := range id { if !unicode.IsPrint(r) || unicode.Is(unicode.Cf, r) { diff --git a/cmd/mecatui/exit_handoff_composition_test.go b/cmd/mecatui/exit_handoff_composition_test.go index 542b177d56..7945af8d3a 100644 --- a/cmd/mecatui/exit_handoff_composition_test.go +++ b/cmd/mecatui/exit_handoff_composition_test.go @@ -198,14 +198,15 @@ func checkEmbeddedCompositionChild(t *testing.T, scenario string) { t.Fatalf("stdout changed by program: %q", stdout.String()) } got := stderr.String() - line := strings.Index(got, finalSessionHandoffPrefix) - if line < 1 || got[line-1] != '\n' || strings.Contains(got, "hosting an embedded mecated at") || strings.Count(got, finalSessionHandoffPrefix) != 1 || strings.LastIndex(got[:line], "\x1b[?1049l") < 0 || !strings.Contains(got[line:], "handoff-child-cleaned\n") { + const idLabel = "Session ID: " + line := strings.Index(got, "\n"+idLabel) + if line < 0 || strings.Contains(got, "hosting an embedded mecated at") || strings.Contains(got, finalSessionHandoffPrefix) || strings.Count(got, idLabel) != 1 || strings.LastIndex(got[:line], "\x1b[?1049l") < 0 || !strings.Contains(got[line:], "handoff-child-cleaned\n") { t.Fatalf("handoff must follow teardown with a separating line and no startup socket address: %q", got) } - fields := strings.SplitN(got[line:], "\n", 2) - var id string - if err := json.Unmarshal([]byte(strings.TrimPrefix(fields[0], finalSessionHandoffPrefix)), &id); err != nil || id == "" { - t.Fatalf("invalid ID: %q: %v", fields[0], err) + line++ + id := strings.SplitN(strings.TrimPrefix(got[line:], idLabel), "\n", 2)[0] + if id == "" { + t.Fatalf("missing session ID: %q", got[line:]) } const finalIDPrefix = "handoff-child-final-id=" finalLine := strings.Index(got, finalIDPrefix) @@ -219,14 +220,14 @@ func checkEmbeddedCompositionChild(t *testing.T, scenario string) { if scenario != "missing" && id != finalID { t.Fatalf("handoff ID = %q, want seeded final ID %q", id, finalID) } - if !strings.Contains(got[line:], "Resume: mecatui --resume '"+id+"'\n") || !strings.Contains(got[line:], "Or: mecatui --resume-latest (may select a different chat)\n") { + if !strings.Contains(got[line:], "Resume: mecatui --resume '"+id+"'\n") || !strings.Contains(got[line:], " mecatui --resume-latest (may select a different chat)\n") { t.Fatalf("final ID command missing: %q", got[line:]) } if scenario == "missing" { - if id != "missing-final" || strings.Contains(got[line:], "Session:") || strings.Contains(got[line:], "Model calls:") || strings.Contains(got[line:], "Tokens (main):") { + if id != "missing-final" || strings.Contains(got[line:], "Title:") || strings.Contains(got[line:], "Model calls:") || strings.Contains(got[line:], "Tokens (main):") { t.Fatalf("failed lookup retained stale summary: %q", got[line:]) } - } else if !strings.Contains(got[line:], "Session: final title\nModel calls: 1\nTokens (main): 0 input, 0 output\n") || strings.Contains(got[line:], "first title") { + } else if !strings.Contains(got[line:], "Title: final title\nModel calls: 1\nTokens (main): 0 input, 0 output\n") || strings.Contains(got[line:], "first title") { t.Fatalf("wrong final session snapshot: %q", got[line:]) } } diff --git a/cmd/mecatui/exit_handoff_scenarios_test.go b/cmd/mecatui/exit_handoff_scenarios_test.go index 57facf3100..7b03c620f3 100644 --- a/cmd/mecatui/exit_handoff_scenarios_test.go +++ b/cmd/mecatui/exit_handoff_scenarios_test.go @@ -73,16 +73,11 @@ func TestMecatuiExitHandoff_Scenario1_EmbeddedResumeAfterTeardown(t *testing.T) got := stderr.String() teardown := strings.Index(got, "\x1b[?1049l") cleanup := strings.Index(got, "cleanup-complete\n") - line := strings.Index(got, finalSessionHandoffPrefix) - if teardown < 0 || cleanup <= teardown || line <= cleanup || strings.Count(got, finalSessionHandoffPrefix) != 1 { - t.Fatalf("teardown/cleanup/ID record order = %q", got) + line := strings.Index(got, "\nSession ID: final chat\n") + if teardown < 0 || cleanup <= teardown || line <= cleanup || strings.Contains(got, finalSessionHandoffPrefix) { + t.Fatalf("teardown/cleanup/session ID order = %q", got) } - lines := strings.Split(got[line:], "\n") - var decoded string - if err := json.Unmarshal([]byte(strings.TrimPrefix(lines[0], finalSessionHandoffPrefix)), &decoded); err != nil || decoded != "final chat" { - t.Fatalf("ID record = %q, decoded=%q, err=%v", lines[0], decoded, err) - } - if !strings.Contains(got, "Resume: mecatui --resume 'final chat'\n") || !strings.Contains(got, "Or: mecatui --resume-latest (may select a different chat)\n") { + if !strings.Contains(got, "Resume: mecatui --resume 'final chat'\n") || !strings.Contains(got, " mecatui --resume-latest (may select a different chat)\n") { t.Fatalf("missing exact/qualified continuation: %q", got) } } @@ -92,13 +87,21 @@ func TestMecatuiExitHandoff_Scenario1_AuthoritativeSummary(t *testing.T) { if id != "final" { t.Fatalf("requested session %q, want final", id) } - return client.SessionSnapshot{Title: "Server display title", State: "completed", Turns: 7, Usage: client.Usage{InputTokens: 42, OutputTokens: 13, CacheReadTokens: 9, CacheWriteTokens: 3}, ContextOccupancy: &client.ContextOccupancy{InputTokens: 999}}, nil + return client.SessionSnapshot{Title: "Server display title", State: "completed", Turns: 7, + Usage: client.Usage{InputTokens: 1_234_567, OutputTokens: 13_400, CacheReadTokens: 9, CacheWriteTokens: 3}, + AuxiliaryUsage: client.Usage{InputTokens: 2_500, OutputTokens: 40, CacheReadTokens: 1_000}, + ContextOccupancy: &client.ContextOccupancy{InputTokens: 999}}, nil }) got := handoffOutput(t, "final", true, get, nil, false) - for _, line := range []string{"Session: Server display title\n", "Model calls: 7\n", "Tokens (main): 42 input, 13 output, 9 cache read, 3 cache write\n"} { - if !strings.Contains(got, line) { - t.Fatalf("missing %q: %q", line, got) - } + want := "Session ID: final\n" + + "Title: Server display title\n" + + "Model calls: 7\n" + + "Tokens (main): 1.2M input, 13.4K output, 9 cache read, 3 cache write\n" + + "Tokens (aux): 2.5K input, 40 output, 1K cache read\n" + + "Resume: mecatui --resume 'final'\n" + + " mecatui --resume-latest (may select a different chat)\n" + if got != "\n"+want { + t.Fatalf("aligned summary = %q, want suffix %q", got, want) } if strings.Contains(got, "999") { t.Fatalf("context meter leaked into lifetime totals: %q", got) @@ -106,7 +109,7 @@ func TestMecatuiExitHandoff_Scenario1_AuthoritativeSummary(t *testing.T) { got = handoffOutput(t, "final", true, handoffSnapshotGetter(func(context.Context, string) (client.SessionSnapshot, error) { return client.SessionSnapshot{State: "completed", Turns: 0}, nil }), nil, false) - if strings.Contains(got, "Session:") || strings.Contains(got, "cache read") || !strings.Contains(got, "Model calls: 0\n") { + if strings.Contains(got, "Title:") || strings.Contains(got, "cache read") || strings.Contains(got, "Tokens (aux)") || !strings.Contains(got, "Model calls: 0\n") { t.Fatalf("empty title/zero cache = %q", got) } } @@ -116,8 +119,8 @@ func TestMecatuiExitHandoff_Scenario1_SafePresentation(t *testing.T) { got := handoffOutput(t, id, true, nil, nil, false) line := "" for _, l := range strings.Split(got, "\n") { - if strings.HasPrefix(l, "Resume: ") { - line = strings.TrimPrefix(l, "Resume: ") + if strings.HasPrefix(l, "Resume:") { + line = strings.TrimSpace(strings.TrimPrefix(l, "Resume:")) } } if line == "" { @@ -131,14 +134,14 @@ func TestMecatuiExitHandoff_Scenario1_SafePresentation(t *testing.T) { for _, unsafeID := range []string{"two\nlines", "escape\x1b[31m", "bidi\u202eright"} { got := handoffOutput(t, unsafeID, true, nil, nil, false) quoted, _ := json.Marshal(unsafeID) - if !strings.Contains(got, finalSessionHandoffPrefix+string(quoted)+"\n") || strings.Contains(got, "Resume:") || strings.Contains(got, "Or:") { + if !strings.Contains(got, finalSessionHandoffPrefix+string(quoted)+"\n") || strings.Contains(got, "Resume:") || strings.Contains(got, "--resume-latest") || strings.Contains(got, "Session ID:") { t.Fatalf("unsafe ID %q: %q", unsafeID, got) } } got = handoffOutput(t, "final", true, handoffSnapshotGetter(func(context.Context, string) (client.SessionSnapshot, error) { return client.SessionSnapshot{State: "completed", Title: "ok\nFAKE LINE\x1b[31m\u202eevil", Turns: 1}, nil }), nil, false) - if strings.Contains(got, "\x1b") || strings.Contains(got, "\u202e") || strings.Contains(got, "\nFAKE LINE") || strings.Count(got, "Session:") != 1 || !strings.Contains(got, "Session: okFAKE LINE[31mevil\n") { + if strings.Contains(got, "\x1b") || strings.Contains(got, "\u202e") || strings.Contains(got, "\nFAKE LINE") || strings.Count(got, "Title:") != 1 || !strings.Contains(got, "Title: okFAKE LINE[31mevil\n") { t.Fatalf("untrusted title controls/lines: %q", got) } } @@ -163,7 +166,7 @@ func TestMecatuiExitHandoff_Scenario2_SnapshotUnavailable(t *testing.T) { if time.Since(start) > 2*time.Second { t.Fatalf("snapshot blocked cleanup beyond deadline: %s", time.Since(start)) } - if !strings.Contains(got, finalSessionHandoffPrefix+`"final"`+"\n") || !strings.Contains(got, "Resume: mecatui --resume 'final'\n") || strings.Contains(got, "Session:") || strings.Contains(got, "Model calls:") || strings.Contains(got, "Tokens (main):") { + if got != "\nSession ID: final\nResume: mecatui --resume 'final'\n mecatui --resume-latest (may select a different chat)\n" || strings.Contains(got, "Title:") || strings.Contains(got, "Model calls:") || strings.Contains(got, "Tokens (main):") { t.Fatalf("failed snapshot must retain only safe guidance: %q", got) } }) diff --git a/cmd/mecatui/ui/internal/renderfmt/renderfmt.go b/cmd/mecatui/internal/renderfmt/renderfmt.go similarity index 100% rename from cmd/mecatui/ui/internal/renderfmt/renderfmt.go rename to cmd/mecatui/internal/renderfmt/renderfmt.go diff --git a/cmd/mecatui/ui/internal/renderfmt/renderfmt_test.go b/cmd/mecatui/internal/renderfmt/renderfmt_test.go similarity index 99% rename from cmd/mecatui/ui/internal/renderfmt/renderfmt_test.go rename to cmd/mecatui/internal/renderfmt/renderfmt_test.go index 3ce01fa149..978d31883e 100644 --- a/cmd/mecatui/ui/internal/renderfmt/renderfmt_test.go +++ b/cmd/mecatui/internal/renderfmt/renderfmt_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stacklok/mecatl/cmd/mecatui/client" - "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/renderfmt" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" ) func TestHumanizeTokens(t *testing.T) { diff --git a/cmd/mecatui/ui/agents_overlay.go b/cmd/mecatui/ui/agents_overlay.go index 049483c0ce..e11b98335a 100644 --- a/cmd/mecatui/ui/agents_overlay.go +++ b/cmd/mecatui/ui/agents_overlay.go @@ -12,10 +12,10 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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" ) diff --git a/cmd/mecatui/ui/models_surface.go b/cmd/mecatui/ui/models_surface.go index 5c4da771e1..c22a4e814c 100644 --- a/cmd/mecatui/ui/models_surface.go +++ b/cmd/mecatui/ui/models_surface.go @@ -14,9 +14,9 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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. diff --git a/cmd/mecatui/ui/noprogress_test.go b/cmd/mecatui/ui/noprogress_test.go index b088e653a4..4bd8a16ac4 100644 --- a/cmd/mecatui/ui/noprogress_test.go +++ b/cmd/mecatui/ui/noprogress_test.go @@ -5,8 +5,8 @@ import ( "testing" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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" ) diff --git a/cmd/mecatui/ui/plan_approval_test.go b/cmd/mecatui/ui/plan_approval_test.go index 8b2a0e19ec..f15c30375d 100644 --- a/cmd/mecatui/ui/plan_approval_test.go +++ b/cmd/mecatui/ui/plan_approval_test.go @@ -10,8 +10,8 @@ import ( tea "charm.land/bubbletea/v2" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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" ) diff --git a/cmd/mecatui/ui/render.go b/cmd/mecatui/ui/render.go index ebfc1d9f3c..bd6f07046b 100644 --- a/cmd/mecatui/ui/render.go +++ b/cmd/mecatui/ui/render.go @@ -16,9 +16,9 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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" ) diff --git a/cmd/mecatui/ui/sessions_surface.go b/cmd/mecatui/ui/sessions_surface.go index 7efaacf685..71adc83dd6 100644 --- a/cmd/mecatui/ui/sessions_surface.go +++ b/cmd/mecatui/ui/sessions_surface.go @@ -16,10 +16,10 @@ import ( tea "charm.land/bubbletea/v2" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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 diff --git a/cmd/mecatui/ui/statusline_source.go b/cmd/mecatui/ui/statusline_source.go index 25718ba417..1ac15b1f2f 100644 --- a/cmd/mecatui/ui/statusline_source.go +++ b/cmd/mecatui/ui/statusline_source.go @@ -8,7 +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" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" ) type statusLineChangedMsg struct { diff --git a/cmd/mecatui/ui/team.go b/cmd/mecatui/ui/team.go index e3092c2349..35d69a2a8d 100644 --- a/cmd/mecatui/ui/team.go +++ b/cmd/mecatui/ui/team.go @@ -7,10 +7,10 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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 diff --git a/cmd/mecatui/ui/tool_block.go b/cmd/mecatui/ui/tool_block.go index b25a6d16c8..6eadce3e4d 100644 --- a/cmd/mecatui/ui/tool_block.go +++ b/cmd/mecatui/ui/tool_block.go @@ -5,9 +5,9 @@ import ( "strings" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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" ) diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 3c2caa0598..2f43259ae6 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -15,10 +15,10 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/client" customization "github.com/stacklok/mecatl/cmd/mecatui/customization" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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" ) diff --git a/cmd/mecatui/ui/view.go b/cmd/mecatui/ui/view.go index a587a028fd..2b0d247456 100644 --- a/cmd/mecatui/ui/view.go +++ b/cmd/mecatui/ui/view.go @@ -9,9 +9,9 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/internal/renderfmt" "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 diff --git a/user-docs/mecatui/sessions.md b/user-docs/mecatui/sessions.md index d5c554af64..cc8ba9492b 100644 --- a/user-docs/mecatui/sessions.md +++ b/user-docs/mecatui/sessions.md @@ -70,31 +70,47 @@ use another supported client or control path. Repeating the unchanged command will not add watch support. Plan approvals and guardrail reviews use their existing dedicated flows and cannot be recovered this way. -To get the active session ID, run `/session` and press `c` to copy it. On a -normal exit, `mecatui` writes a JSON-quoted ID record to standard error: +To get the active session ID, run `/session` and press `c` to copy it. When an +embedded session ends normally, `mecatui` writes an aligned summary to standard +error: + +```text +Session ID: 01JOPAQUESESSIONID +Title: Fix the flaky CI job +Model calls: 12 +Tokens (main): 29.7K input, 1.8K output, 6.4K cache read +Tokens (aux): 2.1K input, 85 output +Resume: mecatui --resume '01JOPAQUESESSIONID' + mecatui --resume-latest (may select a different chat) +``` + +The shell-quoted `mecatui --resume` command returns to that exact final chat. +The `--resume-latest` alternative below it looks for the newest eligible chat +and can select a different one. A blank line separates the summary from startup +notices, and `mecatui` does not print the embedded server's private socket +address. + +A connected session instead prints a JSON-quoted ID record: ```text mecatui: final-session-id="01JOPAQUESESSIONID" ``` -A blank line separates the exit record from startup notices. `mecatui` does not -print the embedded server's private socket address. - -After an embedded session ends normally, the lines that follow show a -shell-quoted `mecatui --resume ''` command for that exact final chat. -Use it to return to the same chat. The separately labelled `--resume-latest` -alternative looks for the newest eligible chat and can select a different one. -A connected session prints the ID record; to resume it, use -`mecatui connect
--resume ` with the original server address. - -When the session snapshot is available, **Model calls** counts model calls begun -in the chat, and **Tokens (main)** shows lifetime input and output tokens for -that chat. Nonzero cache-read and cache-write counts appear separately, not -added to input or output. Title-generation tokens and the latest context-meter -reading are not included. If the snapshot is unavailable, the exact embedded -resume command and ID record still appear without a summary. An ID that cannot -be safely displayed as a single terminal line retains its JSON ID record but -has no copyable command. +To resume it, use `mecatui connect
--resume ` with the +original server address. + +**Model calls** counts model calls begun in the chat, and **Tokens (main)** +shows lifetime input and output tokens for the chat's agent runs. **Tokens +(aux)** appears when auxiliary model work, such as title generation, +compaction, routing, reviewers, or guardrails, used tokens in the chat. Counts +are abbreviated, for example `29.7K` or `1.2M`. Nonzero cache-read and +cache-write counts appear separately, not added to input or output. The latest +context-meter reading is not included. + +If the session snapshot is unavailable, the title, model-call, and token lines +are omitted; the session ID and resume commands still appear. An embedded ID +that cannot be safely displayed as a single terminal line gets the JSON-quoted +ID record instead of the summary. ## Inspect the active session during a run