From 793e967c69060f4bef6da07f3308f177aff27fc7 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Thu, 15 Oct 2026 16:04:43 -0700 Subject: [PATCH 1/2] feat(mecatui): add comfort transcript presentation Add accent speaker headings and dark/light transcript styles, collapse settled tool groups in comfort view, and preserve per-call details, copy provenance, and reading anchors. Omit proven internal harness control continuations from reloaded human transcripts without altering model history. Co-authored-by: Mecatl --- ...quieter_toolcalls_scenario3_rebind_test.go | 4 +- cmd/mecatui/startup_resume_e2e_test.go | 73 ++++ cmd/mecatui/theme/builtins.go | 12 + cmd/mecatui/theme/registry.go | 4 +- cmd/mecatui/theme/theme.go | 66 ++- cmd/mecatui/theme/theme_test.go | 182 ++++++++ cmd/mecatui/ui/block_cache.go | 11 +- cmd/mecatui/ui/conversation_view.go | 11 +- ...ctional_structured_cards_scenario2_test.go | 4 +- cmd/mecatui/ui/indent_test.go | 4 +- cmd/mecatui/ui/internal/blocks/plain_cards.go | 19 +- .../ui/internal/blocks/plain_cards_test.go | 118 +++++- cmd/mecatui/ui/internal/blocks/prepared.go | 26 +- cmd/mecatui/ui/models_handoff_test.go | 2 +- ...eter_toolcalls_scenario1_lifecycle_test.go | 2 +- .../ui/quieter_toolcalls_scenario1_test.go | 10 +- ...quieter_toolcalls_scenario3_detail_test.go | 8 +- ...uieter_toolcalls_scenario4_longrun_test.go | 7 +- cmd/mecatui/ui/render.go | 55 +-- cmd/mecatui/ui/render_cache_test.go | 33 +- cmd/mecatui/ui/render_wrap_test.go | 5 +- cmd/mecatui/ui/rendered_frame.go | 32 +- cmd/mecatui/ui/rendered_frame_test.go | 15 +- cmd/mecatui/ui/selection.go | 22 +- .../ui/session_continuity_scenario6_test.go | 2 +- cmd/mecatui/ui/speaker_block_test.go | 16 +- cmd/mecatui/ui/structured_blocks.go | 27 +- .../sessions_transcript_rendered.golden | 6 +- cmd/mecatui/ui/testdata/steer_card.golden | 2 +- cmd/mecatui/ui/tool_groups.go | 100 +++++ .../ui/tool_groups_integration_test.go | 400 ++++++++++++++++++ cmd/mecatui/ui/tool_groups_test.go | 381 +++++++++++++++++ cmd/mecatui/ui/toolcall_projection.go | 19 +- cmd/mecatui/ui/toolcall_projection_test.go | 38 ++ .../ui/toolcalls_detail_scenario_test.go | 2 +- cmd/mecatui/ui/toolcalls_presentation_test.go | 4 +- cmd/mecatui/ui/transcript_theme_test.go | 294 +++++++++++++ contracts/gen/go/mecatl/v1/harness.pb.go | 3 +- contracts/proto/mecatl/v1/harness.proto | 3 +- docs/tui.md | 37 +- ...e_pending_approval_tui_integration_test.go | 11 +- internal/adapter/server/transcript.go | 23 +- internal/adapter/server/transcript_test.go | 138 ++++++ .../src/gen/mecatl/v1/harness_pb.ts | 3 +- user-docs/mecatui/keybindings.md | 13 +- user-docs/mecatui/sessions.md | 4 + user-docs/mecatui/themes.md | 10 + user-docs/mecatui/using-the-tui.md | 10 +- user-docs/reference/grpc-schema.md | 3 +- 49 files changed, 2085 insertions(+), 189 deletions(-) create mode 100644 cmd/mecatui/ui/tool_groups.go create mode 100644 cmd/mecatui/ui/tool_groups_integration_test.go create mode 100644 cmd/mecatui/ui/tool_groups_test.go create mode 100644 cmd/mecatui/ui/transcript_theme_test.go diff --git a/cmd/mecatui/quieter_toolcalls_scenario3_rebind_test.go b/cmd/mecatui/quieter_toolcalls_scenario3_rebind_test.go index b357fda7fd..a602026b09 100644 --- a/cmd/mecatui/quieter_toolcalls_scenario3_rebind_test.go +++ b/cmd/mecatui/quieter_toolcalls_scenario3_rebind_test.go @@ -80,12 +80,12 @@ func TestMecatuiQuieterToolCalls_Scenario3_ConfiguredFooterAndShortcuts(t *testi m = updated.(ui.Model) } view := ansi.Strip(m.View().Content) - if !strings.Contains(view, "ctrl+f11 tool calls · ctrl+f12 session details · ctrl+c quit") || strings.Contains(view, "ctrl+f10 tool calls") || strings.Contains(view, "ctrl+g select all") || strings.Contains(view, "ctrl+y copy") || strings.Contains(view, "ctrl+u clear") || strings.Contains(view, "● mecatl") { + if !strings.Contains(view, "ctrl+f11 tool calls · ctrl+f12 session details · ctrl+c quit") || strings.Contains(view, "ctrl+f10 tool calls") || strings.Contains(view, "ctrl+g select all") || strings.Contains(view, "ctrl+y copy") || strings.Contains(view, "ctrl+u clear") || strings.Contains(view, "◀ mecatl") { t.Fatalf("effective settings/CLI shortcuts missing from footer: %s", view) } updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyF12, Mod: tea.ModCtrl}) m = updated.(ui.Model) - if view := ansi.Strip(m.View().Content); !strings.Contains(view, "● mecatl") { + if view := ansi.Strip(m.View().Content); !strings.Contains(view, "◀ mecatl") { t.Fatalf("configured ExpandConversation chord did not reveal speaker: %s", view) } updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyF11, Mod: tea.ModCtrl}) diff --git a/cmd/mecatui/startup_resume_e2e_test.go b/cmd/mecatui/startup_resume_e2e_test.go index 61be69d83d..0d18b9be40 100644 --- a/cmd/mecatui/startup_resume_e2e_test.go +++ b/cmd/mecatui/startup_resume_e2e_test.go @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "github.com/stacklok/mecatl/adapters/jsonlstore" "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/embed" "github.com/stacklok/mecatl/cmd/mecatui/theme" @@ -104,6 +105,78 @@ func TestSessionContinuityUX_Scenario6_EmbeddedAndConnectE2E(t *testing.T) { } } +func TestStartupResumeHidesPersistedHarnessControlNotesE2E(t *testing.T) { + starters := []struct { + name string + start func(*testing.T, app.Config) (string, func()) + }{ + {name: "embedded", start: startStartupResumeEmbedded}, + {name: "connect", start: startStartupResumeConnect}, + } + for _, tc := range starters { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + workspace, storeDir, userModelDir := t.TempDir(), t.TempDir(), t.TempDir() + cfg := app.Config{Workspace: workspace, StoreDir: storeDir, UserModelDir: userModelDir, Model: "mock-model", UseMock: true, Shell: "/bin/sh", Compaction: "heuristic", Tokenizer: "heuristic"} + seed, err := jsonlstore.New(storeDir) + if err != nil { + t.Fatalf("open seed store: %v", err) + } + sess := session.New("persisted-harness-control", session.ModeDefault, session.EnvironmentRef{Kind: session.EnvKindLocal, ID: workspace, Revision: "in-tree-v1"}, session.Limits{}, time.Unix(1, 0)) + const backgroundCompletion = "[harness note: 1 background subagent(s) finished: subagent-job-123 (end_turn). Collect each result with SubagentStatus before relying on it.]" + if err := sess.SeedHistory([]session.Message{ + {Role: session.RoleUser, Text: "retain this prompt", UserPromptProvenance: session.UserPromptProvenancePrincipal}, + {Role: session.RoleUser, Text: session.NoProgressNudgeText, UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: backgroundCompletion, UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "[harness note: background command(s) still running: build]", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleAssistant, Text: "retain this answer"}, + }); err != nil { + t.Fatalf("seed history: %v", err) + } + if err := seed.Save(ctx, sess); err != nil { + t.Fatalf("save seed session: %v", err) + } + + _, stop := tc.start(t, cfg) + stop() + target, stop := tc.start(t, cfg) + defer stop() + cl, err := client.Dial(client.DialConfig{Server: target}) + if err != nil { + t.Fatalf("dial restarted server: %v", err) + } + defer func() { _ = cl.Close() }() + selection, err := resolveStartupResume(ctx, cl, string(sess.ID), false) + if err != nil { + t.Fatalf("resolve startup resume: %v", err) + } + if !selection.Transcript.Complete || len(selection.Transcript.Messages) != 2 || + selection.Transcript.Messages[0].Text != "retain this prompt" || selection.Transcript.Messages[1].Text != "retain this answer" { + t.Fatalf("resumed transcript retained internal control notes or lost messages: %+v", selection.Transcript) + } + m := ui.New(ui.Deps{ + Session: &sessionAdapter{cl: cl, mode: "default"}, Conv: cl, Transcript: cl, + Theme: theme.New("aztec", theme.AztecPalette()), Workspace: workspace, Mode: "default", Model: "mock-model", + Ctx: ctx, NoAltScreen: true, NoBanner: true, Resume: selection, + }) + resized, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 35}) + m = resized.(ui.Model) + content := m.View().Content + for _, want := range []string{"retain this prompt", "retain this", "answer"} { + if !strings.Contains(content, want) { + t.Fatalf("resumed UI omitted retained content %q:\n%s", want, content) + } + } + for _, hidden := range []string{"Please continue.", "subagent-job-123", "background command(s) still running: build"} { + if strings.Contains(content, hidden) { + t.Fatalf("resumed UI displayed harness control note %q:\n%s", hidden, content) + } + } + }) + } +} + func TestSessionsCommand_EmbeddedAndConnectE2E(t *testing.T) { starters := []struct { name string diff --git a/cmd/mecatui/theme/builtins.go b/cmd/mecatui/theme/builtins.go index 63c3b0ae17..a36546bc51 100644 --- a/cmd/mecatui/theme/builtins.go +++ b/cmd/mecatui/theme/builtins.go @@ -9,6 +9,18 @@ const ( solarGold = "#B58900" // solar accent / user / code / number ) +var aztecTranscript = transcriptRecipe{ + llm: "#c98f9d", tool: "#57b8bf", user: "#e9b949", harness: "#aaa0d4", + text: "#e7e2d3", harnessText: "#bac8bf", run: "#6b857a", + reasoning: "#9aaba0", stats: "#9aaba0", +} + +var solarTranscript = transcriptRecipe{ + llm: "#a13d62", tool: "#006f7a", user: "#955b00", harness: "#713bb0", + text: "#202d28", harnessText: "#40554a", run: "#687b70", + reasoning: "#52665b", stats: "#52665b", +} + // aztecPalette is the default mecatui palette: jade/turquoise + gold on obsidian, // terracotta for errors, bone-coloured text. Tuned for dark terminals. It is // also the merge base for partial user themes (see load.go), so every slot here diff --git a/cmd/mecatui/theme/registry.go b/cmd/mecatui/theme/registry.go index 62df212e27..1df75f7fad 100644 --- a/cmd/mecatui/theme/registry.go +++ b/cmd/mecatui/theme/registry.go @@ -12,9 +12,9 @@ const defaultName = "aztec" // the lowest layer of the resolution order; user JSON themes (load.go) override // or extend it. var builtins = map[string]Theme{ - "aztec": New("aztec", aztecPalette), + "aztec": newTheme("aztec", aztecPalette, aztecTranscript), "mono": New("mono", monoPalette), - "solar": New("solar", solarPalette), + "solar": newTheme("solar", solarPalette, solarTranscript), } // Registry holds a set of themes by name and answers Get/List/Default. main.go diff --git a/cmd/mecatui/theme/theme.go b/cmd/mecatui/theme/theme.go index 96d77ba4ca..ae2fca6404 100644 --- a/cmd/mecatui/theme/theme.go +++ b/cmd/mecatui/theme/theme.go @@ -105,15 +105,41 @@ type Theme struct { Name string Palette Palette - styles map[string]lipgloss.Style // derived in compile() - slots map[string]string // slot-name → hex, derived in compile() + styles map[string]lipgloss.Style // derived in compile() + slots map[string]string // slot-name → hex, derived in compile() + transcript transcriptRecipe +} + +// Transcript actor glyphs are text data, separate from their styles. +const ( + TranscriptUserGlyph = "▶" + TranscriptLLMGlyph = "◀" + TranscriptToolGlyph = "●" + TranscriptHarnessGlyph = "◆" +) + +type transcriptRecipe struct { + llm, tool, user, harness string + text, harnessText, run, reasoning, stats string +} + +func transcriptFallback(p Palette) transcriptRecipe { + return transcriptRecipe{ + llm: p.Assistant, tool: p.Tool, user: p.User, harness: p.Info, + text: p.Text, harnessText: p.TextMuted, run: p.Border, + reasoning: p.TextMuted, stats: p.TextMuted, + } } // New builds a Theme from a name and palette, compiling the derived styles. It -// is the only constructor: it guarantees the styles map is populated so Style() -// never returns a zero value for a known slot. +// is the constructor for programmatic and custom themes, whose transcript roles +// derive from their existing palette slots. func New(name string, p Palette) Theme { - t := Theme{Name: name, Palette: p} + return newTheme(name, p, transcriptFallback(p)) +} + +func newTheme(name string, p Palette, transcript transcriptRecipe) Theme { + t := Theme{Name: name, Palette: p, transcript: transcript} t.compile() return t } @@ -181,7 +207,9 @@ func (t Theme) Color(slot string) color.Color { // Style returns the derived lipgloss.Style for a named UI element. Known names: // header, footer, viewport, userBlock, userLabel, assistantLabel, toolCard, // toolName, toolArgs, toolOk, toolErr, askCard, askTitle, askArgs, askButton, -// askButtonActive, spinner, muted, warning, dangerPill, errorText. Unknown names +// askButtonActive, spinner, muted, warning, dangerPill, errorText, and the +// transcript{LLM,Tool,User,Harness}{Marker,Heading,Rail,Text}, transcriptRunRail, +// transcriptReasoningText, and transcriptStatistics semantic roles. Unknown names // return an empty style so callers degrade gracefully rather than panic. func (t Theme) Style(name string) lipgloss.Style { if s, ok := t.styles[name]; ok { @@ -243,6 +271,7 @@ func buildSlots(p Palette) map[string]string { // View() never allocates styles in the hot path. func (t *Theme) compile() { p := t.Palette + tr := t.transcript t.slots = buildSlots(p) rounded := lipgloss.RoundedBorder() thick := lipgloss.ThickBorder() @@ -282,10 +311,7 @@ func (t *Theme) compile() { "viewport": lipgloss.NewStyle(). Foreground(col(p.Text)), - // User prompt block: gold left bar. NO background tint — the faint panel tint - // belongs ONLY to the input box we type in, never the conversation history (the - // tinted history block read as a distinct, off surface). Conversation user turns - // are the gold rail + the "▌ you" label over the plain viewport surface. + // User-coloured left border over the plain viewport surface. "userBlock": lipgloss.NewStyle(). Foreground(col(p.Text)). BorderStyle(lipgloss.NormalBorder()). @@ -299,6 +325,26 @@ func (t *Theme) compile() { Foreground(col(p.Assistant)). Bold(true), + "transcriptLLMMarker": lipgloss.NewStyle().Foreground(col(tr.llm)), + "transcriptLLMHeading": lipgloss.NewStyle().Foreground(col(tr.llm)).Bold(true), + "transcriptLLMRail": lipgloss.NewStyle().Foreground(col(tr.llm)), + "transcriptLLMText": lipgloss.NewStyle().Foreground(col(tr.text)), + "transcriptToolMarker": lipgloss.NewStyle().Foreground(col(tr.tool)), + "transcriptToolHeading": lipgloss.NewStyle().Foreground(col(tr.tool)).Bold(true), + "transcriptToolRail": lipgloss.NewStyle().Foreground(col(tr.tool)), + "transcriptToolText": lipgloss.NewStyle().Foreground(col(tr.text)), + "transcriptUserMarker": lipgloss.NewStyle().Foreground(col(tr.user)), + "transcriptUserHeading": lipgloss.NewStyle().Foreground(col(tr.user)).Bold(true), + "transcriptUserRail": lipgloss.NewStyle().Foreground(col(tr.user)), + "transcriptUserText": lipgloss.NewStyle().Foreground(col(tr.text)), + "transcriptHarnessMarker": lipgloss.NewStyle().Foreground(col(tr.harness)), + "transcriptHarnessHeading": lipgloss.NewStyle().Foreground(col(tr.harness)).Bold(true), + "transcriptHarnessRail": lipgloss.NewStyle().Foreground(col(tr.harness)), + "transcriptHarnessText": lipgloss.NewStyle().Foreground(col(tr.harnessText)), + "transcriptRunRail": lipgloss.NewStyle().Foreground(col(tr.run)), + "transcriptReasoningText": lipgloss.NewStyle().Foreground(col(tr.reasoning)).Italic(true), + "transcriptStatistics": lipgloss.NewStyle().Foreground(col(tr.stats)), + // Tool-call card. "toolCard": lipgloss.NewStyle(). BorderStyle(rounded). diff --git a/cmd/mecatui/theme/theme_test.go b/cmd/mecatui/theme/theme_test.go index 3e810c92e3..3e14100666 100644 --- a/cmd/mecatui/theme/theme_test.go +++ b/cmd/mecatui/theme/theme_test.go @@ -2,9 +2,13 @@ package theme import ( "encoding/json" + "image/color" + "math" "reflect" "strings" "testing" + "unicode" + "unicode/utf8" "charm.land/lipgloss/v2" ) @@ -38,6 +42,11 @@ func TestStylesCompiled(t *testing.T) { "assistantLabel", "toolCard", "toolName", "toolArgs", "toolOk", "toolErr", "askCard", "askTitle", "askArgs", "askButton", "askButtonActive", "spinner", "muted", "warning", "dangerPill", "errorText", "selection", + "transcriptLLMMarker", "transcriptLLMHeading", "transcriptLLMRail", "transcriptLLMText", + "transcriptToolMarker", "transcriptToolHeading", "transcriptToolRail", "transcriptToolText", + "transcriptUserMarker", "transcriptUserHeading", "transcriptUserRail", "transcriptUserText", + "transcriptHarnessMarker", "transcriptHarnessHeading", "transcriptHarnessRail", "transcriptHarnessText", + "transcriptRunRail", "transcriptReasoningText", "transcriptStatistics", } th := New("aztec", aztecPalette) for _, slot := range want { @@ -362,3 +371,176 @@ func TestSelectionFallbackToAccent(t *testing.T) { t.Errorf("fallback selection fg = %v, want luminance-of-accent %v", fg, want) } } + +func TestTranscriptVocabularyBuiltins(t *testing.T) { + cases := []struct { + name string + th Theme + want map[string]string + }{ + { + name: "aztec", + th: NewRegistry().Default(), + want: map[string]string{ + "transcriptLLMMarker": "#c98f9d", "transcriptLLMHeading": "#c98f9d", "transcriptLLMRail": "#c98f9d", "transcriptLLMText": "#e7e2d3", + "transcriptToolMarker": "#57b8bf", "transcriptToolHeading": "#57b8bf", "transcriptToolRail": "#57b8bf", "transcriptToolText": "#e7e2d3", + "transcriptUserMarker": "#e9b949", "transcriptUserHeading": "#e9b949", "transcriptUserRail": "#e9b949", "transcriptUserText": "#e7e2d3", + "transcriptHarnessMarker": "#aaa0d4", "transcriptHarnessHeading": "#aaa0d4", "transcriptHarnessRail": "#aaa0d4", "transcriptHarnessText": "#bac8bf", + "transcriptRunRail": "#6b857a", "transcriptReasoningText": "#9aaba0", "transcriptStatistics": "#9aaba0", + }, + }, + { + name: "solar", + th: Solar(), + want: map[string]string{ + "transcriptLLMMarker": "#a13d62", "transcriptLLMHeading": "#a13d62", "transcriptLLMRail": "#a13d62", "transcriptLLMText": "#202d28", + "transcriptToolMarker": "#006f7a", "transcriptToolHeading": "#006f7a", "transcriptToolRail": "#006f7a", "transcriptToolText": "#202d28", + "transcriptUserMarker": "#955b00", "transcriptUserHeading": "#955b00", "transcriptUserRail": "#955b00", "transcriptUserText": "#202d28", + "transcriptHarnessMarker": "#713bb0", "transcriptHarnessHeading": "#713bb0", "transcriptHarnessRail": "#713bb0", "transcriptHarnessText": "#40554a", + "transcriptRunRail": "#687b70", "transcriptReasoningText": "#52665b", "transcriptStatistics": "#52665b", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for name, want := range tc.want { + if got := tc.th.Style(name).GetForeground(); got != col(want) { + t.Errorf("%s foreground = %v, want %v", name, got, col(want)) + } + } + for _, name := range []string{"transcriptLLMText", "transcriptToolText", "transcriptUserText", "transcriptHarnessText", "transcriptStatistics"} { + if tc.th.Style(name).GetItalic() { + t.Errorf("%s must not be italic", name) + } + } + if !tc.th.Style("transcriptReasoningText").GetItalic() { + t.Error("transcriptReasoningText must be italic") + } + }) + } +} + +func TestTranscriptFallbacksAndIndependentStyles(t *testing.T) { + p := aztecPalette + p.User = "#112233" + p.Assistant = "#223344" + p.Tool = "#334455" + p.Info = "#445566" + p.Text = "#556677" + p.TextMuted = "#667788" + p.Border = "#778899" + th := New("aztec", p) // A user theme can share a built-in's name. + want := map[string]string{ + "transcriptUserMarker": "#112233", "transcriptUserHeading": "#112233", "transcriptUserRail": "#112233", + "transcriptLLMMarker": "#223344", "transcriptLLMHeading": "#223344", "transcriptLLMRail": "#223344", + "transcriptToolMarker": "#334455", "transcriptToolHeading": "#334455", "transcriptToolRail": "#334455", + "transcriptHarnessMarker": "#445566", "transcriptHarnessHeading": "#445566", "transcriptHarnessRail": "#445566", + "transcriptUserText": "#556677", "transcriptLLMText": "#556677", "transcriptToolText": "#556677", + "transcriptHarnessText": "#667788", "transcriptReasoningText": "#667788", "transcriptStatistics": "#667788", + "transcriptRunRail": "#778899", + } + for name, want := range want { + if got := th.Style(name).GetForeground(); got != col(want) { + t.Errorf("%s foreground = %v, want %v", name, got, col(want)) + } + } + + marker := th.Style("transcriptUserMarker").Foreground(col("#abcdef")) + if got := marker.GetForeground(); got != col("#abcdef") { + t.Errorf("changed marker foreground = %v, want %v", got, col("#abcdef")) + } + if got := th.Style("transcriptUserRail").GetForeground(); got != col("#112233") { + t.Errorf("rail changed with marker = %v, want %v", got, col("#112233")) + } + + parsed, err := ParseTheme([]byte(`{"name":"aztec","palette":{"user":"#010203"}}`)) + if err != nil { + t.Fatalf("ParseTheme: %v", err) + } + if got := parsed.Style("transcriptUserMarker").GetForeground(); got != col("#010203") { + t.Errorf("partial custom user marker = %v, want %v", got, col("#010203")) + } + if got := parsed.Style("transcriptLLMMarker").GetForeground(); got != col(aztecPalette.Assistant) { + t.Errorf("partial custom LLM marker = %v, want palette assistant %v", got, col(aztecPalette.Assistant)) + } + + mono := New("mono", monoPalette) + if got := mono.Style("transcriptLLMMarker").GetForeground(); got != col(monoPalette.Assistant) { + t.Errorf("mono LLM marker = %v, want palette assistant %v", got, col(monoPalette.Assistant)) + } +} + +func TestTranscriptGlyphsAreSingleCellTerminalText(t *testing.T) { + for _, tc := range []struct { + name string + glyph string + }{ + {"user", TranscriptUserGlyph}, + {"LLM", TranscriptLLMGlyph}, + {"tool", TranscriptToolGlyph}, + {"harness", TranscriptHarnessGlyph}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.glyph == "" || !utf8.ValidString(tc.glyph) { + t.Errorf("glyph = %q, want valid non-empty UTF-8", tc.glyph) + } + if got := lipgloss.Width(tc.glyph); got != 1 { + t.Errorf("glyph width = %d, want 1", got) + } + for _, r := range tc.glyph { + if unicode.IsControl(r) { + t.Errorf("glyph contains control character %q", r) + } + } + }) + } +} + +func TestTranscriptContrast(t *testing.T) { + for _, tc := range []struct { + name string + th Theme + background string + }{ + {"aztec", NewRegistry().Default(), aztecPalette.Bg}, + {"solar", Solar(), solarPalette.Bg}, + {"solar on white", Solar(), "#ffffff"}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, name := range []string{"transcriptLLMMarker", "transcriptToolMarker", "transcriptUserMarker", "transcriptHarnessMarker", "transcriptLLMText", "transcriptToolText", "transcriptUserText", "transcriptHarnessText", "transcriptReasoningText", "transcriptStatistics"} { + if got := styleContrast(tc.th.Style(name), tc.background); got < 4.5 { + t.Errorf("%s contrast = %.2f, want >= 4.5", name, got) + } + } + for _, name := range []string{"transcriptLLMRail", "transcriptToolRail", "transcriptUserRail", "transcriptHarnessRail", "transcriptRunRail"} { + if got := styleContrast(tc.th.Style(name), tc.background); got < 3 { + t.Errorf("%s contrast = %.2f, want >= 3", name, got) + } + } + }) + } +} + +func styleContrast(st lipgloss.Style, background string) float64 { + fg := colorLuminance(st.GetForeground()) + bg, ok := relLuminance(background) + if !ok { + return 0 + } + if fg < bg { + fg, bg = bg, fg + } + return (fg + 0.05) / (bg + 0.05) +} + +func colorLuminance(c color.Color) float64 { + r, g, b, _ := c.RGBA() + linear := func(v uint32) float64 { + n := float64(v) / 65535 + if n <= 0.04045 { + return n / 12.92 + } + return math.Pow((n+0.055)/1.055, 2.4) + } + return 0.2126*linear(r) + 0.7152*linear(g) + 0.0722*linear(b) +} diff --git a/cmd/mecatui/ui/block_cache.go b/cmd/mecatui/ui/block_cache.go index 2b60844fb5..56073a215f 100644 --- a/cmd/mecatui/ui/block_cache.go +++ b/cmd/mecatui/ui/block_cache.go @@ -43,11 +43,12 @@ type blockRenderOutput struct { // renderPass is the renderer-owned handoff from cache lookup to frame assembly. // It retains only cache identity, rendered output, and lockstep provenance. type renderPass struct { - id uint64 - revision int - kind scrollback.Kind - text string - rows []renderedRow + id uint64 + revision int + kind scrollback.Kind + text string + rows []renderedRow + toolState toolcallProjectionState } // joinPrefixState is the validity key of the cached incremental-join prefix. diff --git a/cmd/mecatui/ui/conversation_view.go b/cmd/mecatui/ui/conversation_view.go index a69b904694..8c887a8017 100644 --- a/cmd/mecatui/ui/conversation_view.go +++ b/cmd/mecatui/ui/conversation_view.go @@ -17,8 +17,12 @@ func (v *conversationView) observe(vp viewport.Model) { return } if anchor, ok := v.frame.observedAnchorForRow(vp.YOffset(), towardStart); ok { - anchor.bias = towardStart - v.anchor = anchor + // A collapsed member still owns the reading position while its summary + // remains at the viewport edge. F9 can then restore that exact member. + if anchor.region != conversationRegionToolSummary || v.frame.toolSummaryRow(v.anchor.blockID) != vp.YOffset() { + anchor.bias = towardStart + v.anchor = anchor + } } v.mode = anchored } @@ -86,6 +90,9 @@ func (conversationView) restore(frame renderedFrame, anchor readingAnchor) int { return row } } + if row := frame.toolSummaryRow(anchor.blockID); row >= 0 { + return row + } if row := sameBlockRow(frame, anchor); row >= 0 { return row } diff --git a/cmd/mecatui/ui/functional_structured_cards_scenario2_test.go b/cmd/mecatui/ui/functional_structured_cards_scenario2_test.go index f2301bf9cb..9553a9793a 100644 --- a/cmd/mecatui/ui/functional_structured_cards_scenario2_test.go +++ b/cmd/mecatui/ui/functional_structured_cards_scenario2_test.go @@ -25,8 +25,8 @@ func TestMecatuiFunctionalConversationCards_Scenario2_PerFamilyPreparedBlocksPre payload scrollback.PayloadSnapshot want, bad []string }{ - {"user media", scrollback.UserCardSnapshot{Text: "hello\nworld", Media: []string{"image/png (inline)", "audio/wav (inline)"}}, []string{"▌ you", "hello", "world", "📎 image/png", "📎 audio/wav"}, nil}, - {"ordinary notice", scrollback.NoticeCardSnapshot{Text: "compacted"}, []string{"• compacted"}, nil}, + {"user media", scrollback.UserCardSnapshot{Text: "hello\nworld", Media: []string{"image/png (inline)", "audio/wav (inline)"}}, []string{"▶ you", "hello", "world", "📎 image/png", "📎 audio/wav"}, nil}, + {"ordinary notice", scrollback.NoticeCardSnapshot{Text: "compacted"}, []string{"◆ system", "compacted"}, nil}, {"recovery notice", scrollback.NoticeCardSnapshot{Text: "recover this turn", Recover: true}, []string{"⚠ recover this turn"}, nil}, {"hook", scrollback.HookCardSnapshot{Text: "PreToolUse hook rewrote tool arguments", Phase: "PreToolUse", Tool: "Shell", Decision: string(client.HookModified)}, []string{"✎ hook PreToolUse · Shell: modified", "rewrote tool arguments"}, []string{"PreToolUse hook rewrote"}}, {"transient error", scrollback.ErrorCardSnapshot{Text: "upstream unavailable"}, []string{"✗ upstream unavailable"}, []string{"retrying won't help"}}, diff --git a/cmd/mecatui/ui/indent_test.go b/cmd/mecatui/ui/indent_test.go index b1f7b86b2b..eaa9cb4370 100644 --- a/cmd/mecatui/ui/indent_test.go +++ b/cmd/mecatui/ui/indent_test.go @@ -185,11 +185,11 @@ func TestAssistantBodyHangsUnderLabel(t *testing.T) { out := r.renderConversation(c, true) wantCol := r.indent + assistantBodyHang // base + marker width - asstCol := bodyTextColumn(t, out, "● mecatl", 'a') + asstCol := bodyTextColumn(t, out, "◀ mecatl", 'a') if asstCol != wantCol { t.Errorf("assistant body text column = %d, want %d (base %d + hang %d, under \"mecatl\")", asstCol, wantCol, r.indent, assistantBodyHang) } - userCol := bodyTextColumn(t, out, "▌ you", 'u') + userCol := bodyTextColumn(t, out, "▶ you", 'u') if userCol != asstCol { t.Errorf("user body text column = %d, assistant = %d — the two message bodies must align under their labels", userCol, asstCol) } diff --git a/cmd/mecatui/ui/internal/blocks/plain_cards.go b/cmd/mecatui/ui/internal/blocks/plain_cards.go index 17dce11a5d..d50277be2c 100644 --- a/cmd/mecatui/ui/internal/blocks/plain_cards.go +++ b/cmd/mecatui/ui/internal/blocks/plain_cards.go @@ -1,6 +1,10 @@ package blocks -import "strings" +import ( + "strings" + + "charm.land/lipgloss/v2" +) // UserInput is caller-owned user-prompt content. type UserInput struct { @@ -21,7 +25,13 @@ func SnapshotUser(in UserInput) UserSnapshot { // PrepareUser prepares a user-prompt block. func PrepareUser(in UserSnapshot, layout PlainLayout, theme Theme) Prepared { - lines := []string{theme.UserLabel.Render("▌ you"), wrapStyled(SanitizePlain(in.text), theme.UserBody, layout.width)} + label := theme.UserMarker.Render(theme.UserGlyph) + theme.UserLabel.Render(" you") + body := theme.UserBody. + BorderStyle(lipgloss.NormalBorder()). + BorderLeft(true). + BorderForeground(theme.UserRail.GetForeground()). + PaddingLeft(1) + lines := []string{label, wrapStyled(SanitizePlain(in.text), body, layout.width)} for _, media := range in.media { lines = append(lines, wrapPrefixed("📎 ", SanitizePlain(media), theme.Muted, layout.width)) } @@ -50,7 +60,8 @@ func PrepareNotice(in NoticeSnapshot, layout PlainLayout, theme Theme) Prepared if in.recovery { return preparePlain([]string{wrapPrefixed("⚠ ", SanitizePlain(in.text), theme.Warning, layout.width)}, 0) } - return preparePlain([]string{wrapPrefixed("• ", SanitizePlain(in.text), theme.Muted, layout.width)}, 0) + label := theme.HarnessMarker.Render(theme.HarnessGlyph) + theme.HarnessLabel.Render(" system") + return preparePlain([]string{label, wrapStyled(SanitizePlain(in.text), theme.HarnessText, layout.width)}, 1) } // HookInput is caller-owned structured hook content. @@ -165,5 +176,5 @@ func SnapshotTurnStat(in TurnStatInput) TurnStatSnapshot { // PrepareTurnStat prepares a turn-stat line. func PrepareTurnStat(in TurnStatSnapshot, layout PlainLayout, theme Theme) Prepared { - return preparePlain([]string{wrapStyled(SanitizePlain(in.text), theme.Muted, layout.width)}, 0) + return preparePlain([]string{wrapStyled(SanitizePlain(in.text), theme.Statistics, layout.width)}, 0) } diff --git a/cmd/mecatui/ui/internal/blocks/plain_cards_test.go b/cmd/mecatui/ui/internal/blocks/plain_cards_test.go index fdc1a22514..06af3a68bf 100644 --- a/cmd/mecatui/ui/internal/blocks/plain_cards_test.go +++ b/cmd/mecatui/ui/internal/blocks/plain_cards_test.go @@ -1,6 +1,15 @@ package blocks -import "testing" +import ( + "reflect" + "strconv" + "strings" + "testing" + "unicode/utf8" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) // TestDeliveryBodyForDisplay pins the operator-facing display transform: the // recorded delivery note is fenced-untrusted with a provenance header (both @@ -51,3 +60,110 @@ func TestDeliveryBodyForDisplay(t *testing.T) { }) } } + +func TestTranscriptNoticeStylesPreserveWrappingAndProvenance(t *testing.T) { + th := Theme{ + HarnessGlyph: "◆", + HarnessMarker: lipgloss.NewStyle().Foreground(lipgloss.Color("#665086")), + HarnessLabel: lipgloss.NewStyle().Foreground(lipgloss.Color("#665086")).Bold(true), + HarnessText: lipgloss.NewStyle().Foreground(lipgloss.Color("#40554a")), + Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("#778899")).Italic(true), + Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("#aa5500")), + } + const raw = "first 界 • body\nsecond\x1b[2J line wraps here" + for _, width := range []int{0, 2, 3, 4, 8, 24} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + layout := SnapshotPlainLayout(PlainLayoutInput{Width: width}) + got := PrepareNotice(SnapshotNotice(NoticeInput{Text: raw}), layout, th) + base := preparePlain([]string{th.HarnessMarker.Render("◆") + th.HarnessLabel.Render(" system"), wrapStyled(SanitizePlain(raw), th.HarnessText, width)}, 1) + if ansi.Strip(got.Text()) != ansi.Strip(base.Text()) || !reflect.DeepEqual(got.Rows, base.Rows) { + t.Fatalf("notice changed base wrapping/provenance: got %+v, base %+v", got, base) + } + if !strings.Contains(got.Text(), th.HarnessMarker.Render("◆")) || + !strings.Contains(got.Text(), "38;2;64;85;74") || + strings.Contains(got.Text(), "\x1b[3;") || strings.Contains(got.Text(), "\x1b[3m") || + strings.Contains(got.Text(), "\x1b[2J") { + t.Fatalf("notice lost separate marker/nonitalic body or sanitization: %q", got.Text()) + } + recovery := PrepareNotice(SnapshotNotice(NoticeInput{Text: raw, Recovery: true}), layout, th) + oldRecovery := preparePlain([]string{wrapPrefixed("⚠ ", SanitizePlain(raw), th.Warning, width)}, 0) + if !reflect.DeepEqual(recovery, oldRecovery) { + t.Fatal("recovery warning changed") + } + }) + } +} + +func TestTranscriptPlainCardsSanitizeRawBodies(t *testing.T) { + body := lipgloss.NewStyle().Foreground(lipgloss.Color("#202d28")) + muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#778899")).Italic(true) + th := Theme{ + UserGlyph: "▶", UserMarker: body, UserLabel: body.Bold(true), UserRail: body, UserBody: body, + HarnessGlyph: "◆", HarnessMarker: body, HarnessLabel: body.Bold(true), HarnessText: body, Statistics: body, Muted: muted, + } + baseUserBody := body.BorderStyle(lipgloss.NormalBorder()).BorderLeft(true).BorderForeground(body.GetForeground()).PaddingLeft(1) + for _, fixture := range []struct{ name, raw, clean string }{ + {"controls", "body\x00\x07\r\x7f\u009b\u202e \x1b[2J\x1b]0;title\x07\n界 tail", "body [2J]0;title\n界 tail"}, + {"invalid UTF8", "body\xff\xc0\xaf\x00 tail\n界", "body��� tail\n界"}, + } { + if got := SanitizePlain(fixture.raw); got != fixture.clean { + t.Fatalf("%s fixture sanitizes to %q, want %q", fixture.name, got, fixture.clean) + } + for _, width := range []int{2, 8, 24, 80} { + layout := SnapshotPlainLayout(PlainLayoutInput{Width: width}) + for _, card := range []struct { + name string + got, base Prepared + }{ + {"user", PrepareUser(SnapshotUser(UserInput{Text: fixture.raw}), layout, th), + preparePlain([]string{th.UserLabel.Render("▶ you"), wrapStyled(fixture.clean, baseUserBody, width)}, 1)}, + {"notice", PrepareNotice(SnapshotNotice(NoticeInput{Text: fixture.raw}), layout, th), + preparePlain([]string{th.HarnessMarker.Render("◆") + th.HarnessLabel.Render(" system"), wrapStyled(fixture.clean, body, width)}, 1)}, + {"statistics", PrepareTurnStat(SnapshotTurnStat(TurnStatInput{Text: fixture.raw}), layout, th), + preparePlain([]string{wrapStyled(fixture.clean, muted, width)}, 0)}, + } { + t.Run(fixture.name+"/"+strconv.Itoa(width)+"/"+card.name, func(t *testing.T) { + out := card.got.Text() + visible := ansi.Strip(out) + if !utf8.ValidString(out) || SanitizePlain(visible) != visible || + strings.Contains(out, "\x1b[2J") || strings.Contains(out, "\x1b]0;") { + t.Fatalf("unsafe rendered body: %q", out) + } + if visible != ansi.Strip(card.base.Text()) || !reflect.DeepEqual(card.got.Rows, card.base.Rows) { + t.Fatalf("sanitized body changed base wrapping/provenance: got %+v, base %+v", card.got, card.base) + } + var sourceBody strings.Builder + for i, row := range card.got.Rows { + if row.Text { + sourceBody.WriteString(ansi.Strip(card.got.Lines[i])) + } + } + bodyText := strings.NewReplacer("│", "", "◆", "").Replace(sourceBody.String()) + if strings.Join(strings.Fields(bodyText), "") != strings.Join(strings.Fields(fixture.clean), "") { + t.Fatalf("meaningful sanitized source body lost: %q", bodyText) + } + }) + } + } + } +} + +func TestTranscriptStatisticsNonitalicPreserveWrappingAndProvenance(t *testing.T) { + th := Theme{ + Statistics: lipgloss.NewStyle().Foreground(lipgloss.Color("#52665b")), + Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("#778899")).Italic(true), + } + const raw = "turn 1 · ↑1.2k ↓300 · 2.1s" + for _, width := range []int{0, 2, 8, 24, 80} { + layout := SnapshotPlainLayout(PlainLayoutInput{Width: width}) + got := PrepareTurnStat(SnapshotTurnStat(TurnStatInput{Text: raw}), layout, th) + base := preparePlain([]string{wrapStyled(raw, th.Muted, width)}, 0) + if ansi.Strip(got.Text()) != ansi.Strip(base.Text()) || !reflect.DeepEqual(got.Rows, base.Rows) { + t.Fatalf("width %d: statistics changed base wrapping/provenance", width) + } + if !strings.Contains(got.Text(), "\x1b[38;2;82;102;91m") || + strings.Contains(got.Text(), "\x1b[3;") || strings.Contains(got.Text(), "\x1b[3m") { + t.Fatalf("width %d: statistics lost semantic nonitalic style: %q", width, got.Text()) + } + } +} diff --git a/cmd/mecatui/ui/internal/blocks/prepared.go b/cmd/mecatui/ui/internal/blocks/prepared.go index 2a0b1eb666..faf4e45ebd 100644 --- a/cmd/mecatui/ui/internal/blocks/prepared.go +++ b/cmd/mecatui/ui/internal/blocks/prepared.go @@ -15,15 +15,23 @@ import ( // Theme contains the resolved semantic styles required to prepare conversation blocks. // The UI maps its stylesheet roles to this value at the package boundary. type Theme struct { - UserLabel lipgloss.Style - UserBody lipgloss.Style - Muted lipgloss.Style - Warning lipgloss.Style - Error lipgloss.Style - HookModified lipgloss.Style - HookAdvisory lipgloss.Style - DeliveryHead lipgloss.Style - ToolCard lipgloss.Style + UserGlyph string + UserMarker lipgloss.Style + UserLabel lipgloss.Style + UserRail lipgloss.Style + UserBody lipgloss.Style + HarnessGlyph string + HarnessMarker lipgloss.Style + HarnessLabel lipgloss.Style + HarnessText lipgloss.Style + Statistics lipgloss.Style + Muted lipgloss.Style + Warning lipgloss.Style + Error lipgloss.Style + HookModified lipgloss.Style + HookAdvisory lipgloss.Style + DeliveryHead lipgloss.Style + ToolCard lipgloss.Style } // Region identifies a block-local semantic area. It has no document identity. diff --git a/cmd/mecatui/ui/models_handoff_test.go b/cmd/mecatui/ui/models_handoff_test.go index ea2f8416b8..ff0a89231a 100644 --- a/cmd/mecatui/ui/models_handoff_test.go +++ b/cmd/mecatui/ui/models_handoff_test.go @@ -78,7 +78,7 @@ func TestModelSwitchAdoptsAuthoritativeTargetTranscript(t *testing.T) { t.Fatalf("adopted state = phase:%v session:%q, want idle target", m.phase, m.sessionID) } view := stripANSIstr(m.View().Content) - for _, text := range []string{"target user", "target assistant before tool", "✓ Read · target.txt", "target assistant after tool"} { + for _, text := range []string{"target user", "target assistant before tool", "● 1 tool run", "target assistant after tool"} { if !strings.Contains(view, text) { t.Fatalf("adopted transcript omitted %q:\n%s", text, view) } diff --git a/cmd/mecatui/ui/quieter_toolcalls_scenario1_lifecycle_test.go b/cmd/mecatui/ui/quieter_toolcalls_scenario1_lifecycle_test.go index 9ee364c8c4..049afbbd6b 100644 --- a/cmd/mecatui/ui/quieter_toolcalls_scenario1_lifecycle_test.go +++ b/cmd/mecatui/ui/quieter_toolcalls_scenario1_lifecycle_test.go @@ -17,7 +17,7 @@ func TestMecatuiQuieterToolCalls_Scenario1_OutOfOrderAndResume(t *testing.T) { check := func(m Model, callID, wantStatus string, settled bool, result string) { t.Helper() id := toolBlockID(t, m.conv.scrollback, callID) - frame := m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame := m.rend.renderConversationFrame(&m.conv.scrollback, true) rows := blockRows(frame, id) m.phase = phaseIdle inspected := openToolcallsForTest(t, m) diff --git a/cmd/mecatui/ui/quieter_toolcalls_scenario1_test.go b/cmd/mecatui/ui/quieter_toolcalls_scenario1_test.go index bc784310eb..5020e709e0 100644 --- a/cmd/mecatui/ui/quieter_toolcalls_scenario1_test.go +++ b/cmd/mecatui/ui/quieter_toolcalls_scenario1_test.go @@ -22,7 +22,7 @@ func TestSkillSummaryIncludesNameOnConversationAndInspector(t *testing.T) { ) for _, width := range []int{28, 100} { m.rend.setWidth(width) - frame := m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame := m.rend.renderConversationFrame(&m.conv.scrollback, true) rows := blockRows(frame, toolBlockID(t, m.conv.scrollback, "skill")) if len(rows) != 1 || strings.TrimSpace(stripANSIstr(rows[0])) != "✓ Skill · test-writer" || ansi.StringWidth(rows[0]) > width { t.Fatalf("width %d Skill conversation line = %q, want one bounded named row", width, rows) @@ -49,7 +49,7 @@ func TestMecatuiQuieterToolCalls_Scenario1_PendingAndSettledParity(t *testing.T) m, _, _ := newTestModel(t, theme.New("aztec", theme.AztecPalette())) m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 30}, client.ToolCallMsg{ID: "read", Name: "Read", Args: `{"path":"greeting.txt"}`}) - pending := m.rend.renderConversationFrame(&m.conv.scrollback, false) + pending := m.rend.renderConversationFrame(&m.conv.scrollback, true) pendingID := toolBlockID(t, m.conv.scrollback, "read") if got := blockRows(pending, pendingID); len(got) < 2 || !strings.Contains(strings.Join(got, "\n"), "Read") || !strings.Contains(strings.Join(got, "\n"), "greeting.txt") { t.Fatalf("pending call must retain its bounded bordered card with action and target, got:\n%s", strings.Join(got, "\n")) @@ -70,7 +70,7 @@ func TestMecatuiQuieterToolCalls_Scenario1_PendingAndSettledParity(t *testing.T) client.ToolResultMsg{CallID: "write", Content: "written"}, ) - frame := m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame := m.rend.renderConversationFrame(&m.conv.scrollback, true) for _, want := range []struct { call, line string }{ @@ -186,7 +186,7 @@ func TestMecatuiQuieterToolCalls_Scenario1_SharedIntentAndSafety(t *testing.T) { for _, width := range []int{20, 12, 1} { m.rend.setWidth(width) - frame := m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame := m.rend.renderConversationFrame(&m.conv.scrollback, true) for _, entry := range m.toolcallEntries() { rows := blockRows(frame, uint64(entry.blockID)) if len(rows) != 1 { @@ -203,7 +203,7 @@ func TestMecatuiQuieterToolCalls_Scenario1_SharedIntentAndSafety(t *testing.T) { } m.rend.setWidth(160) - frame := m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame := m.rend.renderConversationFrame(&m.conv.scrollback, true) entries := m.toolcallEntries() m.phase = phaseIdle modal, _ := m.runToolcalls() diff --git a/cmd/mecatui/ui/quieter_toolcalls_scenario3_detail_test.go b/cmd/mecatui/ui/quieter_toolcalls_scenario3_detail_test.go index 696daed1b0..e21fd2bd4d 100644 --- a/cmd/mecatui/ui/quieter_toolcalls_scenario3_detail_test.go +++ b/cmd/mecatui/ui/quieter_toolcalls_scenario3_detail_test.go @@ -139,24 +139,24 @@ func TestConversationDetailToggleShowsTurnChromeOnlyOnF9(t *testing.T) { m.phase = phaseIdle m.refreshView() collapsed := detailText(m) - for _, want := range []string{"Please check the files", "I will inspect them.", "notice stays visible", "reasoning summary", "✓ Read", "✓ Grep"} { + for _, want := range []string{"Please check the files", "I will inspect them.", "notice stays visible", "reasoning summary", "● 2 tools run"} { if !strings.Contains(collapsed, want) { t.Errorf("normal conversation lacks %q: %q", want, collapsed) } } - for _, hidden := range []string{"● mecatl", "↑1.2K ↓100", "↑1.3K ↓110", "4.1s", "3.1s", "why this tool is needed", "changed this session", "appendix-only.go"} { + for _, hidden := range []string{"↑1.2K ↓100", "↑1.3K ↓110", "4.1s", "3.1s", "why this tool is needed", "changed this session", "appendix-only.go"} { if strings.Contains(collapsed, hidden) { t.Errorf("normal conversation shows hidden detail %q: %q", hidden, collapsed) } } m, _ = pressKey(m, f9) expanded := detailText(m) - for _, want := range []string{"● mecatl", "↑1.2K ↓100 · 4.1s", "↑1.3K ↓110 · 3.1s", "why this tool is needed", "changed this session", " appendix-only.go", "I will inspect them.", "notice stays visible", "✓ Read", "✓ Grep"} { + for _, want := range []string{"◀ mecatl", "↑1.2K ↓100 · 4.1s", "↑1.3K ↓110 · 3.1s", "why this tool is needed", "changed this session", " appendix-only.go", "I will inspect them.", "notice stays visible", "✓ Read", "✓ Grep"} { if !strings.Contains(expanded, want) { t.Errorf("F9 view lacks %q: %q", want, expanded) } } - if count := strings.Count(expanded, "● mecatl"); count != 1 { + if count := strings.Count(expanded, "◀ mecatl"); count != 1 { t.Errorf("F9 resurrected empty assistant heading: count=%d", count) } m, _ = pressKey(m, f9) diff --git a/cmd/mecatui/ui/quieter_toolcalls_scenario4_longrun_test.go b/cmd/mecatui/ui/quieter_toolcalls_scenario4_longrun_test.go index 57ae6cce6a..5747661bfa 100644 --- a/cmd/mecatui/ui/quieter_toolcalls_scenario4_longrun_test.go +++ b/cmd/mecatui/ui/quieter_toolcalls_scenario4_longrun_test.go @@ -47,11 +47,12 @@ func TestMecatuiQuieterToolCalls_Scenario4_LongRunAnchorsAndSelection(t *testing } m.conv.appendAssistant("ordinary assistant block after the run") m.phase = phaseIdle + m, _ = pressKey(m, f9) // retain the individual-line selection/reflow regression m.refreshView() for _, width := range []int{28, 100} { m.rend.setWidth(width) - frame := m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame := m.rend.renderConversationFrame(&m.conv.scrollback, true) firstID := toolBlockID(t, m.conv.scrollback, "settled-000") assertLongRunGap(t, frame, userID, assistantID, 1) assertLongRunGap(t, frame, assistantID, noticeID, 1) @@ -72,7 +73,7 @@ func TestMecatuiQuieterToolCalls_Scenario4_LongRunAnchorsAndSelection(t *testing t.Fatalf("width %d live call lost its bounded card: %q", width, liveRows) } plain := ansi.Strip(strings.Join(frame.lines, "\n")) - if !strings.Contains(plain, "long run request") || !strings.Contains(plain, "starting the long run") || !strings.Contains(plain, "readable long-run notice") || !strings.Contains(plain, "ordinary assistant block") { + if !strings.Contains(plain, "long run request") || !strings.Contains(plain, "starting the long run") || !strings.Contains(plain, "history compacted") || !strings.Contains(plain, "long-run notice") || !strings.Contains(plain, "ordinary assistant block") { t.Fatalf("width %d lost ordinary readable conversation blocks:\n%s", width, plain) } } @@ -102,7 +103,7 @@ func TestMecatuiQuieterToolCalls_Scenario4_LongRunAnchorsAndSelection(t *testing m.refreshView() for _, width := range []int{28, 100} { m.rend.setWidth(width) - frame := m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame := m.rend.renderConversationFrame(&m.conv.scrollback, true) rows := blockRows(frame, liveID) if len(rows) != 1 || strings.ContainsAny(rows[0], "╭╮╰╯┌┐└┘─│") { t.Fatalf("width %d settled preceding call = %q, want one borderless line", width, rows) diff --git a/cmd/mecatui/ui/render.go b/cmd/mecatui/ui/render.go index 3ac6fc9661..93bab14f29 100644 --- a/cmd/mecatui/ui/render.go +++ b/cmd/mecatui/ui/render.go @@ -236,9 +236,9 @@ type inputRenderKey struct { const defaultBlockIndent = 1 // assistantBodyHang is the extra left indent (cells) for assistant text and -// reasoning. In the F9 detail view it aligns the body under the "● mecatl" +// reasoning. In the F9 detail view it aligns the body under the "◀ mecatl" // label; in the normal view it keeps prose inset from tool lines. It equals the -// label marker width lipgloss.Width("● ") = 2. Applied on top of the base block +// label marker width lipgloss.Width("◀ ") = 2. Applied on top of the base block // indent; the markdown wrap budget subtracts it so wrapped text fits r.width. const assistantBodyHang = 2 @@ -631,6 +631,8 @@ func (r *renderer) renderPasses(c *scrollback.Conversation, expand bool) ([]rend switch meta.Kind { case scrollback.KindTool, scrollback.KindSubagent, scrollback.KindTeam: effectiveExpand = false + metadata, _ := c.ToolCallMetadataAt(i) + pass.toolState = toolCallState(metadata) } key := blockRenderKey{revision: pass.revision, context: r.renderContext(effectiveExpand)} if entry, ok := r.blocks.renderedBlock(i, key); ok { @@ -705,31 +707,17 @@ func (r *renderer) renderAssistantSnapshotFresh(idx int, p scrollback.AssistantC } body = padLines(reasoning, assistantBodyHang) + body } - if !expand { - return body - } - return r.th.Style("assistantLabel").Render("● mecatl") + "\n\n" + body + label := r.th.Style("transcriptLLMMarker").Render(theme.TranscriptLLMGlyph) + r.th.Style("transcriptLLMHeading").Render(" mecatl") + return label + "\n\n" + body } func (r *renderer) renderConversationLines(c *scrollback.Conversation, expand bool) []string { return r.renderConversationFrame(c, expand).lines } -// The inter-block separator is written BEFORE every block after the first by the -// string-path join (renderConversation); each block already ends with a trailing "\n", -// so the on-screen gap between two blocks is (trailing "\n") + separator. The lines-path -// (appendSegmentLines) MUST mirror this exactly (the matching blank-"" count before each -// block i>0) or the cache-equivalence oracle (render_cache_test.go) trips. -// -// The production frame path uses blockBlankLinesAfterPasses; the string-path -// oracle mirrors it, including the settled-tool → assistant exception below. -// -// Spacing policy (compact throughout): -// - settled tool → assistant : 1 blank line — separate prose from the call run -// - other tool → any : 0 blank lines — tool boxes cluster tight -// - blockAssistant → blockTurnStat : 0 blank lines — empty turns need no gap before stats -// - blockTurnStat → any : 1 blank line — compact stat annotation -// - everything else : 1 blank line — user↔assistant, assistant→tool, etc. +// Frame assembly and the string oracle share the spacing policy: one blank row +// between visible response blocks/groups, none inside a tool group or between +// an assistant and its attached turn statistics. Internal card spacing is retained. const ( interBlockSepCompact = "\n" interBlockBlankLinesCompact = 1 // == strings.Count(trailing-"\n" + interBlockSepCompact, "\n") - 1 @@ -739,28 +727,17 @@ const ( ) func blockBlankLinesBetween(previous, current scrollback.Kind) int { - switch previous { - case scrollback.KindTool, scrollback.KindSubagent, scrollback.KindTeam: + if toolFamily(previous) && toolFamily(current) { return interBlockBlankLinesNone - case scrollback.KindAssistant: - if current == scrollback.KindTurnStat { - return interBlockBlankLinesNone - } } - return interBlockBlankLinesCompact -} - -// blockSepAfter returns the inter-block separator to write AFTER block i (i.e. -// before block i+1). -func blockSepAfter(kinds []scrollback.Kind, i int) string { - if blockBlankLinesBetween(kinds[i], kinds[i+1]) == 0 { - return interBlockSepNone + if previous == scrollback.KindAssistant && current == scrollback.KindTurnStat { + return interBlockBlankLinesNone } - return interBlockSepCompact + return interBlockBlankLinesCompact } -// blockBlankLinesAfter is the lines-path mirror of blockSepAfter: it returns the -// number of blank "" lines to insert before block i (i.e. after block i-1). +// blockBlankLinesAfter returns the number of blank "" lines to insert before +// block i (i.e. after block i-1). func blockBlankLinesAfter(kinds []scrollback.Kind, i int) int { return blockBlankLinesBetween(kinds[i-1], kinds[i]) } @@ -784,7 +761,7 @@ func (r *renderer) renderReasoningSnapshot(p scrollback.AssistantCardSnapshot, e if strings.TrimSpace(p.Reasoning) == "" { return "" } - style := r.th.Style("reasoning") + style := r.th.Style("transcriptReasoningText") text := terminaltext.Sanitize(strings.TrimRight(p.Reasoning, "\n")) n := lineCount(text) expandMark := r.marks.expandConversation diff --git a/cmd/mecatui/ui/render_cache_test.go b/cmd/mecatui/ui/render_cache_test.go index 97fcad0ec4..d6b11d7bd8 100644 --- a/cmd/mecatui/ui/render_cache_test.go +++ b/cmd/mecatui/ui/render_cache_test.go @@ -26,7 +26,6 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/theme" - "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) // newCacheRenderer builds a renderer at a fixed width, mirroring the model's @@ -41,30 +40,20 @@ func newCacheRenderer() *renderer { // independent test oracle and allocation benchmark for the production frame path. func (r *renderer) renderConversation(c *conversation, expand bool) string { var b strings.Builder - snapshots := c.testBlocks() - kinds := make([]scrollback.Kind, c.scrollback.Len()) - for i := range kinds { - kinds[i] = c.scrollback.MetadataAt(i).Kind - } - var previousText string + passes, firstChanged := r.renderPasses(&c.scrollback, expand) + r.assembleToolGroups(passes, firstChanged, expand) previousVisible := -1 - for i := range snapshots { - text := r.renderSnapshot(i, snapshots[i], expand) - if text == "" { + for i, pass := range passes { + if pass.text == "" { continue } if previousVisible >= 0 { - if kinds[i] == scrollback.KindAssistant && - (kinds[previousVisible] == scrollback.KindTool || kinds[previousVisible] == scrollback.KindSubagent || kinds[previousVisible] == scrollback.KindTeam) && - !strings.Contains(previousText, "\n") { - b.WriteString(interBlockSepCompact) - } else { - b.WriteString(blockSepAfter([]scrollback.Kind{kinds[previousVisible], kinds[i]}, 0)) + for gap := 0; gap < blockBlankLinesAfterPasses(passes, previousVisible, i); gap++ { + b.WriteByte('\n') } } - previousText = text previousVisible = i - b.WriteString(text) + b.WriteString(pass.text) b.WriteByte('\n') } return b.String() @@ -554,7 +543,7 @@ func TestNonTailResolveRendersThroughUpdateFlow(t *testing.T) { renderTickMsg{}, ) got := m.rend.renderConversation(&m.conv, m.expandConversation) - if !strings.Contains(stripANSIstr(got), "✓ Shell · go test ./...") { + if !strings.Contains(stripANSIstr(got), "● 1 tool run") { t.Errorf("non-tail resolve through Update must render the settled summary (stale cached card?), got:\n%s", stripANSIstr(got)) } fresh := newRenderer(m.rend.th, defaultHelpKeys()) @@ -686,7 +675,7 @@ func TestIncrementalJoinTailChangesMidScrollback(t *testing.T) { t.Fatalf("post-non-tail-resolve incremental join diverged from fresh oracle (stale prefix served past the changed index?)\n got %q\nwant %q", stripANSIstr(got), stripANSIstr(want)) } - if !strings.Contains(stripANSIstr(got), "✓ Shell · go test ./...") { + if !strings.Contains(stripANSIstr(got), "● 1 tool run") { t.Error("non-tail resolve must render the settled summary through the incremental path (stale prefix?)") } } @@ -956,7 +945,7 @@ func TestPathSwitchStalePrefix(t *testing.T) { t.Fatalf("path-switch served a STALE prefix: lines join diverged from fresh oracle after a string-path re-render of a non-tail block\n got %q\nwant %q", stripANSIstr(got), stripANSIstr(want)) } - if !strings.Contains(stripANSIstr(got), "✓ Shell · go test ./...") { + if !strings.Contains(stripANSIstr(got), "● 1 tool run") { t.Error("the resolved summary must render through the line path after the path switch (stale prefix served?)") } } @@ -1003,7 +992,7 @@ func TestIncrementalJoinMultiBlockOneFrame(t *testing.T) { t.Fatalf("multi-block frame: incremental join diverged from fresh oracle (firstChanged took max not min?)\n got %q\nwant %q", stripANSIstr(got), stripANSIstr(want)) } - if !strings.Contains(stripANSIstr(got), "✓ Read · f.go") { + if !strings.Contains(stripANSIstr(got), "● 1 tool run") { t.Error("the mid-block settled summary must render (firstChanged must be the MIN changed index)") } } diff --git a/cmd/mecatui/ui/render_wrap_test.go b/cmd/mecatui/ui/render_wrap_test.go index 0602a160af..2d6ab95781 100644 --- a/cmd/mecatui/ui/render_wrap_test.go +++ b/cmd/mecatui/ui/render_wrap_test.go @@ -69,7 +69,7 @@ func TestNoticeAndErrorWrap(t *testing.T) { payload scrollback.PayloadSnapshot marker string }{ - {"notice", scrollback.NoticeCardSnapshot{Text: long}, "•"}, + {"notice", scrollback.NoticeCardSnapshot{Text: long}, theme.TranscriptHarnessGlyph}, {"error", scrollback.ErrorCardSnapshot{Text: long}, "✗"}, } { b := testSnapshot(0, tc.payload) @@ -99,6 +99,9 @@ func TestNoticeAndErrorWrap(t *testing.T) { t.Errorf("%s: marker %q should appear exactly once, got %d in %q", tc.name, tc.marker, strings.Count(whole, tc.marker), whole) } + if tc.name == "notice" { + continue + } const indent = " " // marker width: glyph + space for i := 1; i < len(lines); i++ { plain := stripANSIstr(lines[i]) diff --git a/cmd/mecatui/ui/rendered_frame.go b/cmd/mecatui/ui/rendered_frame.go index 081741b3e6..be954bd1d7 100644 --- a/cmd/mecatui/ui/rendered_frame.go +++ b/cmd/mecatui/ui/rendered_frame.go @@ -21,6 +21,7 @@ const ( conversationRegionArguments conversationRegionResult conversationRegionAppendix + conversationRegionToolSummary // derived display, never a member's source payload ) // readingAnchor is the renderer-facing subset of the logical conversation coordinate. @@ -77,7 +78,8 @@ type renderedFrame struct { provenance []renderedRow // Functional-card provenance is structural only; prepared semantic inputs are // discarded at the cache boundary. - appendixID uint64 + appendixID uint64 + toolSummaries map[uint64]uint64 // hidden member -> existing summary member ID } func (f renderedFrame) hasRegion(blockID uint64, region regionKind) bool { @@ -149,14 +151,25 @@ func (f renderedFrame) rowForAnchor(anchor readingAnchor) (int, bool) { best = i } } + if best < 0 { + best = f.toolSummaryRow(anchor.blockID) + } return best, best >= 0 } +func (f renderedFrame) toolSummaryRow(memberID uint64) int { + if summaryID, ok := f.toolSummaries[memberID]; ok { + return f.firstRegionRow(summaryID, conversationRegionToolSummary) + } + return -1 +} + // Cache/render inputs: walk blocks once and pass its render-pass output // explicitly to frame assembly. renderConversationLines remains the byte-identical // viewport wrapper. func (r *renderer) renderConversationFrame(c *scrollback.Conversation, expand bool) renderedFrame { passes, firstChanged := r.renderPasses(c, expand) + firstChanged, summaries := r.assembleToolGroups(passes, firstChanged, expand) n := len(passes) prefixN := min(firstChanged, n) key := joinPrefixState{width: r.width, expand: expand} @@ -167,8 +180,9 @@ func (r *renderer) renderConversationFrame(c *scrollback.Conversation, expand bo } frame := renderedFrame{ - lines: make([]string, 0, len(prefixLines)+(n-prefixN)*2+1), - provenance: r.frameProvenanceScratch[:0], + lines: make([]string, 0, len(prefixLines)+(n-prefixN)*2+1), + provenance: r.frameProvenanceScratch[:0], + toolSummaries: summaries, } if expand { if appendix, ok := c.AppendixSnapshot(); ok && len(appendix.Files) > 0 { @@ -219,12 +233,6 @@ func (*renderer) appendFrameSegment(frame *renderedFrame, passes []renderPass, i } func blockBlankLinesAfterPasses(passes []renderPass, previous, current int) int { - if passes[current].kind == scrollback.KindAssistant && len(passes[previous].rows) == 1 { - switch passes[previous].kind { - case scrollback.KindTool, scrollback.KindSubagent, scrollback.KindTeam: - return interBlockBlankLinesCompact - } - } return blockBlankLinesBetween(passes[previous].kind, passes[current].kind) } @@ -241,9 +249,7 @@ func (r *renderer) snapshotProvenanceRows(blockID uint64, kind scrollback.Kind, case scrollback.KindUser: textStart, region = 1, conversationRegionBody case scrollback.KindAssistant: - if expand { - textStart = 2 // expanded speaker label plus its blank row - } + textStart = 2 // speaker label plus its blank row if strings.TrimSpace(assistant.Reasoning) != "" { reasoning := r.renderReasoningSnapshot(assistant, expand) reasoningRows := len(strings.Split(reasoning, "\n")) @@ -251,7 +257,7 @@ func (r *renderer) snapshotProvenanceRows(blockID uint64, kind scrollback.Kind, rows[i] = renderedRow{blockID: blockID, region: conversationRegionReasoning, row: i - textStart} } if expand { - caveatRows := len(strings.Split(r.wrapStyled(reasoningCaveat, r.th.Style("reasoning")), "\n")) + caveatRows := len(strings.Split(r.wrapStyled(reasoningCaveat, r.th.Style("transcriptReasoningText")), "\n")) for i := textStart + 1 + caveatRows; i < min(textStart+reasoningRows, len(rows)); i++ { rows[i].text = true } diff --git a/cmd/mecatui/ui/rendered_frame_test.go b/cmd/mecatui/ui/rendered_frame_test.go index 367bef928d..6c8f1514e8 100644 --- a/cmd/mecatui/ui/rendered_frame_test.go +++ b/cmd/mecatui/ui/rendered_frame_test.go @@ -81,12 +81,16 @@ func TestEmptyAssistantTurnsDoNotSeparateSettledCalls(t *testing.T) { for _, width := range []int{28, 100} { r.setWidth(width) frame := r.renderConversationFrame(&c.scrollback, false) - if got := strings.Count(stripANSIstr(strings.Join(frame.lines, "\n")), "● mecatl"); got != 0 { + if got := strings.Count(stripANSIstr(strings.Join(frame.lines, "\n")), "◀ mecatl"); got != 0 { t.Fatalf("width %d rendered %d empty assistant labels: %q", width, got, frame.lines) } first := toolBlockID(t, c.scrollback, "one") second := toolBlockID(t, c.scrollback, "two") - assertLongRunGap(t, frame, first, second, 0) + if !strings.Contains(stripANSIstr(strings.Join(frame.lines, "\n")), "● 2 tools run") || frame.toolSummaryRow(first) != frame.toolSummaryRow(second) || frame.toolSummaryRow(second) < 0 { + t.Fatal("empty turns must bridge a single comfort group") + } + detailed := r.renderConversationFrame(&c.scrollback, true) + assertLongRunGap(t, detailed, first, second, 0) if len(frame.lines) != len(frame.provenance) { t.Fatalf("width %d frame/provenance mismatch", width) } @@ -98,7 +102,7 @@ func TestEmptyAssistantTurnsDoNotSeparateSettledCalls(t *testing.T) { } // F9 must not resurrect whitespace-only messages as empty speaker turns. expandedEmpty := r.renderConversationFrame(&c.scrollback, true) - if strings.Contains(stripANSIstr(strings.Join(expandedEmpty.lines, "\n")), "● mecatl") || expandedEmpty.hasRegion(secondEmpty, conversationRegionBody) { + if strings.Contains(stripANSIstr(strings.Join(expandedEmpty.lines, "\n")), "◀ mecatl") || expandedEmpty.hasRegion(secondEmpty, conversationRegionBody) { t.Fatalf("whitespace-only assistant rendered in F9 detail: %q", expandedEmpty.lines) } @@ -107,14 +111,14 @@ func TestEmptyAssistantTurnsDoNotSeparateSettledCalls(t *testing.T) { c.appendReasoning("a summary") frame := r.renderConversationFrame(&c.scrollback, false) plain := stripANSIstr(strings.Join(frame.lines, "\n")) - if got := strings.Count(plain, "● mecatl"); got != 0 { + if got := strings.Count(plain, "◀ mecatl"); got != 1 { t.Fatalf("collapsed reasoning-only turn rendered %d speaker labels", got) } if !strings.Contains(plain, "reasoning…") || !frame.hasRegion(lastEmpty, conversationRegionReasoning) { t.Fatalf("reasoning-only turn lost its collapsed summary or provenance: %q", plain) } expandedFrame := r.renderConversationFrame(&c.scrollback, true) - if expanded := stripANSIstr(strings.Join(expandedFrame.lines, "\n")); !strings.Contains(expanded, "a summary") || !strings.Contains(expanded, "● mecatl") || !expandedFrame.hasRegion(lastEmpty, conversationRegionReasoning) { + if expanded := stripANSIstr(strings.Join(expandedFrame.lines, "\n")); !strings.Contains(expanded, "a summary") || !strings.Contains(expanded, "◀ mecatl") || !expandedFrame.hasRegion(lastEmpty, conversationRegionReasoning) { t.Fatalf("reasoning-only turn lost its expanded detail or provenance: %q", expanded) } c.appendAssistant("an answer") @@ -136,6 +140,7 @@ func TestEmptyAssistantBecomesVisibleWithoutMovingReader(t *testing.T) { m.conv.resolveTool(id, "result", false) } m.conv.startAssistant() + m.expandConversation = true // individual member anchors are visible in F9 m.refreshView() m.vp.SetYOffset(5) m.conversationView.mode = anchored diff --git a/cmd/mecatui/ui/selection.go b/cmd/mecatui/ui/selection.go index f67361fa7b..8d8268cb4e 100644 --- a/cmd/mecatui/ui/selection.go +++ b/cmd/mecatui/ui/selection.go @@ -34,11 +34,12 @@ const ( ) type selectionPoint struct { - blockID uint64 - region regionKind - sourceOffset int - before string - after string + blockID uint64 + region regionKind + sourceOffset int + before string + after string + derivedDisplay string } // selection is the in-app text-selection state: a left-click-drag over the @@ -364,11 +365,15 @@ func selectionPointFor(frame renderedFrame, line, col int) (selectionPoint, bool if offset < 0 || offset > graphemeCount(lineText) { return selectionPoint{}, false } - return selectionPoint{ + point := selectionPoint{ blockID: row.blockID, region: row.region, sourceOffset: row.sourceOffset + offset, before: graphemeSlice(lineText, max(0, offset-selectionContextGraphemes), offset), after: graphemeSlice(lineText, offset, min(graphemeCount(lineText), offset+selectionContextGraphemes)), - }, true + } + if row.region == conversationRegionToolSummary { + point.derivedDisplay = lineText + } + return point, true } func graphemeSlice(text string, start, end int) string { @@ -401,6 +406,9 @@ func resolveSelectionPoint(frame renderedFrame, point selectionPoint) (int, int, continue } text, leading := selectionRowText(frame, row, frame.lines[i]) + if point.region == conversationRegionToolSummary && text != point.derivedDisplay { + continue + } for offset := 0; offset <= graphemeCount(text); offset++ { if row.sourceOffset+offset != point.sourceOffset { continue diff --git a/cmd/mecatui/ui/session_continuity_scenario6_test.go b/cmd/mecatui/ui/session_continuity_scenario6_test.go index dae615f170..03c69d3c7d 100644 --- a/cmd/mecatui/ui/session_continuity_scenario6_test.go +++ b/cmd/mecatui/ui/session_continuity_scenario6_test.go @@ -125,7 +125,7 @@ func TestStartupRunEntryFailureRebuildsDocumentProjection(t *testing.T) { m = m.failStartupRunEntry(nil) content := stripANSIstr(m.vp.GetContent()) - if !strings.Contains(content, "new request") || !strings.Contains(content, "✗ Bash · printf new") || strings.Contains(content, "old.go") { + if !strings.Contains(content, "new request") || !strings.Contains(content, "● 1 tool run · 1 failed") || strings.Contains(content, "old.go") { t.Fatalf("replacement rendered stale document content:\n%s", content) } fresh := newRenderer(m.deps.Theme, m.rend.marks) diff --git a/cmd/mecatui/ui/speaker_block_test.go b/cmd/mecatui/ui/speaker_block_test.go index 4f391f22fe..75ba8f0da9 100644 --- a/cmd/mecatui/ui/speaker_block_test.go +++ b/cmd/mecatui/ui/speaker_block_test.go @@ -9,23 +9,25 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) -// TestSpeakerLabels pins decisions 1+2: the user block leads with "▌ you" and the -// assistant block with "● mecatl" — the speaker-turn glyphs. The labels are the -// first line of each block's render (stripped of ANSI). +// TestSpeakerLabels pins the user and LLM actor glyphs in both transcript views. func TestSpeakerLabels(t *testing.T) { r := newTestRenderer() r.setWidth(80) user := testSnapshot(0, scrollback.UserCardSnapshot{Text: "hello there"}) got := stripANSIstr(r.renderSnapshot(0, user, false)) - if first := strings.TrimLeft(firstLine(got), " "); !strings.HasPrefix(first, "▌ you") { - t.Errorf("user block must lead with %q, got first line %q", "▌ you", first) + if first := strings.TrimLeft(firstLine(got), " "); !strings.HasPrefix(first, "▶ you") { + t.Errorf("user block must lead with %q, got first line %q", "▶ you", first) } asst := testSnapshot(1, scrollback.AssistantCardSnapshot{Text: "the answer"}) + compact := stripANSIstr(r.renderSnapshot(1, asst, false)) + if first := strings.TrimLeft(firstLine(compact), " "); !strings.HasPrefix(first, "◀ mecatl") { + t.Errorf("compact assistant block must lead with %q, got first line %q", "◀ mecatl", first) + } got = stripANSIstr(r.renderSnapshot(1, asst, true)) - if first := strings.TrimLeft(firstLine(got), " "); !strings.HasPrefix(first, "● mecatl") { - t.Errorf("assistant block must lead with %q, got first line %q", "● mecatl", first) + if first := strings.TrimLeft(firstLine(got), " "); !strings.HasPrefix(first, "◀ mecatl") { + t.Errorf("assistant block must lead with %q, got first line %q", "◀ mecatl", first) } } diff --git a/cmd/mecatui/ui/structured_blocks.go b/cmd/mecatui/ui/structured_blocks.go index d347d5b78c..7779b4a68e 100644 --- a/cmd/mecatui/ui/structured_blocks.go +++ b/cmd/mecatui/ui/structured_blocks.go @@ -1,6 +1,7 @@ package ui import ( + "github.com/stacklok/mecatl/cmd/mecatui/theme" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/blocks" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) @@ -16,15 +17,23 @@ func (r *renderer) plainBlockLayout(expand bool) blocks.PlainLayout { func (r *renderer) blockTheme() blocks.Theme { toolCard, _, _ := r.toolCardLayout() return blocks.Theme{ - UserLabel: r.th.Style("userLabel"), - UserBody: r.th.Style("userBlock"), - Muted: r.th.Style("muted"), - Warning: r.th.Style("warning"), - Error: r.th.Style("errorText"), - HookModified: r.th.Style("hookModified"), - HookAdvisory: r.th.Style("hookAdvisory"), - DeliveryHead: r.th.Style("hookModified"), - ToolCard: toolCard, + UserGlyph: theme.TranscriptUserGlyph, + UserMarker: r.th.Style("transcriptUserMarker"), + UserLabel: r.th.Style("transcriptUserHeading"), + UserRail: r.th.Style("transcriptUserRail"), + UserBody: r.th.Style("transcriptUserText"), + HarnessGlyph: theme.TranscriptHarnessGlyph, + HarnessMarker: r.th.Style("transcriptHarnessMarker"), + HarnessLabel: r.th.Style("transcriptHarnessHeading"), + HarnessText: r.th.Style("transcriptHarnessText"), + Statistics: r.th.Style("transcriptStatistics"), + Muted: r.th.Style("muted"), + Warning: r.th.Style("warning"), + Error: r.th.Style("errorText"), + HookModified: r.th.Style("hookModified"), + HookAdvisory: r.th.Style("hookAdvisory"), + DeliveryHead: r.th.Style("hookModified"), + ToolCard: toolCard, } } diff --git a/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden b/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden index fe56c34123..95479ac00e 100644 --- a/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden +++ b/cmd/mecatui/ui/testdata/sessions_transcript_rendered.golden @@ -1,12 +1,12 @@ mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── Inspecting Nightly checks · read-only - ▌ you + ▶ you │ run checks - All checks passed. - + ◀ mecatl + All checks passed. diff --git a/cmd/mecatui/ui/testdata/steer_card.golden b/cmd/mecatui/ui/testdata/steer_card.golden index 3d2629a324..3808fb6e8b 100644 --- a/cmd/mecatui/ui/testdata/steer_card.golden +++ b/cmd/mecatui/ui/testdata/steer_card.golden @@ -1,6 +1,6 @@ mecatui · Golden session · fixture/fixture-model/default · mode default ──────────────────────────────────────────────────────────────────────────────────────────────────── - ▌ you + ▶ you │ first diff --git a/cmd/mecatui/ui/tool_groups.go b/cmd/mecatui/ui/tool_groups.go new file mode 100644 index 0000000000..7ebbb39f9e --- /dev/null +++ b/cmd/mecatui/ui/tool_groups.go @@ -0,0 +1,100 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/stacklok/mecatl/cmd/mecatui/theme" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +func toolFamily(kind scrollback.Kind) bool { + return kind == scrollback.KindTool || kind == scrollback.KindSubagent || kind == scrollback.KindTeam +} + +// assembleToolGroups changes only display passes, never the conversation or card +// caches. Prefix coverage remains in source indices, always outside a whole group. +func (r *renderer) assembleToolGroups(passes []renderPass, firstChanged int, expand bool) (int, map[uint64]uint64) { + var summaries map[uint64]uint64 + for start := 0; start < len(passes); { + if passes[start].text == "" || !toolFamily(passes[start].kind) { + start++ + continue + } + end := start + 1 + for end < len(passes) && (passes[end].text == "" || toolFamily(passes[end].kind)) { + end++ + } + // Appending or revealing a boundary can also change the preceding group. + if firstChanged < len(passes) && firstChanged <= end { + firstChanged = min(firstChanged, start) + } + if !expand { + settled, failed := 0, 0 + var summaryID uint64 + var active []renderPass + for i := start; i < end; i++ { + pass := passes[i] + if pass.text == "" { + continue + } + if (toolcallProjection{state: pass.toolState}).settled() { + settled++ + if pass.toolState == toolcallFailed { + failed++ + } + if summaryID == 0 { + summaryID = pass.id + } + } else { + active = append(active, pass) + } + } + if settled > 0 { + if summaries == nil { + summaries = make(map[uint64]uint64) + } + for i := start; i < end; i++ { + pass := passes[i] + if pass.text != "" && (toolcallProjection{state: pass.toolState}).settled() { + summaries[pass.id] = summaryID + } + passes[i] = renderPass{} + } + output := r.renderToolAggregate(summaryID, settled, failed) + passes[start] = renderPass{id: summaryID, kind: scrollback.KindTool, text: output.text, rows: output.rows} + copy(passes[start+1:end], active) + } + } + start = end + } + return firstChanged, summaries +} + +func (r *renderer) renderToolAggregate(blockID uint64, count, failed int) blockRenderOutput { + noun := "tools" + if count == 1 { + noun = "tool" + } + out := r.th.Style("transcriptToolMarker").Render(theme.TranscriptToolGlyph) + r.th.Style("transcriptToolHeading").Render(fmt.Sprintf(" %d %s run", count, noun)) + if failed > 0 { + out += r.th.Style("toolErr").Render(fmt.Sprintf(" · %d failed", failed)) + } + out += r.th.Style("muted").Render(" · " + r.marks.expandConversation + " detailed · " + r.marks.toolcalls + " Toolcalls") + out = ansi.Truncate(out, r.contentWidth(), "…") + indent := 0 + if r.width > r.indent { + indent = r.indent + } + out = padLines(out, indent) + return blockRenderOutput{ + text: out, + rows: []renderedRow{{ + blockID: blockID, region: conversationRegionToolSummary, text: true, + kind: scrollback.KindTool, indent: indent, leading: indent, + span: graphemeCount(ansi.Strip(strings.TrimPrefix(out, strings.Repeat(" ", indent)))), + }}, + } +} diff --git a/cmd/mecatui/ui/tool_groups_integration_test.go b/cmd/mecatui/ui/tool_groups_integration_test.go new file mode 100644 index 0000000000..444e57aebf --- /dev/null +++ b/cmd/mecatui/ui/tool_groups_integration_test.go @@ -0,0 +1,400 @@ +package ui + +import ( + "image/color" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" +) + +// Check the displayed Model frame against both assembly paths without resetting +// the warm renderer. Model owns an independent copy of provenance scratch. +func assertGroupedModelFrame(t *testing.T, m Model) { + t.Helper() + frame := m.conversationView.frame + fresh := newRenderer(m.deps.Theme, m.rend.marks) + fresh.setWidth(m.width) + want := fresh.renderConversationFrame(&m.conv.scrollback, m.expandConversation) + if !reflect.DeepEqual(frame.lines, want.lines) || !reflect.DeepEqual(frame.provenance, want.provenance) || !reflect.DeepEqual(frame.toolSummaries, want.toolSummaries) { + t.Fatalf("displayed frame differs from fresh at width %d detailed=%t:\n%q\n%q", m.width, m.expandConversation, frame.lines, want.lines) + } + if got := m.rend.renderConversation(&m.conv, m.expandConversation); got != strings.Join(frame.lines, "\n") { + t.Fatal("warm string assembly differs from displayed frame") + } +} + +func TestToolGroupsModelStableSelections(t *testing.T) { + for _, marker := range []string{"PROSEMARKER", "ACTIVEMARKER"} { + t.Run(marker, func(t *testing.T) { + m := scenario2Model(t, 100, 20, false, phaseRunning, nil) + m.conv.addNotice("untouched PROSEMARKER prose") + m = applyAll(m, + client.ToolCallMsg{ID: "earlier", Name: "Read", Args: `{"path":"earlier.go"}`}, + client.ToolCallMsg{ID: "active", Name: "Read", Args: `{"path":"ACTIVEMARKER"}`}, + client.ToolCallMsg{ID: "done", Name: "Read", Args: `{}`}, + client.ToolResultMsg{CallID: "done", Content: "PRIVATE_DONE"}, + ) + m.conv.addUser(strings.Repeat("later prose\n", 40)) + m.refreshView() + line := lineIndexContaining(m.vp.GetContent(), marker) + if line < 0 { + t.Fatal("selection marker missing") + } + m.vp.SetYOffset(line) + m.conversationView.mode = anchored + text := stripANSIstr(m.conversationView.frame.lines[line]) + m = m.wordSelect(line, graphemeCount(text[:strings.Index(text, marker)])) + if m.sel.snapshot != marker { + t.Fatalf("selection fixture: %+v", m.sel) + } + point := m.sel.anchorPoint + check := func(step string) { + t.Helper() + if !m.sel.active || selectedText(m.vp.GetContent(), m.sel) != marker || m.sel.anchorPoint != point { + t.Fatalf("%s lost unchanged selection: %+v", step, m.sel) + } + if m.conversationView.mode != anchored || m.conversationView.frame.provenance[m.vp.YOffset()].blockID != point.blockID { + t.Fatalf("%s moved reading away from untouched content", step) + } + _, cmd := m.copySelection() + if got, ok := osc52Payload(collectLeaves(cmd)); !ok || got != marker { + t.Fatalf("%s copy=%q ok=%t", step, got, ok) + } + assertGroupedModelFrame(t, m) + } + m = applyAll(m, client.ToolResultMsg{CallID: "earlier", Content: "PRIVATE_EARLIER"}) + check("earlier settlement") + m = applyAll(m, client.ToolCallMsg{ID: "append", Name: "Shell", Args: `{}`}, client.ToolResultMsg{CallID: "append", Content: "PRIVATE_APPEND", IsError: true}) + check("count growth in later group") + for _, msg := range []tea.Msg{f9, tea.WindowSizeMsg{Width: 80, Height: 20}, f9} { + m = applyAll(m, msg) + check("detail/resize") + } + // A changed active card is the negative control, not permission to + // weaken preservation for the untouched prose. + m = applyAll(m, client.ToolResultMsg{CallID: "active", Content: "PRIVATE_ACTIVE"}) + if marker == "ACTIVEMARKER" { + if m.sel.active { + t.Fatal("disappeared active selection survived settlement") + } + } else { + check("active settlement leaves prose") + } + }) + } +} + +func TestToolGroupsModelMemberAnchorGrowthAndInspector(t *testing.T) { + m := scenario2Model(t, 100, 18, false, phaseRunning, nil) + m = applyAll(m, + client.ToolCallMsg{ID: "earlier", Name: "Read", Args: `{"path":"earlier.go"}`}, + client.ToolCallMsg{ID: "member", Name: "Shell", Args: `{"command":"false"}`}, + client.ToolResultMsg{CallID: "member", Content: "PRIVATE_MEMBER", IsError: true}, + ) + // Hidden assistant insertion lets a new call grow the same presentation group. + m.conv.startAssistant() + m = applyAll(m, client.ToolCallMsg{ID: "growth", Name: "Read", Args: `{"path":"growth.go"}`}) + m.conv.addUser(strings.Repeat("later prose\n", 40)) + m.refreshView() + m = applyAll(m, f9) + member := toolBlockID(t, m.conv.scrollback, "member") + m.vp.SetYOffset(m.conversationView.frame.firstRegionRow(member, conversationRegionBody)) + m.conversationView.mode = anchored + m = applyAll(m, f9) + check := func(step string) { + t.Helper() + if m.conversationView.mode != anchored || m.conversationView.anchor.blockID != member || m.vp.YOffset() != m.conversationView.frame.toolSummaryRow(member) { + t.Fatalf("%s lost member-owned summary edge: offset=%d anchor=%+v", step, m.vp.YOffset(), m.conversationView.anchor) + } + assertGroupedModelFrame(t, m) + } + check("collapse") + m = applyAll(m, client.ToolResultMsg{CallID: "growth", Content: "PRIVATE_GROWTH"}) + check("count growth") + oldSummary := m.conversationView.frame.toolSummaries[member] + m = applyAll(m, client.ToolResultMsg{CallID: "earlier", Content: "PRIVATE_EARLIER"}) + check("earlier active settles") + if m.conversationView.frame.toolSummaries[member] == oldSummary { + t.Fatal("fixture did not change first settled summary ID") + } + m = applyAll(m, tea.WindowSizeMsg{Width: 80, Height: 18}) + check("resize") + before, offset, anchor := m.conversationContent(), m.vp.YOffset(), m.conversationView.anchor + m = applyAll(m, ctrlT, tea.KeyPressMsg{Code: tea.KeyHome}) + s := toolcallsForTest(t, m) + if len(s.entries) != 3 { + t.Fatalf("inspector entries=%d", len(s.entries)) + } + for i, want := range []struct{ id, args, result string }{ + {"earlier", "Path: earlier.go", "PRIVATE_EARLIER"}, + {"member", "Command: false", "PRIVATE_MEMBER"}, + {"growth", "Path: growth.go", "PRIVATE_GROWTH"}, + } { + if uint64(s.entries[i].blockID) != toolBlockID(t, m.conv.scrollback, want.id) { + t.Fatal("inspector reordered source inventory") + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + out := stripANSIstr(m.View().Content) + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) + out += stripANSIstr(m.View().Content) + if !strings.Contains(out, want.args) || !strings.Contains(out, want.result) { + t.Fatalf("inspector truncated exact call details: %q", out) + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}, tea.KeyPressMsg{Code: tea.KeyDown}) + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.modal != nil || m.expandConversation || m.conversationContent() != before || m.vp.YOffset() != offset || m.conversationView.anchor != anchor { + t.Fatal("inspector close changed projection or reading") + } + check("inspector close") + m = applyAll(m, f9) + if m.conversationView.frame.provenance[m.vp.YOffset()].blockID != member { + t.Fatal("detail did not restore original member after summary ID changed") + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnd}) + m = applyAll(m, client.ToolCallMsg{ID: "tail", Name: "Read", Args: `{}`}, client.ToolResultMsg{CallID: "tail", Content: "PRIVATE_TAIL"}, f9) + if !m.vp.AtBottom() || m.conversationView.mode != followTail { + t.Fatal("settlement/detail stole tail ownership") + } + assertGroupedModelFrame(t, m) +} + +func TestToolGroupsModelSummaryCopyAndTheme(t *testing.T) { + m := newTestModelFromDeps(Deps{Theme: theme.NewRegistry().Default(), ThemeAutoDetect: true, Ctx: t.Context(), NoBanner: true}) + m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 20}, tea.ColorProfileMsg{Profile: colorprofile.TrueColor}) + m.phase = phaseRunning + m = applyAll(m, + client.ToolCallMsg{ID: "one", Name: "Read", Args: `{}`}, + client.ToolResultMsg{CallID: "one", Content: strings.Repeat("PRIVATE_RESULT", 10000), IsError: true, Blocks: []client.ContentBlock{{Kind: client.ContentBlockImage, Data: []byte(strings.Repeat("PRIVATE_ARTIFACT", 10000))}}}, + client.ToolCallMsg{ID: "pending", Name: "Read", Args: `{}`}, + ) + m.conv.addNotice("neutral body") + m.conv.addUser(strings.Repeat("later prose\n", 40)) + m.refreshView() + member := toolBlockID(t, m.conv.scrollback, "one") + row := m.conversationView.frame.toolSummaryRow(member) + m.vp.SetYOffset(row) + m.conversationView.mode = anchored + plain := stripANSIstr(m.conversationView.frame.lines[row]) + x := strings.Index(plain, "●") + y := convTopRow(m) + m, _ = pressMouse(m, tea.MouseLeft, x, y) + m, _ = motionMouse(m, len(plain), y) + if got := selectedText(m.vp.GetContent(), m.sel); got != strings.TrimSpace(plain) { + t.Fatalf("summary gesture selected %q", got) + } + beforeSel, beforeAnchor, dark := m.sel, m.conversationView.anchor, m.rend + darkBG := selectionBgSGR(t, m) + if !strings.Contains(m.vp.GetContent(), darkBG) { + t.Fatal("summary selection has no dark highlight") + } + _, cmd := m.copySelection() + if got, ok := osc52Payload(collectLeaves(cmd)); !ok || got != strings.TrimSpace(plain) || strings.Contains(got, "PRIVATE") { + t.Fatalf("derived copy=%q ok=%t", got, ok) + } + m = applyAll(m, tea.BackgroundColorMsg{Color: color.White}) + if m.rend == dark || m.deps.Theme.Name != "solar" { + t.Fatal("background response did not rebuild renderer") + } + if m.sel != beforeSel || m.conversationView.anchor != beforeAnchor || m.vp.YOffset() != row || m.conversationView.mode != anchored { + t.Fatal("theme changed summary selection/reading") + } + lightBG := selectionBgSGR(t, m) + if lightBG == darkBG || !strings.Contains(m.vp.GetContent(), lightBG) || strings.Contains(m.vp.GetContent(), darkBG) { + t.Fatal("summary selection retained dark highlight") + } + out := m.conversationContent() + for _, token := range []string{m.deps.Theme.Style("transcriptToolHeading").Render(" 1 tool run"), m.deps.Theme.Style("toolErr").Render(" · 1 failed"), m.deps.Theme.Style("transcriptHarnessText").Render("neutral body")} { + if !strings.Contains(out, token) { + t.Fatalf("light transcript missing role %q", token) + } + } + assertGroupedModelFrame(t, m) + loads, prepares := m.rend.snapshotLoads, m.rend.cardPrepares + m.refreshView() + if m.rend.snapshotLoads != loads || m.rend.cardPrepares != prepares { + t.Fatal("warm Model render reloaded settled payload") + } + m = applyAll(m, f9) + if m.sel.active { + t.Fatal("disappeared summary retained selection") + } + m = applyAll(m, f9) + row = m.conversationView.frame.toolSummaryRow(member) + plain = stripANSIstr(m.conversationView.frame.lines[row]) + m = m.wordSelect(row, graphemeCount(plain[:strings.Index(plain, "Toolcalls")])) + // A changed count must invalidate even an unchanged selected hint. + m = applyAll(m, client.ToolCallMsg{ID: "two", Name: "Shell", Args: `{}`}, client.ToolResultMsg{CallID: "two", Content: "PRIVATE_GROWTH", IsError: true}) + // This later group leaves the selected summary unchanged. + if !m.sel.active { + t.Fatal("unrelated group growth cleared summary selection") + } + m = applyAll(m, client.ToolResultMsg{CallID: "pending", Content: "PRIVATE_SETTLEMENT", IsError: true}) + if m.sel.active { + t.Fatal("changed summary retained stale derived hint selection") + } + assertGroupedModelFrame(t, m) +} + +func TestToolGroupsModelFailureLifecycleAndReplay(t *testing.T) { + m := scenario2Model(t, 100, 20, false, phaseRunning, nil) + events := []tea.Msg{ + client.ToolCallMsg{ID: "read", Name: "Read", Args: `{}`}, + client.ToolResultMsg{CallID: "read", Content: "PRIVATE_PROVISIONAL", IsError: true, Available: true}, + client.ToolResultMsg{CallID: "read", Content: "PRIVATE_CANONICAL", IsError: true}, + client.ToolCallMsg{ID: "sub", Name: "Subagent", Args: `{}`}, + client.SubagentMsg{Kind: client.SubagentStart, ParentCallID: "sub", ChildID: "child"}, + client.SubagentMsg{Kind: client.SubagentEnd, ParentCallID: "sub", ChildID: "child", Stop: "error"}, + client.ToolCallMsg{ID: "team", Name: "Team", Args: `{}`}, + client.TeamMsg{Kind: client.TeamStart, ParentCallID: "team", TeamID: "team"}, + client.TeamMsg{Kind: client.TeamEnd, ParentCallID: "team", TeamID: "team", Stop: "error"}, + } + replay := sessionsState{} + for i, event := range events { + m = applyAll(m, event) + replay.applyReplayEvent(event) + assertGroupedModelFrame(t, m) + fresh := newRenderer(m.deps.Theme, m.rend.marks) + fresh.setWidth(m.width) + if got := strings.Join(fresh.renderConversationFrame(&replay.transcript.scrollback, false).lines, "\n"); got != m.conversationContent() { + t.Fatalf("event %d replay/live differ", i) + } + if i < 2 && strings.Contains(detailText(m), "tools run") { + t.Fatal("active/provisional failure counted as settled") + } + if i == 1 && !strings.Contains(detailText(m), "finalizing") { + t.Fatal("provisional failure lost finalizing state") + } + } + if got := detailText(m); !strings.Contains(got, "● 3 tools run · 3 failed") { + t.Fatalf("canonical and delegation failures: %q", got) + } + for _, msg := range []tea.Msg{f9, tea.WindowSizeMsg{Width: 28, Height: 20}, f9, tea.WindowSizeMsg{Width: 100, Height: 20}} { + m = applyAll(m, msg) + assertGroupedModelFrame(t, m) + } +} + +func TestToolGroupsModelOrdinaryTerminalFailure(t *testing.T) { + m := scenario2Model(t, 100, 20, false, phaseRunning, nil) + m = applyAll(m, client.ToolCallMsg{ID: "failed", Name: "Read", Args: `{"path":"failed.go"}`}, client.ToolCallMsg{ID: "awaiting", Name: "Read", Args: `{"path":"awaiting.go"}`}) + // Ordinary terminal metadata has no dedicated client message. Exercise its + // owning scrollback seam, then the real Model/viewport replacement. + if !m.conv.scrollback.Tools().Finish("failed", true) || !m.conv.scrollback.Tools().Finish("awaiting", false) { + t.Fatal("terminal metadata not recorded") + } + m.refreshView() + if got := detailText(m); !strings.Contains(got, "● 1 tool run · 1 failed") || !strings.Contains(got, "awaiting result") { + t.Fatalf("terminal failure/success-without-result: %q", got) + } + assertGroupedModelFrame(t, m) + m = applyAll(m, f9) + if got := detailText(m); !strings.Contains(got, "✗ Read · failed.go") || !strings.Contains(got, "awaiting result") { + t.Fatalf("terminal detail: %q", got) + } + m = applyAll(m, client.ToolResultMsg{CallID: "failed", Content: "PRIVATE_FAILURE", IsError: true}, client.ToolResultMsg{CallID: "awaiting", Content: "PRIVATE_SUCCESS"}, f9) + if got := detailText(m); !strings.Contains(got, "● 2 tools run · 1 failed") || strings.Contains(got, "awaiting result") { + t.Fatalf("canonical results did not replace terminal projection: %q", got) + } + assertGroupedModelFrame(t, m) +} + +func TestToolGroupsModelHistoryReplacementReusesLocalIDs(t *testing.T) { + loader := &fakeSessionTranscriptLoader{} + m := newSessionsModel(t, newSessionsConv(), &fakeSessionLister{}, loader) + m.deps.NoAltScreen = false + m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 20}) + messages := func(prefix string, split bool) []client.ConversationMessage { + out := []client.ConversationMessage{ + {Role: "assistant", ToolCalls: []client.ConvToolCall{{ID: "one", Name: "Read", Args: `{"path":"` + prefix + `.go"}`}}}, + {Role: "tool", ToolResult: &client.ConvToolResult{CallID: "one", Content: "PRIVATE_" + prefix, IsError: !split}}, + } + if split { + out = append(out, client.ConversationMessage{Role: "user", Text: "new visible boundary"}) + } + return append(out, + client.ConversationMessage{Role: "assistant", ToolCalls: []client.ConvToolCall{{ID: "two", Name: "Shell", Args: `{"command":"false"}`}}}, + client.ConversationMessage{Role: "tool", ToolResult: &client.ConvToolResult{CallID: "two", Content: "PRIVATE_SECOND"}}, + client.ConversationMessage{Role: "user", Text: strings.Repeat("later prose\n", 40)}, + ) + } + var lastLoad tea.Msg + load := func(id string, inspect bool, messages []client.ConversationMessage) { + t.Helper() + loader.transcript = client.SessionTranscript{SessionID: id, Complete: true, Messages: messages} + mm, cmd, handled := m.loadSessionTranscript(client.SessionListItem{ID: id, Title: id, Kind: client.SessionKindMain, Capabilities: client.SessionInventoryCapabilities{PublicChat: true, Inspect: true}}, inspect) + if !handled || cmd == nil { + t.Fatal("history load was not issued") + } + m = mm.(Model) + lastLoad = cmd() + m = applyAll(m, lastLoad) + } + load("old", false, messages("OLD", false)) + oldMember := toolBlockID(t, m.conv.scrollback, "one") + if !strings.Contains(detailText(m), "● 2 tools run · 1 failed") { + t.Fatal("history did not assemble settled group") + } + m.vp.SetYOffset(m.conversationView.frame.toolSummaryRow(oldMember)) + m.conversationView.mode = anchored + row := m.vp.YOffset() + plain := stripANSIstr(m.conversationView.frame.lines[row]) + m = m.wordSelect(row, graphemeCount(plain[:strings.Index(plain, "Toolcalls")])) + if !m.sel.active || m.conversationView.mode != anchored || len(m.rend.blocks.rendered) == 0 { + t.Fatal("old projection was not primed") + } + assertGroupedModelFrame(t, m) + load("new", false, messages("NEW", true)) + if toolBlockID(t, m.conv.scrollback, "one") != oldMember { + t.Fatal("replacement did not reuse local block ID/index") + } + if m.sel.active || m.selBase != "" || m.conversationView.anchor != (readingAnchor{}) || m.conversationView.mode != followTail || !m.vp.AtBottom() { + t.Fatal("history adoption leaked old selection/reading state") + } + if got := detailText(m); strings.Count(got, "● 1 tool run") != 2 || strings.Contains(got, "failed") || strings.Contains(got, "OLD") { + t.Fatalf("replacement leaked grouping/cache: %q", got) + } + for _, msg := range []tea.Msg{f9, tea.WindowSizeMsg{Width: 40, Height: 20}, f9, tea.WindowSizeMsg{Width: 100, Height: 20}} { + m = applyAll(m, msg) + assertGroupedModelFrame(t, m) + } + // Read-only history has a separate renderer/viewport and must not replace + // the active document. Reopening the same session reuses all local slots. + active := m.conversationContent() + load("inspect", true, messages("INSPECT_OLD", false)) + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyHome}) + if got := stripANSIstr(m.View().Content); !strings.Contains(got, "● 2 tools run · 1 failed") || strings.Contains(got, "PRIVATE_") { + t.Fatalf("inspection aggregate: %q", got) + } + st := sessionsSurface(&m) + stale := lastLoad + oldRenderer := st.transcriptRend + m = applyAll(m, f9) + if got := stripANSIstr(m.View().Content); !strings.Contains(got, "✗ Read") || strings.Contains(got, "PRIVATE_") { + t.Fatalf("history detailed projection: %q", got) + } + load("inspect", true, messages("INSPECT_NEW", true)) + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyHome}) + freshInspection := m.View().Content + m = applyAll(m, stale) + if m.View().Content != freshInspection { + t.Fatal("late same-session history response resurrected old grouping") + } + if sessionsSurface(&m).transcriptRend == oldRenderer || sessionsSurface(&m).transcriptExpand { + t.Fatal("same-session reload retained renderer/mode") + } + if got := stripANSIstr(m.View().Content); !strings.Contains(got, "● 1 tool run") || strings.Contains(got, "failed") || strings.Contains(got, "INSPECT_OLD") { + t.Fatalf("inspection reused stale groups: %q", got) + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.modal != nil || m.conversationContent() != active || m.sessionID != "new" { + t.Fatal("inspection replaced active conversation") + } + assertGroupedModelFrame(t, m) +} diff --git a/cmd/mecatui/ui/tool_groups_test.go b/cmd/mecatui/ui/tool_groups_test.go new file mode 100644 index 0000000000..42c2861577 --- /dev/null +++ b/cmd/mecatui/ui/tool_groups_test.go @@ -0,0 +1,381 @@ +package ui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +func TestToolPresentationGroupsSettledAndMixed(t *testing.T) { + m := scenario2Model(t, 100, 24, false, phaseRunning, nil) + for i, name := range []string{"Read", "Shell", "Grep", "Glob"} { + id := name + m = applyAll(m, client.ToolCallMsg{ID: id, Name: name, Args: `{"path":"file.go"}`}, client.ToolResultMsg{CallID: id, Content: "PRIVATE_RESULT", IsError: i == 1}) + } + got := detailText(m) + if !strings.Contains(got, "● 4 tools run · 1 failed") || !strings.Contains(got, "f9") || !strings.Contains(got, "ctrl+t") { + t.Fatalf("missing aggregate: %q", got) + } + styled := m.conversationContent() + if !strings.Contains(styled, m.rend.th.Style("toolErr").Render(" · 1 failed")) || !strings.Contains(styled, m.rend.th.Style("transcriptToolHeading").Render(" 4 tools run")) { + t.Fatal("aggregate lost tool accent or failure styling") + } + if strings.Contains(got, "PRIVATE_RESULT") || strings.Contains(got, "✓ Read") { + t.Fatalf("hidden tool leaked: %q", got) + } + m, _ = pressKey(m, f9) + detailed := detailText(m) + if !strings.Contains(detailed, "✓ Read") || !strings.Contains(detailed, "✗ Shell") || strings.Contains(detailed, "PRIVATE_RESULT") || strings.Contains(detailed, "\n\n") { + t.Fatalf("bad detailed group: %q", detailed) + } + m, _ = pressKey(m, ctrlT) + if s := toolcallsForTest(t, m); len(s.entries) != 4 { + t.Fatalf("inventory lost calls: %d", len(s.entries)) + } +} + +func TestToolPresentationGroupsBoundariesAndSpacing(t *testing.T) { + for _, boundary := range []struct { + name string + add func(*conversation) + comfortVisible bool + }{ + {"empty assistant", func(c *conversation) { c.startAssistant() }, false}, + {"hidden stats", func(c *conversation) { c.addTurnStat("stat boundary") }, false}, + {"user", func(c *conversation) { c.addUser("user boundary") }, true}, + {"assistant", func(c *conversation) { c.appendAssistant("assistant boundary") }, true}, + {"notice", func(c *conversation) { c.addNotice("notice boundary") }, true}, + {"error", func(c *conversation) { c.addError("error boundary") }, true}, + {"hook", func(c *conversation) { c.addHook("hook boundary", "before", "Read", "blocked") }, true}, + {"delivery", func(c *conversation) { c.addDelivery("schedule", "fire", "delivery boundary") }, true}, + } { + t.Run(boundary.name, func(t *testing.T) { + c := &conversation{} + c.addTool("one", "Read", `{}`) + c.resolveTool("one", "PRIVATE_ONE", false) + boundary.add(c) + boundaryID := uint64(c.scrollback.MetadataAt(1).ID) + c.addTool("two", "Read", `{}`) + c.resolveTool("two", "PRIVATE_TWO", false) + r := newCacheRenderer() + first, second := toolBlockID(t, c.scrollback, "one"), toolBlockID(t, c.scrollback, "two") + for _, detailed := range []bool{false, true} { + frame := r.renderConversationFrame(&c.scrollback, detailed) + plain := stripANSIstr(strings.Join(frame.lines, "\n")) + visible := boundary.comfortVisible || (detailed && boundary.name == "hidden stats") + if visible { + assertLongRunGap(t, frame, first, boundaryID, 1) + assertLongRunGap(t, frame, boundaryID, second, 1) + if !detailed && strings.Count(plain, "● 1 tool run") != 2 { + t.Fatalf("boundary failed to split: %q", plain) + } + } else if detailed { + assertLongRunGap(t, frame, first, second, 0) + } else if !strings.Contains(plain, "● 2 tools run") { + t.Fatalf("hidden card split group: %q", plain) + } + if strings.Contains(plain, "PRIVATE_") { + t.Fatal("settled payload leaked") + } + if got, want := strings.Join(frame.lines, "\n"), r.renderConversation(c, detailed); got != want { + t.Fatalf("frame/string differ: %q != %q", got, want) + } + } + }) + } +} + +func TestToolPresentationGroupsWarmAppendAndVisibility(t *testing.T) { + c := &conversation{} + c.addUser("unrelated prefix") + c.addTool("one", "Read", `{"path":"file.go"}`) + c.resolveTool("one", strings.Repeat("PRIVATE_PAYLOAD", 10000), false, client.ContentBlock{Kind: client.ContentBlockImage, Data: []byte(strings.Repeat("artifact", 10000))}) + r := newCacheRenderer() + check := func(step string, detailed bool) { + t.Helper() + got := strings.Join(r.renderConversationFrame(&c.scrollback, detailed).lines, "\n") + fresh := newCacheRenderer() + if want := strings.Join(fresh.renderConversationFrame(&c.scrollback, detailed).lines, "\n"); got != want { + t.Fatalf("%s stale frame: %q != %q", step, got, want) + } + if want := r.renderConversation(c, detailed); got != want { + t.Fatalf("%s string mismatch", step) + } + if strings.Contains(got, "PRIVATE_PAYLOAD") || strings.Contains(got, "artifact") { + t.Fatalf("%s leaked payload", step) + } + } + check("cold", false) + check("warm", false) + loads, prepares := r.snapshotLoads, r.cardPrepares + check("steady", false) + if r.snapshotLoads != loads || r.cardPrepares != prepares { + t.Fatal("warm group cloned or prepared payloads") + } + c.startAssistant() // hidden insertion extends the cached tail group + boundary := c.scrollback.Notices().AddGuardrailNotice("hidden boundary", true) + check("hidden append", false) + loads = r.snapshotLoads + c.addTool("two", "Shell", `{"command":"false"}`) + c.resolveTool("two", "PRIVATE_FAILURE", true) + check("settled append", false) + if r.snapshotLoads != loads+1 { + t.Fatalf("append reloaded settled payloads: delta %d", r.snapshotLoads-loads) + } + c.addTool("three", "Grep", `{}`) + c.startAssistant() + check("pending non-tail", false) + c.scrollback.Tools().Finish("three", true) + check("non-tail lifecycle failure", false) + got := stripANSIstr(strings.Join(r.renderConversationFrame(&c.scrollback, false).lines, "\n")) + if !strings.Contains(got, "● 3 tools run · 2 failed") { + t.Fatalf("late failure counts: %q", got) + } + c.scrollback.Notices().UpdateGuardrailNotice(boundary, "now visible", false) + check("revealed boundary", false) + c.scrollback.Notices().UpdateGuardrailNotice(boundary, "hidden again", true) + check("hidden boundary again", false) + check("F9", true) + check("comfort", false) + r.setWidth(28) + fresh := newCacheRenderer() + fresh.setWidth(28) + if got, want := strings.Join(r.renderConversationFrame(&c.scrollback, false).lines, "\n"), strings.Join(fresh.renderConversationFrame(&c.scrollback, false).lines, "\n"); got != want { + t.Fatal("width retained stale group") + } + r.resetBlockCaches() + c = &conversation{} + c.addTool("new", "Read", `{}`) + c.resolveTool("new", "new result", false) + r.setWidth(100) + check("rebuilt conversation", false) +} + +func TestToolPresentationGroupsDerivedSelectionAndMemberAnchors(t *testing.T) { + c := &conversation{} + c.addTool("one", "Read", `{"path":"one.go"}`) + c.resolveTool("one", "PRIVATE_ONE", false) + c.addTool("two", "Shell", `{"command":"false"}`) + c.resolveTool("two", "PRIVATE_TWO", true) + c.addTool("three", "Grep", `{}`) + c.addUser("later user keeps reader away from tail") + r := newCacheRenderer() + frame := r.renderConversationFrame(&c.scrollback, false) + summary := frame.toolSummaryRow(toolBlockID(t, c.scrollback, "two")) + if summary < 0 || frame.provenance[summary].region != conversationRegionToolSummary { + t.Fatal("hidden member lacks summary anchor") + } + memberAnchor := readingAnchor{blockID: toolBlockID(t, c.scrollback, "two"), region: conversationRegionBody, text: true} + if got := (conversationView{}).restore(frame, memberAnchor); got != summary { + t.Fatalf("member restored to %d, want summary %d", got, summary) + } + sel := selection{active: true, anchorL: summary, headL: summary, anchorC: 1, headC: graphemeCount(stripANSIstr(frame.lines[summary]))} + if !sel.snapshotLogical(frame, strings.Join(frame.lines, "\n")) || !strings.HasPrefix(sel.copied, "● 2 tools run · 1 failed") || strings.Contains(sel.copied, "PRIVATE_") { + t.Fatalf("summary copy not derived: %q", sel.copied) + } + unchanged := r.renderConversationFrame(&c.scrollback, false) + if !sel.resolveLogical(unchanged) { + t.Fatal("unchanged summary selection cleared") + } + detailed := r.renderConversationFrame(&c.scrollback, true) + if sel.resolveLogical(detailed) { + t.Fatal("hidden summary selection survived F9") + } + exact := detailed.firstRegionRow(memberAnchor.blockID, memberAnchor.region) + if got := (conversationView{}).restore(detailed, memberAnchor); got != exact { + t.Fatal("visible member did not retain exact anchor") + } + frame = r.renderConversationFrame(&c.scrollback, false) + summary = frame.toolSummaryRow(memberAnchor.blockID) + // Select only the hint; a changed count still invalidates the derived display. + text := stripANSIstr(frame.lines[summary]) + at := strings.Index(text, "Toolcalls") + sel = selection{active: true, anchorL: summary, headL: summary, anchorC: graphemeCount(text[:at]), headC: graphemeCount(text)} + if !sel.snapshotLogical(frame, strings.Join(frame.lines, "\n")) { + t.Fatal("hint selection failed") + } + c.scrollback.Tools().Finish("three", true) + frame = r.renderConversationFrame(&c.scrollback, false) + if sel.resolveLogical(frame) { + t.Fatal("changed failure count retained stale selection") + } +} + +func TestToolPresentationGroupsAllFailedAndActiveOnly(t *testing.T) { + var c scrollback.Conversation + c.Tools().Add(scrollback.ToolCall{ID: "ordinary", Name: "Read"}) + c.Tools().Add(scrollback.ToolCall{ID: "subagent", Name: "Subagent"}) + c.Subagents().Start("subagent", scrollback.SubagentStart{}) + c.Tools().Add(scrollback.ToolCall{ID: "team", Name: "Team"}) + c.Teams().Start("team", scrollback.TeamStart{}) + r := newCacheRenderer() + plain := stripANSIstr(strings.Join(r.renderConversationFrame(&c, false).lines, "\n")) + if strings.Contains(plain, "tools run") || !strings.Contains(plain, "Read") || !strings.Contains(plain, "Subagent") || !strings.Contains(plain, "Team") { + t.Fatalf("active-only group lost cards or invented aggregate: %q", plain) + } + c.Tools().Finish("ordinary", true) + c.Subagents().Update("subagent", scrollback.SubagentUpdate{Done: true, Stop: "error"}) + c.Teams().Update("team", scrollback.TeamUpdate{Done: true, Stop: "error"}) + plain = stripANSIstr(strings.Join(r.renderConversationFrame(&c, false).lines, "\n")) + if !strings.Contains(plain, "● 3 tools run · 3 failed") || strings.Contains(plain, "Read") || strings.Contains(plain, "\n\n") { + t.Fatalf("recorded terminal failures not aggregated: %q", plain) + } +} + +func TestToolPresentationGroupsReboundHintsAndRouting(t *testing.T) { + m := scenario2Model(t, 100, 24, false, phaseIdle, map[string][]string{"ExpandConversation": {"ctrl+f10"}, "Toolcalls": {"ctrl+f11"}}) + m.conv.addTool("one", "Read", `{"path":"file.go"}`) + m.conv.resolveTool("one", "PRIVATE_RESULT", false) + m.refreshView() + before := detailText(m) + if !strings.Contains(before, "ctrl+f10 detailed · ctrl+f11 Toolcalls") || strings.Contains(before, "f9") || strings.Contains(before, "ctrl+t") { + t.Fatalf("unresolved hints: %q", before) + } + m, _ = pressKey(m, f9) + if detailText(m) != before { + t.Fatal("default detail key still active after rebind") + } + m, _ = pressKey(m, tea.KeyPressMsg{Code: tea.KeyF10, Mod: tea.ModCtrl}) + if got := detailText(m); !strings.Contains(got, "✓ Read") || strings.Contains(got, "PRIVATE_RESULT") { + t.Fatalf("rebound detail did not restore per-call summary: %q", got) + } + m, _ = pressKey(m, tea.KeyPressMsg{Code: tea.KeyF11, Mod: tea.ModCtrl}) + s := toolcallsForTest(t, m) + if len(s.entries) != 1 { + t.Fatal("rebound inspector lost aggregate member") + } + m, _ = pressKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if got := stripANSIstr(m.View().Content); !strings.Contains(got, "PRIVATE_RESULT") { + t.Fatalf("inspector did not expose full result: %q", got) + } + detailed := m.expandConversation + m, _ = pressKey(m, tea.KeyPressMsg{Code: tea.KeyF10, Mod: tea.ModCtrl}) + if m.expandConversation != detailed { + t.Fatal("detail key escaped inspector ownership") + } +} + +func TestToolPresentationGroupsModelSelectionAndReading(t *testing.T) { + m, _ := selModel(t) + m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 20}) + m.conv.addTool("one", "Read", `{"path":"one.go"}`) + m.conv.resolveTool("one", "PRIVATE_ONE", false) + m.conv.addTool("active", "Read", `{"path":"active.go"}`) + m.conv.addTool("two", "Shell", `{"command":"false"}`) + m.conv.resolveTool("two", "PRIVATE_TWO", true) + m.conv.addUser(strings.Repeat("later prose\n", 40)) + m.refreshView() + m, _ = pressKey(m, f9) + memberID := toolBlockID(t, m.conv.scrollback, "two") + line := m.conversationView.frame.firstRegionRow(memberID, conversationRegionBody) + m.vp.SetYOffset(line) + m.conversationView.mode = anchored + m, _ = pressKey(m, f9) + summary := m.conversationView.frame.toolSummaryRow(memberID) + if m.vp.YOffset() != summary || m.conversationView.mode != anchored { + t.Fatal("collapsed member jumped out of its group") + } + text := stripANSIstr(m.conversationView.frame.lines[summary]) + at := strings.Index(text, "Toolcalls") + m = m.wordSelect(summary, graphemeCount(text[:at])) + if !m.sel.active || m.sel.snapshot != "Toolcalls" { + t.Fatalf("derived model selection: %q", m.sel.snapshot) + } + m = applyAll(m, tea.WindowSizeMsg{Width: 90, Height: 20}) + if !m.sel.active || selectedText(m.vp.GetContent(), m.sel) != "Toolcalls" { + t.Fatal("unchanged summary selection lost on resize") + } + _, cmd := m.copySelection() + if copied, ok := osc52Payload(collectLeaves(cmd)); !ok || copied != "Toolcalls" { + t.Fatalf("summary copy=%q ok=%t", copied, ok) + } + m.conv.resolveTool("active", "PRIVATE_ACTIVE", false) + m.refreshView() + if m.sel.active { + t.Fatal("changed aggregate retained model selection") + } + if m.vp.YOffset() != m.conversationView.frame.toolSummaryRow(memberID) || m.conversationView.mode != anchored { + t.Fatal("settlement moved reading away from summary") + } + m, _ = pressKey(m, f9) + if got := m.conversationView.frame.provenance[m.vp.YOffset()].blockID; got != memberID { + t.Fatalf("F9 lost preserved hidden member: got %d want %d", got, memberID) + } + m.vp.GotoBottom() + m.conversationView.mode = followTail + m.conv.addNotice("tail notice") + m.refreshView() + if !m.vp.AtBottom() || m.conversationView.mode != followTail { + t.Fatal("grouping stole tail-follow ownership") + } +} + +func TestToolPresentationGroupsMixedOrder(t *testing.T) { + m := scenario2Model(t, 100, 24, false, phaseRunning, nil) + m = applyAll(m, + client.ToolCallMsg{ID: "sub", Name: "Subagent", Args: `{"goal":"active goal"}`}, + client.SubagentMsg{Kind: client.SubagentStart, ParentCallID: "sub", ChildID: "child", Goal: "active goal"}, + client.ToolCallMsg{ID: "read", Name: "Read", Args: `{"path":"file.go"}`}, client.ToolResultMsg{CallID: "read", Content: "PRIVATE_READ"}, + client.ToolCallMsg{ID: "shell", Name: "Shell", Args: `{"command":"false"}`}, client.ToolResultMsg{CallID: "shell", Content: "PRIVATE_FAIL", IsError: true}, + client.ToolCallMsg{ID: "grep", Name: "Grep", Args: `{"pattern":"finalizing"}`}, client.ToolResultMsg{CallID: "grep", Content: "PRIVATE_PROVISIONAL", IsError: true, Available: true}, + ) + got := detailText(m) + if !strings.HasPrefix(strings.TrimSpace(got), "● 2 tools run · 1 failed") || !strings.Contains(got, "Subagent") || !strings.Contains(got, "finalizing") { + t.Fatalf("bad mixed group: %q", got) + } + if strings.Contains(got, "PRIVATE_READ") || strings.Contains(got, "PRIVATE_FAIL") { + t.Fatalf("settled payload leaked: %q", got) + } + firstRow := func(frame renderedFrame, id uint64) int { + t.Helper() + for row, provenance := range frame.provenance { + if provenance.blockID == id { + return row + } + } + t.Fatalf("displayed frame has no rows for block %d", id) + return -1 + } + assertNoSeparator := func(frame renderedFrame, left, right int) { + t.Helper() + for row := left + 1; row < right; row++ { + if frame.provenance[row].separator { + t.Fatalf("unexpected separator row %d between displayed rows %d and %d", row, left, right) + } + } + } + frame := m.conversationView.frame + readID := toolBlockID(t, m.conv.scrollback, "read") + summaryRow := frame.toolSummaryRow(readID) + subRow := firstRow(frame, toolBlockID(t, m.conv.scrollback, "sub")) + grepRow := firstRow(frame, toolBlockID(t, m.conv.scrollback, "grep")) + if summaryRow < 0 || summaryRow >= subRow || subRow >= grepRow { + t.Fatalf("comfort frame order summary=%d subagent=%d grep=%d", summaryRow, subRow, grepRow) + } + assertNoSeparator(frame, summaryRow, subRow) + assertNoSeparator(frame, subRow, grepRow) + + active := scenario2Model(t, 100, 24, false, phaseRunning, nil) + active = applyAll(active, + client.ToolCallMsg{ID: "sub", Name: "Subagent", Args: `{"goal":"active goal"}`}, + client.SubagentMsg{Kind: client.SubagentStart, ParentCallID: "sub", ChildID: "child", Goal: "active goal"}, + client.ToolCallMsg{ID: "grep", Name: "Grep", Args: `{"pattern":"active"}`}, + ) + activeFrame := active.conversationView.frame + activeSubRow := firstRow(activeFrame, toolBlockID(t, active.conv.scrollback, "sub")) + activeGrepRow := firstRow(activeFrame, toolBlockID(t, active.conv.scrollback, "grep")) + if activeFrame.toolSummaryRow(toolBlockID(t, active.conv.scrollback, "sub")) >= 0 || activeSubRow >= activeGrepRow { + t.Fatalf("all-active comfort frame reordered or aggregated cards: subagent=%d grep=%d", activeSubRow, activeGrepRow) + } + assertNoSeparator(activeFrame, activeSubRow, activeGrepRow) + + m, _ = pressKey(m, f9) + got = detailText(m) + if strings.Index(got, "Subagent") >= strings.Index(got, "✓ Read") || strings.Index(got, "✓ Read") >= strings.Index(got, "✗ Shell") || strings.Index(got, "✗ Shell") >= strings.Index(got, "Grep") { + t.Fatalf("source order lost: %q", got) + } +} diff --git a/cmd/mecatui/ui/toolcall_projection.go b/cmd/mecatui/ui/toolcall_projection.go index 35ddb156cc..9388615895 100644 --- a/cmd/mecatui/ui/toolcall_projection.go +++ b/cmd/mecatui/ui/toolcall_projection.go @@ -32,6 +32,18 @@ func projectToolCall(metadata scrollback.ToolCallMetadata) toolcallProjection { if title, ok := mcpTitle(metadata.Name); ok { displayName = terminaltext.SanitizeSingleLine(title) } + return toolcallProjection{ + blockID: metadata.ID, revision: metadata.Revision, + displayName: displayName, fullName: fullName, + intent: renderfmt.ToolIntent(metadata.Name, metadata.Arguments), + state: toolCallState(metadata), + } +} + +// toolCallState is the lifecycle-only metadata projection used on warm render +// passes. It deliberately does not inspect presentation fields such as name or +// arguments, which are only needed when preparing a cache-miss card. +func toolCallState(metadata scrollback.ToolCallMetadata) toolcallProjectionState { state := toolcallPending switch { case metadata.ResultReceived && !metadata.Provisional && metadata.ResultError: @@ -47,12 +59,7 @@ func projectToolCall(metadata scrollback.ToolCallMetadata) toolcallProjection { case metadata.Terminal: state = toolcallAwaitingResult } - return toolcallProjection{ - blockID: metadata.ID, revision: metadata.Revision, - displayName: displayName, fullName: fullName, - intent: renderfmt.ToolIntent(metadata.Name, metadata.Arguments), - state: state, - } + return state } func (p toolcallProjection) settled() bool { diff --git a/cmd/mecatui/ui/toolcall_projection_test.go b/cmd/mecatui/ui/toolcall_projection_test.go index c596f7d8e6..f16377eebf 100644 --- a/cmd/mecatui/ui/toolcall_projection_test.go +++ b/cmd/mecatui/ui/toolcall_projection_test.go @@ -61,6 +61,18 @@ func TestToolcallProjectionStates(t *testing.T) { if got.state != test.state { t.Fatalf("state = %v, want %v", got.state, test.state) } + if state := toolCallState(metadata); state != test.state { + t.Fatalf("lifecycle-only state = %v, want %v", state, test.state) + } + frame := newCacheRenderer().renderConversationFrame(&c, false) + plain := stripANSIstr(strings.Join(frame.lines, "\n")) + if got.settled() { + if !strings.Contains(plain, "● 1 tool run") || (got.state == toolcallFailed) != strings.Contains(plain, "1 failed") { + t.Fatalf("settled projection not aggregated: %q", plain) + } + } else if strings.Contains(plain, "tool run") || !strings.Contains(plain, got.line().Status()) { + t.Fatalf("nonsettled projection aggregated or lost status: %q", plain) + } if got.settled() != (test.state == toolcallDone || test.state == toolcallFailed) { t.Fatalf("settled = %v for state %v", got.settled(), test.state) } @@ -68,6 +80,32 @@ func TestToolcallProjectionStates(t *testing.T) { } } +func TestRenderPassesWarmToolStateSkipsPresentationProjection(t *testing.T) { + var c scrollback.Conversation + arguments := `{"content":"` + strings.Repeat("x", 4<<20) + `"}` + c.Tools().Add(scrollback.ToolCall{ID: "write", Name: "Write", Arguments: arguments}) + c.Tools().Resolve("write", scrollback.ToolResult{}) + + r := newCacheRenderer() + r.renderConversationFrame(&c, false) // cache miss: full card presentation is expected. + loads, prepares := r.snapshotLoads, r.cardPrepares + passes, firstChanged := r.renderPasses(&c, false) + if firstChanged != c.Len() || passes[0].toolState != toolcallDone { + t.Fatalf("warm lifecycle pass = firstChanged %d, state %v", firstChanged, passes[0].toolState) + } + if allocations := testing.AllocsPerRun(100, func() { r.renderPasses(&c, false) }); allocations != 0 { + t.Fatalf("warm lifecycle pass allocations = %v, want 0", allocations) + } + + frame := r.renderConversationFrame(&c, false) + if row := frame.toolSummaryRow(uint64(c.MetadataAt(0).ID)); row < 0 { + t.Fatal("warm render lost settled lifecycle summary") + } + if r.snapshotLoads != loads || r.cardPrepares != prepares { + t.Fatal("warm render reloaded or prepared the cached tool card") + } +} + func TestToolcallProjectionProvisionalConfirmationRetainsBlock(t *testing.T) { var c scrollback.Conversation c.Tools().Add(scrollback.ToolCall{ID: "call", Name: "Read"}) diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index c982dea0b8..f879808cc9 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -298,7 +298,7 @@ func TestMecatuiToolcallsInspector_StatusGlyphsAcrossThemes(t *testing.T) { failed := newToolcallsInspectorModel(t) failed.deps.Theme = th failed = applyAll(failed, tea.WindowSizeMsg{Width: 80, Height: 30}, client.ToolCallMsg{ID: "failed", Name: "Read", Args: `{"path":"failed.go"}`}, client.ToolResultMsg{CallID: "failed", Content: "failure body", IsError: true}) - conversation := failed.rend.renderConversationFrame(&failed.conv.scrollback, false) + conversation := failed.rend.renderConversationFrame(&failed.conv.scrollback, true) if got := strings.Join(blockRows(conversation, toolBlockID(t, failed.conv.scrollback, "failed")), "\n"); !strings.Contains(got, "✗") || !strings.Contains(stripANSIstr(got), "✗ Read · failed.go · failed") { t.Fatalf("failed conversation row: %q", got) } diff --git a/cmd/mecatui/ui/toolcalls_presentation_test.go b/cmd/mecatui/ui/toolcalls_presentation_test.go index d09385c1aa..2ba59ee329 100644 --- a/cmd/mecatui/ui/toolcalls_presentation_test.go +++ b/cmd/mecatui/ui/toolcalls_presentation_test.go @@ -55,7 +55,7 @@ func TestGrepIntentRenderingParityAndBounds(t *testing.T) { m.conv.resolveTool("hostile-grep", "matched", false) m.rend.setWidth(52) - frame := m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame := m.rend.renderConversationFrame(&m.conv.scrollback, true) rows := blockRows(frame, toolBlockID(t, m.conv.scrollback, "grep")) const want = `✓ Grep · "TODO" in cmd/**/*.go` if len(rows) != 1 || strings.TrimSpace(stripANSIstr(rows[0])) != want { @@ -68,7 +68,7 @@ func TestGrepIntentRenderingParityAndBounds(t *testing.T) { } m.rend.setWidth(18) - frame = m.rend.renderConversationFrame(&m.conv.scrollback, false) + frame = m.rend.renderConversationFrame(&m.conv.scrollback, true) for _, id := range []string{"grep", "hostile-grep"} { rows = blockRows(frame, toolBlockID(t, m.conv.scrollback, id)) if len(rows) != 1 || strings.ContainsAny(stripANSIstr(rows[0]), "\x1b\a\n\r\t") || ansi.StringWidth(rows[0]) > 18 { diff --git a/cmd/mecatui/ui/transcript_theme_test.go b/cmd/mecatui/ui/transcript_theme_test.go new file mode 100644 index 0000000000..ee9d5d5a56 --- /dev/null +++ b/cmd/mecatui/ui/transcript_theme_test.go @@ -0,0 +1,294 @@ +package ui + +import ( + "image/color" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/stacklok/mecatl/cmd/mecatui/theme" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +func transcriptThemeModel(t *testing.T, th theme.Theme, detect bool) Model { + t.Helper() + m := newTestModelFromDeps(Deps{Theme: th, ThemeAutoDetect: detect, Ctx: t.Context(), NoBanner: true}) + m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 80}, tea.ColorProfileMsg{Profile: colorprofile.TrueColor}) + m.phase = phaseIdle + m.conv.addUser("user body") + m.conv.appendReasoning("reasoning body") + m.conv.appendAssistant("ordinary answer with [docs](https://example.test) and `identifier`.") + m.conv.endReasoningStream() + m.conv.addNotice("harness body") + m.conv.addTurnStat("turn statistics") + m.conv.addRecoverNotice("recovered session") + m.conv.addHook("session started", "SessionStart", "", "info") + m.conv.addHook("denied", "PreToolUse", "Read", "blocked") + m.conv.addDelivery("daily", "fire-1", "scheduled body") + m.conv.addTool("done", "Read", `{"path":"done.go"}`) + m.conv.resolveTool("done", "done", false) + m.conv.addTool("failed", "Read", `{"path":"failed.go"}`) + m.conv.resolveTool("failed", "failed", true) + m.conv.addTool("finalizing", "Read", `{"path":"finalizing.go"}`) + m.conv.resolveAvailableTool("finalizing", "available", false) + m.conv.addTool("pending", "Read", `{"path":"pending.go"}`) + m.refreshView() + return m +} + +func assertTranscriptTheme(t *testing.T, m *Model) { + t.Helper() + th := m.deps.Theme + out := m.conversationContent() + for _, want := range []string{ + th.Style("transcriptUserMarker").Render(theme.TranscriptUserGlyph), + th.Style("transcriptUserHeading").Render(" you"), + th.Style("transcriptHarnessMarker").Render(theme.TranscriptHarnessGlyph), + th.Style("transcriptHarnessHeading").Render(" system"), + th.Style("transcriptLLMMarker").Render(theme.TranscriptLLMGlyph), + th.Style("transcriptLLMHeading").Render(" mecatl"), + } { + if !strings.Contains(out, want) { + t.Errorf("transcript lacks styled token %q", want) + } + } + for _, tc := range []struct { + index int + role string + }{{0, "transcriptUserText"}, {1, "transcriptReasoningText"}, {2, "transcriptHarnessText"}} { + block := m.rend.renderSnapshot(tc.index, m.conv.scrollback.SnapshotAt(tc.index), m.expandConversation) + fg := ansi.NewStyle().ForegroundColor(th.Style(tc.role).GetForeground()).String() + // SGR attributes may share a sequence with the foreground. + fg = strings.TrimSuffix(strings.TrimPrefix(fg, "\x1b["), "m") + if !strings.Contains(block, fg) { + t.Errorf("block %d lacks %s foreground %q: %q", tc.index, tc.role, fg, block) + } + } + if m.expandConversation { + for _, want := range []string{ + th.Style("transcriptStatistics").Render("turn statistics"), + "reasoning body", + } { + if !strings.Contains(out, want) { + t.Errorf("expanded transcript lacks %q", want) + } + } + } else { + for _, hidden := range []string{"reasoning body", "turn statistics"} { + if strings.Contains(stripANSIstr(out), hidden) { + t.Errorf("compact transcript exposed %q", hidden) + } + } + } + for _, want := range []string{"⚠ recovered session", "• hook SessionStart: session started", "✗ hook PreToolUse · Read: blocked — denied", "⏰ scheduled task daily — delivery · fire fire-1", "✓", "✗", "…", "finalizing"} { + if !strings.Contains(stripANSIstr(out), want) { + t.Errorf("transcript lost status/provenance %q", want) + } + } + fresh := newRenderer(th, m.rend.marks) + fresh.setWidth(m.width) + got := m.rend.renderConversationFrame(&m.conv.scrollback, m.expandConversation) + want := fresh.renderConversationFrame(&m.conv.scrollback, m.expandConversation) + if !reflect.DeepEqual(got, want) { + t.Fatal("cached and fresh transcript frames differ") + } + view := m.View().Content + for _, want := range []string{out, view} { + for _, token := range []string{ + th.Style("transcriptUserMarker").Render(theme.TranscriptUserGlyph), + th.Style("transcriptUserHeading").Render(" you"), + th.Style("transcriptHarnessMarker").Render(theme.TranscriptHarnessGlyph), + } { + if !strings.Contains(want, token) { + t.Errorf("styled transcript token %q did not reach rendered output", token) + } + } + for _, role := range []string{"transcriptHarnessText", "transcriptReasoningText"} { + fg := ansi.NewStyle().ForegroundColor(th.Style(role).GetForeground()).String() + if !strings.Contains(want, strings.TrimSuffix(strings.TrimPrefix(fg, "\x1b["), "m")) { + t.Errorf("%s foreground did not reach rendered output", role) + } + } + if m.expandConversation && (!strings.Contains(want, th.Style("transcriptLLMMarker").Render(theme.TranscriptLLMGlyph)) || + !strings.Contains(want, th.Style("transcriptStatistics").Render("turn statistics"))) { + t.Error("expanded LLM/statistics styles did not reach the rendered output") + } + } +} + +func TestTranscriptAutoDetectRenderedLightAndCaches(t *testing.T) { + for _, expanded := range []bool{false, true} { + t.Run(map[bool]string{false: "compact", true: "F9"}[expanded], func(t *testing.T) { + m := transcriptThemeModel(t, theme.NewRegistry().Default(), true) + if expanded { + m = applyAll(m, f9) + } + assertTranscriptTheme(t, &m) + dark := m.rend + _ = m.View() + _ = m.conversationContent() + if len(dark.blocks.rendered) == 0 || len(dark.blocks.markdown) == 0 || dark.blocks.prefixN == 0 || len(dark.cache) == 0 { + t.Fatal("dark block/markdown/join/glamour caches were not primed") + } + before := m.conv.testBlocks() + m = applyAll(m, tea.BackgroundColorMsg{Color: color.White}) + if m.rend == dark || m.deps.Theme.Name != "solar" || !reflect.DeepEqual(before, m.conv.testBlocks()) { + t.Fatal("light response failed to replace renderer without changing conversation") + } + assertTranscriptTheme(t, &m) + out := m.conversationContent() + for _, role := range []string{"transcriptUserMarker", "transcriptLLMMarker", "transcriptHarnessMarker"} { + fg := ansi.NewStyle().ForegroundColor(dark.th.Style(role).GetForeground()).String() + fg = strings.TrimSuffix(strings.TrimPrefix(fg, "\x1b["), "m") + if strings.Contains(out, fg) || strings.Contains(m.View().Content, fg) { + t.Errorf("light output retains dark %s", role) + } + } + m = applyAll(m, tea.BackgroundColorMsg{Color: color.Black}) + if m.conversationContent() != out { + t.Fatal("duplicate response changed rendered transcript") + } + m = applyAll(m, ctrlT) + if s := toolcallsForTest(t, m); s == nil || len(s.entries) != 4 || m.expandConversation != expanded { + t.Fatal("Ctrl+T changed conversation detail or tool inspector entries") + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.conversationContent() != out { + t.Fatal("inspector close changed transcript") + } + m = applyAll(m, f9) + assertTranscriptTheme(t, &m) + }) + } +} + +func TestTranscriptAutoDetectRetainsAnchoredSelection(t *testing.T) { + m := transcriptThemeModel(t, theme.NewRegistry().Default(), true) + const text = "ordinary text" + for range 40 { + m.conv.addNotice("selected " + text) + } + m = applyAll(m, tea.WindowSizeMsg{Width: 100, Height: 30}, tea.KeyPressMsg{Code: tea.KeyPgUp}) + if m.conversationView.mode != anchored || m.vp.YOffset() == 0 || m.vp.AtBottom() { + t.Fatal("fixture must be anchored away from both ends of the conversation") + } + selectedLine := -1 + lines := strings.Split(m.vp.GetContent(), "\n") + for i := m.vp.YOffset() + 1; i < m.vp.YOffset()+m.vp.Height()-1; i++ { + if strings.Contains(ansi.Strip(lines[i]), text) { + selectedLine = i + break + } + } + if selectedLine < 0 { + t.Fatal("ordinary notice body not visible in anchored viewport") + } + plain := ansi.Strip(lines[selectedLine]) + x := lipgloss.Width(plain[:strings.Index(plain, text)]) + y := convTopRow(m) + selectedLine - m.vp.YOffset() + m, _ = pressMouse(m, tea.MouseLeft, x, y) + m, _ = motionMouse(m, x+len(text), y) + if !m.sel.active || selectedText(m.vp.GetContent(), m.sel) != text || m.sel.snapshot != text || m.sel.copied != text || m.sel.anchorPoint.blockID == 0 { + t.Fatalf("fixture must select actual ordinary body text with logical endpoints: %+v", m.sel) + } + beforeSelection, beforeAnchor := m.sel, m.conversationView.anchor + beforeOffset, beforeMode, beforeHeight := m.vp.YOffset(), m.conversationView.mode, m.vp.Height() + dark := m.rend + darkBG := selectionBgSGR(t, m) + if !strings.Contains(m.View().Content, openSelectionSGR(t, m)+text) || + len(dark.blocks.rendered) == 0 || len(dark.blocks.markdown) == 0 || dark.blocks.prefixN == 0 || len(dark.cache) == 0 { + t.Fatal("dark selection and block/markdown/join/glamour caches were not primed") + } + m = applyAll(m, tea.BackgroundColorMsg{Color: color.White}) + if m.deps.Theme.Name != "solar" || m.rend == dark { + t.Fatal("real light response did not switch theme and rebuild renderer") + } + if m.sel != beforeSelection || selectedText(m.vp.GetContent(), m.sel) != text || m.vp.Height() != beforeHeight || + m.conversationView.anchor != beforeAnchor || m.conversationView.mode != beforeMode { + t.Fatalf("light response changed selection payload/endpoints or anchored reading position: selection=%+v want=%+v anchor=%+v want=%+v offset=%d want=%d mode=%v want=%v height=%d want=%d", m.sel, beforeSelection, m.conversationView.anchor, beforeAnchor, m.vp.YOffset(), beforeOffset, m.conversationView.mode, beforeMode, m.vp.Height(), beforeHeight) + } + lightBG := selectionBgSGR(t, m) + if lightBG == darkBG || !strings.Contains(m.View().Content, openSelectionSGR(t, m)+text) || strings.Contains(m.View().Content, darkBG) { + t.Fatal("selected text did not acquire the light selection style, or retained dark styling") + } + // The notice does not rewrap here; F9 only changes earlier assistant/stat rows. + for _, msg := range []tea.Msg{tea.WindowSizeMsg{Width: 80, Height: 30}, f9, f9} { + m = applyAll(m, msg) + if !m.sel.active || m.sel.snapshot != text || selectedText(m.vp.GetContent(), m.sel) != text || + m.sel.anchorPoint != beforeSelection.anchorPoint || m.sel.headPoint != beforeSelection.headPoint || + m.conversationView.anchor != beforeAnchor || m.conversationView.mode != anchored { + t.Fatalf("%T changed logical selection or reading anchor", msg) + } + if !strings.Contains(m.View().Content, openSelectionSGR(t, m)+text) || strings.Contains(m.View().Content, darkBG) { + t.Fatalf("%T lost light selection highlight", msg) + } + } +} + +func TestTranscriptAutoDetectRenderedFallbacks(t *testing.T) { + custom, err := theme.ParseTheme([]byte(`{"name":"custom","palette":{"user":"#112233","assistant":"#334455","text":"#ddeeff","textMuted":"#778899","info":"#556677"}}`)) + if err != nil { + t.Fatal(err) + } + if custom.Palette.User != "#112233" || custom.Palette.Text != "#ddeeff" { + t.Fatal("custom theme fixture did not install palette overrides") + } + for _, tc := range []struct { + name string + th theme.Theme + detect bool + reply color.Color + }{ + {"absent", theme.NewRegistry().Default(), true, nil}, + {"dark", theme.NewRegistry().Default(), true, color.Black}, + {"explicit builtin", theme.NewRegistry().Default(), false, color.White}, + {"explicit light", theme.Solar(), false, color.Black}, + {"custom", custom, false, color.White}, + {"custom New", theme.New("custom-new", custom.Palette), false, color.White}, + } { + t.Run(tc.name, func(t *testing.T) { + m := transcriptThemeModel(t, tc.th, tc.detect) + m = applyAll(m, f9) + before := m.conversationContent() + if tc.reply != nil { + m = applyAll(m, tea.BackgroundColorMsg{Color: tc.reply}) + } + if m.conversationContent() != before || m.deps.Theme.Name != tc.th.Name { + t.Fatal("fallback or explicit theme changed rendered transcript") + } + assertTranscriptTheme(t, &m) + if tc.reply != nil { + m = applyAll(m, tea.BackgroundColorMsg{Color: color.White}) + if m.conversationContent() != before { + t.Fatal("late light response changed fallback or explicit theme") + } + } + }) + } +} + +func TestTranscriptAnswerPreservesMarkdown(t *testing.T) { + for _, th := range []theme.Theme{theme.NewRegistry().Default(), theme.Solar()} { + r := newRenderer(th, defaultHelpKeys()) + r.setWidth(100) + p := scrollback.AssistantCardSnapshot{Text: "ordinary answer with [docs](https://example.test) and `identifier`."} + out := r.renderSnapshot(0, testSnapshot(0, p), false) + for _, hex := range []string{th.Palette.MdText, th.Palette.MdLink, th.Palette.MdQuote} { + fg := ansi.NewStyle().ForegroundColor(lipgloss.Color(hex)).String() + if !strings.Contains(out, strings.TrimSuffix(strings.TrimPrefix(fg, "\x1b["), "m")) { + t.Errorf("%s answer lost Markdown colour %s: %q", th.Name, hex, out) + } + } + actorFG := ansi.NewStyle().ForegroundColor(th.Style("transcriptLLMMarker").GetForeground()).String() + body := strings.Join(strings.Split(out, "\n")[2:], "\n") + if strings.Contains(body, "\x1b[3;") || strings.Contains(body, "\x1b[3m") || + strings.Contains(body, strings.TrimSuffix(strings.TrimPrefix(actorFG, "\x1b["), "m")) { + t.Errorf("%s normal answer became italic or actor-accent washed", th.Name) + } + } +} diff --git a/contracts/gen/go/mecatl/v1/harness.pb.go b/contracts/gen/go/mecatl/v1/harness.pb.go index eb8646e108..1b21efbbd3 100644 --- a/contracts/gen/go/mecatl/v1/harness.pb.go +++ b/contracts/gen/go/mecatl/v1/harness.pb.go @@ -2554,7 +2554,8 @@ func (x *ActivityReplayStatus) GetAuthoritative() bool { } // GetSessionTranscriptResponse is one coherent SessionStore snapshot projection. -// messages are the loaded aggregate's current Conversation.Messages in order. +// messages are the ordered human-display projection; internal harness control +// continuations are omitted, while a successful empty projection is complete. type GetSessionTranscriptResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // session_id is copied from the same loaded aggregate as messages. diff --git a/contracts/proto/mecatl/v1/harness.proto b/contracts/proto/mecatl/v1/harness.proto index 8d41c44529..1c77acc24e 100644 --- a/contracts/proto/mecatl/v1/harness.proto +++ b/contracts/proto/mecatl/v1/harness.proto @@ -944,7 +944,8 @@ message ActivityReplayStatus { } // GetSessionTranscriptResponse is one coherent SessionStore snapshot projection. -// messages are the loaded aggregate's current Conversation.Messages in order. +// messages are the ordered human-display projection; internal harness control +// continuations are omitted, while a successful empty projection is complete. message GetSessionTranscriptResponse { // session_id is copied from the same loaded aggregate as messages. string session_id = 1; diff --git a/docs/tui.md b/docs/tui.md index 390089ca96..ac5d270a63 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -144,7 +144,25 @@ existing chord a second meaning in the same focus context; stay consistent with The conversation follows the tail while the reader is at the bottom; once they scroll up, renders keep a logical reading anchor until they return. Use `conversationView` rather than setting viewport offsets directly. Speaker labels, -hanging alignment, and spacing distinguish turns without color. +hanging alignment, and spacing distinguish turns without color. User prompts, +model responses, and ordinary harness notices use accented, bold `▶ you`, +`◀ mecatl`, and `◆ system` labels in both views; their bodies remain neutral. +Recovery and tool or hook outcome markers retain their own warning or lifecycle +meaning. + +In the default view, render one aggregate for settled adjacent visible +Tool, Subagent, and Team calls, followed by any active cards in their original +relative order. Presentation adjacency, rather than dispatch or concurrency, +defines the group. The detail view restores settled calls as one-line summaries +in source order; keep arguments and results in the Toolcalls inspector. Preserve +one blank row between response blocks or groups, with none between calls in a +tool group. + +Snapshot transcript loads use the server's human-display projection, which omits +known background-work and no-progress control continuations only with explicit +stored harness provenance. Preserve user/legacy lookalikes, compaction summaries +and scheduled deliveries; the stored model conversation and event replay remain +unchanged. Collapse large tool arguments and results into cards and keep hidden content inspectable. A preview must not flood scrollback or show more than the bounded, @@ -158,8 +176,21 @@ success claim, and a notice never shows raw transport or credential material. ## Styling and terminal behavior -Use semantic slots from `theme.Palette` so a surface works in light and dark -themes. The fixed danger badge and the contrast-calculated selection foreground are +Use theme-owned semantic styles so a surface works in light and dark themes. +Transcript actor glyph constants are separate from +`transcript{LLM,Tool,User,Harness}{Marker,Rail,Text}` styles. Marker styles own +only the actor symbol; body and label text, reasoning, statistics, and outcome +indicators have independent styles. Rail roles and `transcriptRunRail` are defined +without adding connected transcript rails. Markdown keeps its dedicated palette +and syntax styles. + +Built-in Aztec and Solar have separate dark/light transcript recipes. Custom +themes derive transcript defaults from existing palette slots, including when +they override a built-in name. Startup background detection selects built-in +Solar only when no explicit theme is supplied on a real TTY; `switchTheme` +replaces the renderer and its caches so baked-in styles change together. + +The fixed danger badge and the contrast-calculated selection foreground are deliberate exceptions, not a model for widget-local colors. Check ANSI-stripped rendering when a visual cue carries meaning. diff --git a/internal/adapter/executionclient/native_pending_approval_tui_integration_test.go b/internal/adapter/executionclient/native_pending_approval_tui_integration_test.go index dd7e55ebc8..9f66c8dfaf 100644 --- a/internal/adapter/executionclient/native_pending_approval_tui_integration_test.go +++ b/internal/adapter/executionclient/native_pending_approval_tui_integration_test.go @@ -588,12 +588,19 @@ func TestNativePendingApprovalStartupRecovery(t *testing.T) { if toolResults != 1 || continuationRequests.Load() != 1 || stats.ownerConverse.Load() != 0 || stats.ownerResolve.Load() != 1 || stats.foreignCalls.Load() != 3 { t.Fatalf("unexpected work counts: tool-results=%d model=%d converse=%d resolve=%d foreign=%d", toolResults, continuationRequests.Load(), stats.ownerConverse.Load(), stats.ownerResolve.Load(), stats.foreignCalls.Load()) } + wantAggregate := "● 1 tool run · 1 failed" wantSummary := "✗ Shell · printf bridge-command" if tc.wantRun { + wantAggregate = "● 1 tool run · f9" wantSummary = "✓ Shell · printf bridge-command" } - if strings.Count(view, wantSummary) != 1 || !strings.Contains(view, tc.wantOutput) || !strings.Contains(view, "draft only") || strings.Contains(view, "● mecatl") || strings.Contains(view, "enter queue") || strings.Contains(view, "… Shell") || strings.Contains(view, "│ ✓ Shell") || strings.Contains(view, "│ ✗ Shell") { - t.Fatalf("terminal view lost draft, continuation, or one-line resolved tool summary:\n%s", view) + if strings.Count(view, wantAggregate) != 1 || strings.Contains(view, wantSummary) || !strings.Contains(view, tc.wantOutput) || !strings.Contains(view, "draft only") || !strings.Contains(view, "◀ mecatl") || strings.Contains(view, "enter queue") || strings.Contains(view, "… Shell") { + t.Fatalf("terminal view lost draft, continuation, or settled tool aggregate:\n%s", view) + } + detailed, _ := final.Model.Update(tea.KeyPressMsg{Code: tea.KeyF9}) + detailView := ansi.Strip(detailed.View().Content) + if strings.Count(detailView, wantSummary) != 1 || strings.Contains(detailView, "● 1 tool run") || !strings.Contains(detailView, tc.wantOutput) || !strings.Contains(detailView, "draft only") || strings.Contains(detailView, "│ ✓ Shell") || strings.Contains(detailView, "│ ✗ Shell") { + t.Fatalf("detail view lost draft, continuation, or one-line resolved tool summary:\n%s", detailView) } executor.mu.Lock() operations := append([]executionenv.Operation(nil), executor.operations...) diff --git a/internal/adapter/server/transcript.go b/internal/adapter/server/transcript.go index 73f0638cce..dd814fd50a 100644 --- a/internal/adapter/server/transcript.go +++ b/internal/adapter/server/transcript.go @@ -18,7 +18,8 @@ type ActivityReplayStatus struct { } // SessionTranscript is the coherent, human-displayable projection of one loaded -// session aggregate. A successful load is complete even when Messages is empty. +// session aggregate. Harness control continuations are omitted; a successful load +// is complete even when Messages is empty. type SessionTranscript struct { SessionID session.SessionID Messages []session.Message @@ -64,7 +65,7 @@ func (s *Service) transcriptFromSession(sess *session.Session) (*SessionTranscri } return &SessionTranscript{ SessionID: sess.ID, - Messages: session.CloneMessages(sess.Conversation.Messages), + Messages: transcriptMessages(sess.Conversation.Messages), Complete: true, Kind: sess.Kind, Relationship: sess.Relationship, @@ -73,3 +74,21 @@ func (s *Service) transcriptFromSession(sess *session.Session) (*SessionTranscri }, }, nil } + +// transcriptMessages projects model history for human display without mutating +// it. Only positively proven, text-only harness continuations that are neither +// summaries nor genuine prompts are internal control messages. +func transcriptMessages(messages []session.Message) []session.Message { + projected := make([]session.Message, 0, len(messages)) + for _, message := range messages { + if message.Role == session.RoleUser && + message.UserPromptProvenance == session.UserPromptProvenanceHarness && + len(message.Parts) == 0 && + !session.IsSynthesisedSummary(message.Text) && + !session.IsGenuineUserPrompt(message) { + continue + } + projected = append(projected, message) + } + return projected +} diff --git a/internal/adapter/server/transcript_test.go b/internal/adapter/server/transcript_test.go index 2995e77cf9..7c6a3b9e90 100644 --- a/internal/adapter/server/transcript_test.go +++ b/internal/adapter/server/transcript_test.go @@ -8,6 +8,7 @@ import ( "iter" "net/http" "net/http/httptest" + "reflect" "slices" "strings" "sync" @@ -92,6 +93,22 @@ func (s *transcriptStore) counts() (loads, saves int) { return s.loads, s.saves } +type samePointerTranscriptStore struct { + session *session.Session +} + +func (s *samePointerTranscriptStore) Save(_ context.Context, sess *session.Session) error { + s.session = sess + return nil +} + +func (s *samePointerTranscriptStore) Load(_ context.Context, id session.SessionID) (*session.Session, error) { + if s.session == nil || s.session.ID != id { + return nil, fmt.Errorf("%w: %q", port.ErrSessionNotFound, id) + } + return s.session, nil +} + type countingLease struct { inner port.SessionLease calls atomic.Int32 @@ -162,6 +179,127 @@ func TestSessionContinuityUX_Scenario3_AuthoritativeTranscript(t *testing.T) { } } +func TestTranscriptProjectionHasIndependentMessageSlice(t *testing.T) { + ctx := context.Background() + sess := session.New("transcript-detachment", session.ModeDefault, session.EnvironmentRef{Kind: session.EnvKindLocal, ID: "/workspace", Revision: "in-tree-v1"}, session.Limits{}, time.Unix(1, 0)) + messages := []session.Message{ + session.NewUserMessage("retain this prompt"), + {Role: session.RoleUser, Text: session.NoProgressNudgeText, UserPromptProvenance: session.UserPromptProvenanceHarness}, + session.NewAssistantMessage("retain this answer", "", nil), + } + if err := sess.SeedHistory(messages); err != nil { + t.Fatalf("SeedHistory: %v", err) + } + store := &samePointerTranscriptStore{session: sess} + got, err := newTranscriptService(t, store, nil, false, &atomic.Int32{}, nil).GetTranscript(ctx, sess.ID) + if err != nil { + t.Fatalf("GetTranscript: %v", err) + } + if len(got.Messages) != 2 { + t.Fatalf("display messages = %d, want 2", len(got.Messages)) + } + got.Messages[0] = session.NewUserMessage("replace display message") + got.Messages = append(got.Messages, session.NewUserMessage("append display message")) + if !reflect.DeepEqual(sess.Conversation.Messages, messages) { + t.Fatalf("display slice mutation changed aggregate: %+v", sess.Conversation.Messages) + } +} + +func TestTranscriptOmitsOnlyProvenHarnessControlContinuations(t *testing.T) { + ctx := context.Background() + base := memstore.New() + sess := session.New("harness-control", session.ModeDefault, session.EnvironmentRef{Kind: session.EnvKindLocal, ID: "/workspace", Revision: "in-tree-v1"}, session.Limits{}, time.Unix(1, 0)) + call := session.NewToolCall("read-1", "Read", nil) + const canonicalBackgroundCompletion = "[harness note: 1 background subagent(s) finished: subagent-job-123 (end_turn). Collect each result with SubagentStatus before relying on it.]" + messages := []session.Message{ + {Role: session.RoleUser, Text: "operator prompt", UserPromptProvenance: session.UserPromptProvenancePrincipal}, + {Role: session.RoleUser, Text: session.NoProgressNudgeText, UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: session.NoProgressExtractiveNudgeText, UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "[harness note: background subagent(s) finished: worker]", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "[harness note: background command(s) finished: build]", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "[harness note: background subagent(s) still running: worker]", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "[harness note: background command(s) still running: build]", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "[harness note: background subagent(s) finished: worker; background command(s) still running: build]", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: canonicalBackgroundCompletion, UserPromptProvenance: session.UserPromptProvenancePrincipal}, + {Role: session.RoleUser, Text: canonicalBackgroundCompletion, UserPromptProvenance: session.UserPromptProvenanceUnknown}, + {Role: session.RoleUser, Text: session.NoProgressNudgeText, UserPromptProvenance: session.UserPromptProvenancePrincipal}, + {Role: session.RoleUser, Text: session.NoProgressNudgeText}, + {Role: session.RoleUser, Text: session.NoProgressNudgeText, Parts: []session.Content{{Kind: session.MediaImage}}, UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: session.CompactionSummaryMarker + " retained", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "[harness note: background worker paused for an unrecognized reason]", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "scheduled delivery result", UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleUser, Text: "scheduled delivery content", Parts: []session.Content{{Kind: session.MediaImage}}, UserPromptProvenance: session.UserPromptProvenanceHarness}, + {Role: session.RoleAssistant, Text: "calling tool", ToolCalls: []session.ToolCall{call}}, + session.NewToolMessage(session.NewToolResult(call.ID, "tool result")), + {Role: session.RoleAssistant, Text: "final answer"}, + } + if err := sess.SeedHistory(messages); err != nil { + t.Fatalf("SeedHistory: %v", err) + } + if err := base.Save(ctx, sess); err != nil { + t.Fatalf("Save: %v", err) + } + + svc := newTranscriptService(t, base, nil, false, &atomic.Int32{}, nil) + got, err := svc.GetTranscript(ctx, sess.ID) + if err != nil { + t.Fatalf("GetTranscript: %v", err) + } + if !got.Complete { + t.Fatal("successful all-filtered-capable transcript is not complete") + } + wantTexts := []string{ + "operator prompt", canonicalBackgroundCompletion, canonicalBackgroundCompletion, session.NoProgressNudgeText, + session.NoProgressNudgeText, session.NoProgressNudgeText, session.CompactionSummaryMarker + " retained", "[harness note: background worker paused for an unrecognized reason]", + "scheduled delivery result", "scheduled delivery content", "calling tool", "", "final answer", + } + if len(got.Messages) != len(wantTexts) { + t.Fatalf("display messages = %d, want %d: %+v", len(got.Messages), len(wantTexts), got.Messages) + } + for i, want := range wantTexts { + if got.Messages[i].Text != want { + t.Fatalf("display message %d text = %q, want %q", i, got.Messages[i].Text, want) + } + } + if len(got.Messages[5].Parts) != 1 || len(got.Messages[9].Parts) != 1 { + t.Fatalf("media or scheduled delivery content was not retained: %+v", got.Messages) + } + if len(got.Messages[10].ToolCalls) != 1 || got.Messages[11].ToolResult == nil || got.Messages[11].ToolResult.CallID != call.ID { + t.Fatalf("tool pair was not retained in order: %+v", got.Messages[10:12]) + } + + stored, err := base.Load(ctx, sess.ID) + if err != nil { + t.Fatalf("Load after transcript: %v", err) + } + if len(stored.Conversation.Messages) != len(messages) { + t.Fatalf("stored history length = %d, want %d", len(stored.Conversation.Messages), len(messages)) + } + if !reflect.DeepEqual(stored.Conversation.Messages, messages) { + t.Fatalf("stored model history changed:\n got: %+v\nwant: %+v", stored.Conversation.Messages, messages) + } +} + +func TestTranscriptAllHarnessControlContinuationsIsCompleteAndEmpty(t *testing.T) { + ctx := context.Background() + base := memstore.New() + sess := session.New("only-harness-control", session.ModeDefault, session.EnvironmentRef{Kind: session.EnvKindLocal, ID: "/workspace", Revision: "in-tree-v1"}, session.Limits{}, time.Unix(1, 0)) + if err := sess.SeedHistory([]session.Message{{Role: session.RoleUser, Text: session.NoProgressNudgeText, UserPromptProvenance: session.UserPromptProvenanceHarness}}); err != nil { + t.Fatalf("SeedHistory: %v", err) + } + if err := base.Save(ctx, sess); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := newTranscriptService(t, base, nil, false, &atomic.Int32{}, nil).GetTranscript(ctx, sess.ID) + if err != nil { + t.Fatalf("GetTranscript: %v", err) + } + if !got.Complete || len(got.Messages) != 0 { + t.Fatalf("all-filtered transcript = complete %v, %d messages; want true, 0", got.Complete, len(got.Messages)) + } +} + func TestTranscriptBackendErrorsAreRedacted(t *testing.T) { const raw = "snapshot decode failed at /secret/backend/session.jsonl" diag := &recordingDiagnostics{} diff --git a/sdk/typescript/src/gen/mecatl/v1/harness_pb.ts b/sdk/typescript/src/gen/mecatl/v1/harness_pb.ts index b2ca654a2f..1fb5c120a6 100644 --- a/sdk/typescript/src/gen/mecatl/v1/harness_pb.ts +++ b/sdk/typescript/src/gen/mecatl/v1/harness_pb.ts @@ -1051,7 +1051,8 @@ export const ActivityReplayStatusSchema: GenMessage = /*@_ /** * GetSessionTranscriptResponse is one coherent SessionStore snapshot projection. - * messages are the loaded aggregate's current Conversation.Messages in order. + * messages are the ordered human-display projection; internal harness control + * continuations are omitted, while a successful empty projection is complete. * * @generated from message mecatl.v1.GetSessionTranscriptResponse */ diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index 23e40861af..5aa66ebb91 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -22,12 +22,12 @@ bindings and dims features that the connected server does not provide. |physical `esc` twice within 500ms|Clear an idle draft in terminals with enhanced key events. See [Clear a draft with Escape](#clear-a-draft-with-escape).| |`esc`|Clear an active selection first. While work is running, cancel directly and preserve the draft, queued follow-ups, and steer. While idle with a paused queue, clear that queue but preserve the draft.| |`ctrl+t`|Open `/toolcalls` for the current session; approval requests use it for details.| -|`f9`|Reveal conversation details: reasoning, turn stats, changed files, permanent error details, and benign guardrail notices. Tool results stay in `/toolcalls`.| +|`f9`|Reveal conversation details: reasoning, turn stats, changed files, permanent error details, benign guardrail notices, and individual settled tool-call summaries. Use `ctrl+t` for tool arguments and results.| |`shift+tab`|Cycle the current session permission mode: **default → plan → accept-edits → default**. In an MCP prompt argument form, it instead moves to the previous required field.| |`ctrl+g`|Select all prompt text.| |`ctrl+a` / `ctrl+e`|Move to the start / end of the current prompt line.| |`ctrl+p`|Move to the previous prompt line.| -|`ctrl+y`|Copy the active prompt or conversation selection; no selection is a no-op.| +|`ctrl+y`|Copy the active prompt or visible conversation selection; no selection is a no-op.| |`f6` / `f7` / `f8`|Open Agents / Effort / MCP Prompts.| |Agents overlay controls|Select and inspect delegated work. See [Navigate the Agents overlay](#navigate-the-agents-overlay).| |Models picker controls|The mouse wheel scrolls the visible model list without moving its cursor. A primary click on a visible model row moves the cursor without switching models; press `enter` to activate the cursor row.| @@ -200,10 +200,11 @@ Use `shift+arrow` for keyboard selection. On the alternate screen, drag over prompt text for mouse selection. Starting a prompt selection clears a conversation selection and vice versa. Releasing the mouse does not copy the prompt; use `ctrl+y` or right-click to copy the active selection. Copying writes -the text to the system clipboard and, on X11 and Wayland, the primary selection. -Install `wl-clipboard` or `xclip` when your terminal does not support the -primary-selection mirror through OSC 52. With `--no-mouse`, the terminal retains -native mouse selection and keyboard selection remains available. +only visible conversation text to the system clipboard and, on X11 and Wayland, +the primary selection. If a selected tool summary changes, `mecatui` can clear +the selection. Install `wl-clipboard` or `xclip` when your terminal does not +support the primary-selection mirror through OSC 52. With `--no-mouse`, the +terminal retains native mouse selection and keyboard selection remains available. Client-owned actions take precedence over textarea chords. For example, `ctrl+t` opens tool-call inspection and `ctrl+v` handles paste. `ctrl+g` selects all only diff --git a/user-docs/mecatui/sessions.md b/user-docs/mecatui/sessions.md index d4b2519c21..7ab8d40052 100644 --- a/user-docs/mecatui/sessions.md +++ b/user-docs/mecatui/sessions.md @@ -39,6 +39,10 @@ until a completed turn establishes one. See [Session continuity](/features/sessions/session-continuity.md) for the storage and recovery behavior behind resume. +The reloaded transcript omits background-work notifications and automatic +continuation prompts that the server identifies as harness-authored. These +messages remain in the stored model history. + ### Recover a pending approval Use an exact session ID to reopen an owned main chat that stopped while waiting diff --git a/user-docs/mecatui/themes.md b/user-docs/mecatui/themes.md index c837ea62fc..8abe7a29d5 100644 --- a/user-docs/mecatui/themes.md +++ b/user-docs/mecatui/themes.md @@ -35,6 +35,11 @@ default **aztec** theme. Passing `--theme` or `MECATUI_THEME`, including `--theme aztec`, skips this detection. Pin a theme to disable automatic selection. +Aztec uses rose for model-response markers, gold for user markers, and lavender +for ordinary harness notices. Solar uses saturated accents tuned for light +backgrounds. Message text and success, warning, and error indicators have +separate colors; reasoning is subdued and italic. + ## Add a partial palette Theme files are JSON. A palette extends the Aztec base, so it only needs the @@ -62,6 +67,11 @@ than once, the later location wins: Then choose it with `--theme midnight` or `MECATUI_THEME=midnight`. Themes are discovered at startup, so restart `mecatui` after adding or changing a file. +Custom palettes derive user and model-marker colors from `user` and `assistant`, +and ordinary harness-marker colors from `info`. They use `text` for plain message +text and `textMuted` for harness notices, reasoning, and statistics. Markdown +continues to use the `md*` and syntax-color slots. + ## Next steps - [Customize keybindings](./keybindings.md#remap-actions). diff --git a/user-docs/mecatui/using-the-tui.md b/user-docs/mecatui/using-the-tui.md index b83ed01c07..53de438b88 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -34,8 +34,14 @@ longer than 256 bytes are omitted; child execution and the parent session are unaffected. Press `f9` to reveal conversation details, including reasoning summaries, -per-turn usage, permanent error details, and changed files. Tool results stay in -`/toolcalls`. Both shortcuts can be [remapped](./keybindings.md#remap-actions). +per-turn usage, permanent error details, changed files, and individual settled +tool-call summaries in source order. Use `ctrl+t` to inspect a call's arguments +and results. Both shortcuts can be [remapped](./keybindings.md#remap-actions). + +In the default conversation view, adjacent completed tool, Subagent, and Team +calls share one summary, including failures. Calls still in progress remain as +individual cards after that summary. The grouping follows their order in the +transcript, rather than the order in which work started or finished. `f9` also temporarily reveals retained benign guardrail notices in live and replayed conversations. These are completed, acceptable checks that allowed an diff --git a/user-docs/reference/grpc-schema.md b/user-docs/reference/grpc-schema.md index 0783825406..abe0fc0545 100644 --- a/user-docs/reference/grpc-schema.md +++ b/user-docs/reference/grpc-schema.md @@ -1328,7 +1328,8 @@ conversation is requested. #### `mecatl.v1.GetSessionTranscriptResponse` GetSessionTranscriptResponse is one coherent SessionStore snapshot projection. -messages are the loaded aggregate's current Conversation.Messages in order. +messages are the ordered human-display projection; internal harness control +continuations are omitted, while a successful empty projection is complete. | Field | Type | Label | Oneof | Description | |---|---|---|---|---| From 08b104564b4710c70123d83bbe512124fe181d30 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Thu, 15 Oct 2026 16:24:47 -0700 Subject: [PATCH 2/2] docs(mecatui): trim transcript implementation details Co-authored-by: Mecatl --- user-docs/mecatui/sessions.md | 4 ---- user-docs/mecatui/themes.md | 10 ---------- 2 files changed, 14 deletions(-) diff --git a/user-docs/mecatui/sessions.md b/user-docs/mecatui/sessions.md index 7ab8d40052..d4b2519c21 100644 --- a/user-docs/mecatui/sessions.md +++ b/user-docs/mecatui/sessions.md @@ -39,10 +39,6 @@ until a completed turn establishes one. See [Session continuity](/features/sessions/session-continuity.md) for the storage and recovery behavior behind resume. -The reloaded transcript omits background-work notifications and automatic -continuation prompts that the server identifies as harness-authored. These -messages remain in the stored model history. - ### Recover a pending approval Use an exact session ID to reopen an owned main chat that stopped while waiting diff --git a/user-docs/mecatui/themes.md b/user-docs/mecatui/themes.md index 8abe7a29d5..c837ea62fc 100644 --- a/user-docs/mecatui/themes.md +++ b/user-docs/mecatui/themes.md @@ -35,11 +35,6 @@ default **aztec** theme. Passing `--theme` or `MECATUI_THEME`, including `--theme aztec`, skips this detection. Pin a theme to disable automatic selection. -Aztec uses rose for model-response markers, gold for user markers, and lavender -for ordinary harness notices. Solar uses saturated accents tuned for light -backgrounds. Message text and success, warning, and error indicators have -separate colors; reasoning is subdued and italic. - ## Add a partial palette Theme files are JSON. A palette extends the Aztec base, so it only needs the @@ -67,11 +62,6 @@ than once, the later location wins: Then choose it with `--theme midnight` or `MECATUI_THEME=midnight`. Themes are discovered at startup, so restart `mecatui` after adding or changing a file. -Custom palettes derive user and model-marker colors from `user` and `assistant`, -and ordinary harness-marker colors from `info`. They use `text` for plain message -text and `textMuted` for harness notices, reasoning, and statistics. Markdown -continues to use the `md*` and syntax-color slots. - ## Next steps - [Customize keybindings](./keybindings.md#remap-actions).