From 74914722f01af5272b4ff461a78b3b1dfcde51fd Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 03:33:48 -0700 Subject: [PATCH 01/30] feat(mecatui): add tool-call inspector core Co-authored-by: Mecatl --- cmd/mecatui/ui/builtins.go | 7 +- cmd/mecatui/ui/builtins_test.go | 4 +- .../ui/palette_bounded_list_scenario_test.go | 6 +- cmd/mecatui/ui/palette_test.go | 44 ++-- cmd/mecatui/ui/sdk_high_level_parity_test.go | 1 + cmd/mecatui/ui/toolcalls.go | 205 ++++++++++++++++++ .../ui/toolcalls_inspector_scenario_test.go | 159 ++++++++++++++ cmd/mecatui/ui/update.go | 2 + 8 files changed, 400 insertions(+), 28 deletions(-) create mode 100644 cmd/mecatui/ui/toolcalls.go create mode 100644 cmd/mecatui/ui/toolcalls_inspector_scenario_test.go diff --git a/cmd/mecatui/ui/builtins.go b/cmd/mecatui/ui/builtins.go index 82e78c8a50..2ae71749d2 100644 --- a/cmd/mecatui/ui/builtins.go +++ b/cmd/mecatui/ui/builtins.go @@ -138,6 +138,11 @@ func builtinCommands(caps client.Capabilities, w wiredCollaborators) []builtin { desc: "retry the last eligible failed model step without resending its prompt", run: Model.runFailedStepRetry, }, + { + name: "toolcalls", + desc: "browse tool calls in this session", + run: Model.runToolcalls, + }, { name: "diagnostics", desc: "send a concise client and server diagnostics report", @@ -643,7 +648,7 @@ type builtinName struct { // before a capability-gated builtin can be dispatched. var builtinNameRegistry = []builtinName{ {name: "clear", acceptsArgs: false}, {name: "help", acceptsArgs: false}, {name: "quit", acceptsArgs: false}, {name: "title", acceptsArgs: true}, {name: "session", acceptsArgs: false}, - {name: "retry", acceptsArgs: false}, {name: "diagnostics", acceptsArgs: false}, {name: "compact", acceptsArgs: false}, + {name: "retry", acceptsArgs: false}, {name: "toolcalls", acceptsArgs: false}, {name: "diagnostics", acceptsArgs: false}, {name: "compact", acceptsArgs: false}, {name: "mcp", acceptsArgs: false}, {name: "agents", acceptsArgs: false}, {name: "team", acceptsArgs: false}, {name: "skills", acceptsArgs: false}, {name: "soul", acceptsArgs: false}, {name: "memory", acceptsArgs: false}, {name: "models", acceptsArgs: false}, {name: "effort", acceptsArgs: false}, {name: "worktrees", acceptsArgs: false}, diff --git a/cmd/mecatui/ui/builtins_test.go b/cmd/mecatui/ui/builtins_test.go index d077d697e2..eda8685113 100644 --- a/cmd/mecatui/ui/builtins_test.go +++ b/cmd/mecatui/ui/builtins_test.go @@ -137,7 +137,7 @@ func TestBuiltinCommandsCapsFilter(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := builtinNames(tc.caps, tc.w) - want := append([]string{"clear", "help", "quit", "title", "session", "retry", "diagnostics"}, tc.want[2:]...) + want := append([]string{"clear", "help", "quit", "title", "session", "retry", "toolcalls", "diagnostics"}, tc.want[2:]...) if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("builtinCommands order/filter = %v, want %v", got, want) } @@ -991,7 +991,7 @@ func TestDispatchBareBuiltinUnicodeWhitespaceThroughTextarea(t *testing.T) { // from the builtinCommands table AND that an unknown name is false. func TestIsKnownBuiltinName(t *testing.T) { known := []string{ - "clear", "help", "quit", "title", "session", "retry", "diagnostics", "compact", "mcp", "agents", "team", "skills", "soul", "memory", + "clear", "help", "quit", "title", "session", "retry", "toolcalls", "diagnostics", "compact", "mcp", "agents", "team", "skills", "soul", "memory", "models", "effort", "worktrees", "schedule", "sessions", "learning", "learning-sensitivity", "posture", "debug-ask", } diff --git a/cmd/mecatui/ui/palette_bounded_list_scenario_test.go b/cmd/mecatui/ui/palette_bounded_list_scenario_test.go index e94dbe0ad6..5a0dd44a75 100644 --- a/cmd/mecatui/ui/palette_bounded_list_scenario_test.go +++ b/cmd/mecatui/ui/palette_bounded_list_scenario_test.go @@ -327,7 +327,7 @@ func TestMecatuiSlashPaletteBoundedList_Scenario1_PreservesInteractionOwnership( paged = typeRune(t, paged, '/') paged = applyAll(paged, tea.WindowSizeMsg{Width: 24, Height: 21}) _ = paged.View() - paged.palette.list.SetCursor(7) // alpha follows the seven built-ins. + paged.palette.list.SetCursor(8) // alpha follows the eight built-ins. paged.keys = applyKeyOverrides(paged.keys, map[string][]string{"ScrollU": {"p"}, "ScrollD": {"n"}}) updated, _ := paged.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) paged = updated.(Model) @@ -343,7 +343,7 @@ func TestMecatuiSlashPaletteBoundedList_Scenario1_PreservesInteractionOwnership( workspace := newPaletteModel(t, sampleCommands()) workspace = typeRune(t, workspace, '/') - workspace.palette.list.SetCursor(7) + workspace.palette.list.SetCursor(8) completed, _ := workspace.Update(tea.KeyPressMsg{Code: tea.KeyTab}) workspace = completed.(Model) if workspace.prompt.Value() != "/fix " || workspace.palette.open { @@ -382,7 +382,7 @@ func TestMecatuiSlashPaletteBoundedList_Scenario1_PreservesInteractionOwnership( runningPalette = typeRune(t, runningPalette, '/') runningPalette.phase = phaseRunning _ = runningPalette.View() - runningPalette.palette.list.SetCursor(7) // /fix workspace command + runningPalette.palette.list.SetCursor(8) // /fix workspace command updated, _ := runningPalette.Update(keyMsg) runningPalette = updated.(Model) if runningPalette.prompt.Value() != "/fix " || runningPalette.palette.open { diff --git a/cmd/mecatui/ui/palette_test.go b/cmd/mecatui/ui/palette_test.go index e18e5e19dc..7755082788 100644 --- a/cmd/mecatui/ui/palette_test.go +++ b/cmd/mecatui/ui/palette_test.go @@ -106,9 +106,9 @@ func TestPaletteOpensOnSlash(t *testing.T) { if !m.palette.open { t.Fatalf("palette did not open on '/'") } - // Merged set: 7 built-ins (clear, help, quit, title, session, retry, diagnostics) + 3 workspace rows. - if len(m.palette.filtered) != 10 { - t.Fatalf("filtered = %d, want 10 (7 built-ins + 3 workspace)", len(m.palette.filtered)) + // Merged set: 8 built-ins (clear, help, quit, title, session, retry, toolcalls, diagnostics) + 3 workspace rows. + if len(m.palette.filtered) != 11 { + t.Fatalf("filtered = %d, want 11 (8 built-ins + 3 workspace)", len(m.palette.filtered)) } view := m.View().Content // Built-ins lead and render first. @@ -116,10 +116,10 @@ func TestPaletteOpensOnSlash(t *testing.T) { !strings.Contains(view, "/quit") || strings.Contains(view, "/exit") { t.Fatalf("palette built-in visibility is wrong (want /quit but no /exit):\n%s", view) } - if got := m.palette.filtered[6]; got.Name != "diagnostics" || got.Description != "send a concise client and server diagnostics report" { + if got := m.palette.filtered[7]; got.Name != "diagnostics" || got.Description != "send a concise client and server diagnostics report" { t.Fatalf("diagnostics command changed: %+v", got) } - for range 7 { + for range 8 { m.paletteMoveDown() } view = m.View().Content @@ -147,9 +147,9 @@ func TestPaletteFetchesOnce(t *testing.T) { if fc.gotWS != "sess-test-0001" { t.Fatalf("fetch session = %q, want sess-test-0001", fc.gotWS) } - // Merged: 7 built-ins + 3 fetched workspace rows = 10. - if !m.palette.open || len(m.palette.filtered) != 10 { - t.Fatalf("palette not populated from fetch: open=%v filtered=%d (want 10)", m.palette.open, len(m.palette.filtered)) + // Merged: 8 built-ins + 3 fetched workspace rows = 11. + if !m.palette.open || len(m.palette.filtered) != 11 { + t.Fatalf("palette not populated from fetch: open=%v filtered=%d (want 11)", m.palette.open, len(m.palette.filtered)) } // A second keystroke must NOT re-fetch (the latch holds). @@ -241,21 +241,21 @@ func TestPalettePrefixFilters(t *testing.T) { // TestPaletteNavigateAndComplete verifies ↓ moves the selection and enter // completes a workspace command into the input as "/ ", closing the -// palette. The merged order is [clear, help, quit, title, session, retry, diagnostics, fix, review, refactor], so eight ↓ -// land on "review" (index 8, a workspace row → text-completed, not run). +// palette. The merged order is [clear, help, quit, title, session, retry, toolcalls, diagnostics, fix, review, refactor], so nine ↓ +// land on "review" (index 9, a workspace row → text-completed, not run). func TestPaletteNavigateAndComplete(t *testing.T) { m := newPaletteModel(t, sampleCommands()) m = typeRune(t, m, '/') // open with built-ins then workspace commands - for i := 0; i < 8; i++ { + for i := 0; i < 9; i++ { mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) m = mm.(Model) } - if m.palette.list.Cursor() != 8 { - t.Fatalf("cursor = %d, want 8 after 8×↓", m.palette.list.Cursor()) + if m.palette.list.Cursor() != 9 { + t.Fatalf("cursor = %d, want 9 after 9×↓", m.palette.list.Cursor()) } - if m.palette.filtered[8].Name != "review" || m.palette.filtered[8].Builtin { - t.Fatalf("row 8 = %+v, want workspace 'review'", m.palette.filtered[8]) + if m.palette.filtered[9].Name != "review" || m.palette.filtered[9].Builtin { + t.Fatalf("row 9 = %+v, want workspace 'review'", m.palette.filtered[9]) } // enter completes the selected WORKSPACE command (text-completion). @@ -271,11 +271,11 @@ func TestPaletteNavigateAndComplete(t *testing.T) { // TestPaletteCompleteWorkspaceWithTab verifies tab text-completes a WORKSPACE // row (not a built-in). With built-ins leading, the first workspace row "fix" is -// at index 7. +// at index 8. func TestPaletteCompleteWorkspaceWithTab(t *testing.T) { m := newPaletteModel(t, sampleCommands()) m = typeRune(t, m, '/') - for i := 0; i < 7; i++ { + for i := 0; i < 8; i++ { mm, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) m = mm.(Model) } @@ -358,13 +358,13 @@ func TestPaletteEscDismisses(t *testing.T) { func TestPaletteUnknownPrefixShowsNote(t *testing.T) { m := newPaletteModel(t, &fakeCommander{cmds: nil}) - // Bare "/" opens: clear, help, quit, title, session, retry, and diagnostics are always present. + // Bare "/" opens: clear, help, quit, title, session, retry, toolcalls, and diagnostics are always present. m = typeRune(t, m, '/') if !m.palette.open { t.Fatalf("bare '/' should open the palette (built-ins always exist)") } - if len(m.palette.filtered) != 7 { - t.Fatalf("bare '/' filtered = %d, want 7 built-ins", len(m.palette.filtered)) + if len(m.palette.filtered) != 8 { + t.Fatalf("bare '/' filtered = %d, want 8 built-ins", len(m.palette.filtered)) } // Typing a prefix that matches no command closes the dropdown and the input @@ -395,8 +395,8 @@ func TestPaletteNilCommanderShowsBuiltins(t *testing.T) { if !m.palette.open { t.Fatalf("palette should open with built-ins even with nil Commander") } - if len(m.palette.filtered) != 7 { - t.Fatalf("filtered = %d, want 7 built-ins (clear, help, quit, title, session, retry, diagnostics)", len(m.palette.filtered)) + if len(m.palette.filtered) != 8 { + t.Fatalf("filtered = %d, want 8 built-ins (clear, help, quit, title, session, retry, toolcalls, diagnostics)", len(m.palette.filtered)) } // With no Commander, the fetch latch is never even consulted; assert the rows // are the built-ins. diff --git a/cmd/mecatui/ui/sdk_high_level_parity_test.go b/cmd/mecatui/ui/sdk_high_level_parity_test.go index eca43fcc54..b9c061c48c 100644 --- a/cmd/mecatui/ui/sdk_high_level_parity_test.go +++ b/cmd/mecatui/ui/sdk_high_level_parity_test.go @@ -75,6 +75,7 @@ var sdkBoundaryBuiltins = map[string]sdkBoundaryRow{ "guardrails": {category: sdkBacked, sdkOperation: "Session.guardrailCoverage"}, "help": {category: applicationOnly, rationale: "The help overlay describes local keys and panels."}, "quit": {category: applicationOnly, rationale: "Exiting the terminal application has no server outcome."}, + "toolcalls": {category: applicationOnly, rationale: "The TUI locally projects only the current session's already-rendered tool calls."}, "diagnostics": {category: applicationOnly, rationale: "The TUI assembles a client-state report and its own diagnostic prompt."}, "connect": {category: operatorOnly, rationale: "Saved targets, login, and credential handling belong to the operator client."}, "learning": {category: operatorOnly, rationale: "This changes the TUI's local learning-mode setting."}, diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go new file mode 100644 index 0000000000..0c3bd4d345 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls.go @@ -0,0 +1,205 @@ +package ui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +// runToolcalls opens the current session's local tool-call projection. It has no +// server dependency, so it remains available while a run streams. +func (m Model) runToolcalls() (tea.Model, tea.Cmd) { + if m.phase != phaseIdle && m.phase != phaseRunning { + return m, nil + } + m.prompt.Blur() + s := &toolcallsState{open: true, deps: (&m).surfaceDeps()} + s.setEntries(m.toolcallEntries(), true) + m.modal = s + return m, nil +} + +type toolcallEntry struct { + blockID scrollback.BlockID + name string + intent string + resolved bool + failed bool +} + +type toolcallsState struct { + open bool + deps surfaceDeps + entries []toolcallEntry + selected int + detail bool + list *bounded.List + compact bool +} + +func (*toolcallsState) modalMaxOuterWidth() int { return 128 } +func (s *toolcallsState) modalFrame() bool { return !s.compact } +func (*toolcallsState) Close() {} +func (*toolcallsState) HandleMsg(tea.Msg) (tea.Cmd, bool, bool) { return nil, false, false } + +func (m Model) toolcallEntries() []toolcallEntry { + entries := make([]toolcallEntry, 0) + for i := 0; i < m.conv.scrollback.Len(); i++ { + snapshot := m.conv.scrollback.SnapshotAt(i) + card, ok := snapshot.Payload.(scrollback.ToolCardSnapshot) + if !ok { + continue + } + entries = append(entries, toolcallEntry{ + blockID: snapshot.ID, name: card.Call.Name, intent: card.Call.Arguments, + resolved: card.Resolved || card.Finished, + failed: card.Result.IsError || card.Failed, + }) + } + return entries +} + +// setEntries preserves an earlier reader's block identity while new calls arrive. +// A reader already following the newest row advances to the new newest row. +func (s *toolcallsState) setEntries(entries []toolcallEntry, opening bool) { + var selected scrollback.BlockID + following := opening || (len(s.entries) > 0 && s.selected == len(s.entries)-1) + if !following && s.selected >= 0 && s.selected < len(s.entries) { + selected = s.entries[s.selected].blockID + } + s.entries = entries + if len(entries) == 0 { + s.selected = 0 + return + } + if following { + s.selected = len(entries) - 1 + return + } + for i := range entries { + if entries[i].blockID == selected { + s.selected = i + return + } + } + s.selected = min(s.selected, len(entries)-1) +} + +func (m *Model) syncToolcalls() { + if s, ok := m.modal.(*toolcallsState); ok { + s.setEntries(m.toolcallEntries(), false) + } +} + +func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { + s.compact = false + if !s.open || width <= 0 || height <= 0 { + s.list = nil + return "", nil + } + th := s.deps.theme + line := func(style lipgloss.Style, text string) string { + return ansi.Cut(style.Render(terminaltext.Sanitize(text)), 0, width) + "\x1b[0m" + } + if height < 5 || width < 12 { + s.compact, s.list = true, nil + return line(th.Style("muted"), "too small · "+s.deps.marks.closeOnly+" close"), nil + } + title := line(th.Style("askTitle"), "Tool calls") + if s.detail { + entry := s.entries[s.selected] + return strings.Join([]string{title, "", line(th.Style("muted"), "detail placeholder for "+entry.name), "", line(th.Style("muted"), s.deps.marks.closeOnly+" back")}, "\n"), nil + } + footer := line(th.Style("muted"), s.deps.marks.scroll+" navigate · enter detail · "+s.deps.marks.closeOnly+" close") + if len(s.entries) == 0 { + return strings.Join([]string{title, "", line(th.Style("muted"), "no tool calls in this session."), "", footer}, "\n"), nil + } + bodyHeight := height - 4 + if bodyHeight < 1 { + s.compact, s.list = true, nil + return line(th.Style("muted"), "too small · "+s.deps.marks.closeOnly+" close"), nil + } + if s.list == nil { + s.list = new(bounded.List) + } + items := make([]bounded.ListItem, len(s.entries)) + for i, entry := range s.entries { + status := "running" + if entry.resolved { + status = "done" + } + if entry.failed { + status = "failed" + } + intent := strings.ReplaceAll(terminaltext.Sanitize(entry.intent), "\n", " ") + items[i] = bounded.ListItem{ID: fmt.Sprintf("%d", entry.blockID), Text: status + " · " + terminaltext.Sanitize(entry.name) + " · " + intent} + } + s.list.SetGeometry(width, bodyHeight, 0, bounded.Clip) + s.list.SetItems(items) + s.list.SetCursor(s.selected) + view := s.list.View() + body := []string{title, ""} + for _, row := range view.Rows { + prefix := " " + if row.Selected { + prefix = "> " + } + body = append(body, ansi.Cut(prefix+row.Text, 0, width)+"\x1b[0m") + } + body = append(body, "", footer) + return strings.Join(body, "\n"), nil +} + +func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { + if key.Matches(msg, s.deps.keys.Close) { + if s.detail { + s.detail = false + return nil, true, false + } + return nil, true, true + } + if s.compact || len(s.entries) == 0 { + return nil, true, false + } + move := bounded.LineDown + switch { + case key.Matches(msg, s.deps.keys.Down): + case key.Matches(msg, s.deps.keys.Up): + move = bounded.LineUp + case key.Matches(msg, s.deps.keys.ScrollD): + move = bounded.PageDown + case key.Matches(msg, s.deps.keys.ScrollU): + move = bounded.PageUp + case key.Matches(msg, s.deps.keys.ScrollBottom): + move = bounded.End + case key.Matches(msg, s.deps.keys.ScrollTop): + move = bounded.Top + case key.Matches(msg, s.deps.keys.Choose): + s.detail = true + return nil, true, false + default: + return nil, true, false + } + if s.list != nil { + s.list.Move(move) + s.selected = s.list.Cursor() + } + return nil, true, false +} + +func (s *toolcallsState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { + if s.list == nil { + return nil, true + } + s.list.Move(map[bool]bounded.Move{true: bounded.LineUp, false: bounded.LineDown}[msg.Mouse().Button == tea.MouseWheelUp]) + s.selected = s.list.Cursor() + return nil, true +} diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go new file mode 100644 index 0000000000..7235bd5ef6 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -0,0 +1,159 @@ +package ui + +import ( + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// These acceptance pins intentionally start at the UI reducer seam: the tool-call +// browser is a local, current-session projection and has no server dependency. +func TestMecatuiToolcallsInspector_Scenario1_OpenEmptyRunningAndResume(t *testing.T) { + m := newToolcallsInspectorModel(t) + if _, ok := builtinByName(m.caps, m.wiredCollaborators(), "toolcalls"); !ok { + t.Fatal("/toolcalls is absent from the local slash palette") + } + m = openToolcallsForTest(t, m) + if !toolcallsForTest(t, m).open { + t.Fatal("/toolcalls did not open for an empty conversation") + } + m.closeModal() + m.phase = phaseRunning + m = addToolcallsForTest(t, m, 2) // reconstructed scrollback is the resume projection + m = openToolcallsForTest(t, m) + if got, want := len(toolcallsForTest(t, m).entries), 2; got != want { + t.Fatalf("resumed current-session calls = %d, want %d", got, want) + } +} + +func TestMecatuiToolcallsInspector_Scenario1_ChronologicalNavigation(t *testing.T) { + m := newToolcallsInspectorModel(t) + m = addToolcallsForTest(t, m, 3) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if got, want := s.selected, 2; got != want { + t.Fatalf("newest call selection = %d, want %d", got, want) + } + _, _ = s.Render(80, 20) + _, handled, closed := s.HandleKey(tea.KeyPressMsg{Code: tea.KeyUp}) + if !handled || closed || s.selected != 1 { + t.Fatalf("up navigation = selected %d handled=%v closed=%v", s.selected, handled, closed) + } + _, _, _ = s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !s.detail { + t.Fatal("enter did not open the detail placeholder") + } + _, _, closed = s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + if s.detail || closed || s.selected != 1 { + t.Fatal("escape did not return from detail to its selected list call") + } +} + +func TestMecatuiToolcallsInspector_Scenario1_StableLiveSelection(t *testing.T) { + m := newToolcallsInspectorModel(t) + m = addToolcallsForTest(t, m, 2) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + s.selected = 0 + m = addToolcallsForTest(t, m, 1) + if got, want := s.selected, 0; got != want { + t.Fatalf("earlier selected call moved to %d, want %d", got, want) + } + m.conv.resolveTool("call-0", "done", false) + m.syncToolcalls() + if got, want := s.selected, 0; got != want || !s.entries[0].resolved { + t.Fatalf("result changed selected row or did not update it: selected=%d entry=%#v", got, s.entries[0]) + } + s.selected = len(s.entries) - 1 + m = addToolcallsForTest(t, m, 1) + if got, want := s.selected, len(s.entries)-1; got != want { + t.Fatalf("newest-follow selection = %d, want %d", got, want) + } +} + +func TestMecatuiToolcallsInspector_Scenario3_FullRegionAndCompactFallback(t *testing.T) { + m := newToolcallsInspectorModel(t) + m = addToolcallsForTest(t, m, 1) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if got, _ := s.Render(0, 1); got != "" { + t.Fatalf("nonpositive geometry rendered %q", got) + } + compact, _ := s.Render(10, 3) + if !s.compact || !strings.Contains(stripANSIstr(compact), "too small") { + t.Fatalf("compact fallback = %q, compact=%v", compact, s.compact) + } + normal, _ := s.Render(80, 20) + if s.compact || !strings.Contains(stripANSIstr(normal), "Tool calls") { + t.Fatalf("normal geometry did not restore the list: %q", normal) + } +} + +func TestMecatuiToolcallsInspector_Scenario3_InputOwnershipAndSafety(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.conv.addTool("unsafe", "Read\x1b]8;;https://unsafe.example\a", "{\"path\":\"x\x1b[31m\"}") + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if !s.open || m.prompt.Focused() { + t.Fatal("open inspector did not own keyboard input") + } + body, _ := s.Render(80, 20) + if strings.Contains(body, "\x1b]") || strings.Contains(body, "\x1b[31m") { + t.Fatalf("untrusted terminal control reached inspector rendering: %q", body) + } + m.vp.SetContent(strings.Repeat("conversation\n", 100)) + before := m.vp.YOffset() + updated, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = updated.(Model) + if got := m.vp.YOffset(); got != before { + t.Fatalf("inspector wheel changed hidden conversation offset from %d to %d", before, got) + } +} + +func TestMecatuiToolcallsInspector_Scenario3_SessionReplacementClosesInspector(t *testing.T) { + m := newToolcallsInspectorModel(t) + m = openToolcallsForTest(t, m) + m = m.resetSession() + if toolcallsForTest(t, m) != nil { + t.Fatal("session replacement left the inspector open") + } +} + +func newToolcallsInspectorModel(t *testing.T) Model { + t.Helper() + m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context()}) + m.phase = phaseIdle + m.width, m.height = 100, 30 + m.relayout() + return m +} + +func openToolcallsForTest(t *testing.T, m Model) Model { + t.Helper() + opened, _ := m.runToolcalls() + return opened.(Model) +} + +func addToolcallsForTest(t *testing.T, m Model, count int) Model { + t.Helper() + start := m.conv.scrollback.Len() + for i := 0; i < count; i++ { + m.conv.addTool(fmt.Sprintf("call-%d", start+i), "Read", fmt.Sprintf(`{"path":"file-%d.go"}`, start+i)) + } + m.syncToolcalls() + return m +} + +func toolcallsForTest(t *testing.T, m Model) *toolcallsState { + t.Helper() + if m.modal == nil { + return nil + } + s, ok := m.modal.(*toolcallsState) + if !ok { + t.Fatalf("modal = %T, want *toolcallsState", m.modal) + } + return s +} diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 457267a1e4..2b6bb11e58 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -1338,6 +1338,7 @@ func (m Model) updateStreamEvent(msg tea.Msg) (tea.Model, tea.Cmd) { if p, ok := mutatedPath(msg.Name, msg.Args); ok { m.conv.recordFileChange(p) } + m.syncToolcalls() return m.afterEvent() case client.ToolResultMsg: resolved := false @@ -1351,6 +1352,7 @@ func (m Model) updateStreamEvent(msg tea.Msg) (tea.Model, tea.Cmd) { } m.activeTool = m.conv.latestPendingToolName() m.toolProgress = "" + m.syncToolcalls() return m.afterEvent() case client.ToolProgressMsg: // Transient advisory line from a long-running tool: show it beside the From b8ee76b95bf267cd59de49c39d5cf9c0dc5f44b4 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 03:36:35 -0700 Subject: [PATCH 02/30] fix(mecatui): include delegated parents and fill inspector region Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 30 +++++--- .../ui/toolcalls_inspector_scenario_test.go | 74 ++++++++++++++++++- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 0c3bd4d345..7e2b30c7de 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -45,8 +45,7 @@ type toolcallsState struct { compact bool } -func (*toolcallsState) modalMaxOuterWidth() int { return 128 } -func (s *toolcallsState) modalFrame() bool { return !s.compact } +func (*toolcallsState) modalPlacement() modalPlacement { return modalPlacementFill } func (*toolcallsState) Close() {} func (*toolcallsState) HandleMsg(tea.Msg) (tea.Cmd, bool, bool) { return nil, false, false } @@ -54,15 +53,28 @@ func (m Model) toolcallEntries() []toolcallEntry { entries := make([]toolcallEntry, 0) for i := 0; i < m.conv.scrollback.Len(); i++ { snapshot := m.conv.scrollback.SnapshotAt(i) - card, ok := snapshot.Payload.(scrollback.ToolCardSnapshot) - if !ok { + var entry toolcallEntry + switch card := snapshot.Payload.(type) { + case scrollback.ToolCardSnapshot: + entry = toolcallEntry{ + name: card.Call.Name, intent: card.Call.Arguments, + resolved: card.Resolved || card.Finished, failed: card.Result.IsError || card.Failed, + } + case scrollback.SubagentCardSnapshot: + entry = toolcallEntry{ + name: card.Call.Name, intent: card.Call.Arguments, + resolved: card.Resolved, failed: card.Result.IsError, + } + case scrollback.TeamCardSnapshot: + entry = toolcallEntry{ + name: card.Call.Name, intent: card.Call.Arguments, + resolved: card.Resolved, failed: card.Result.IsError, + } + default: continue } - entries = append(entries, toolcallEntry{ - blockID: snapshot.ID, name: card.Call.Name, intent: card.Call.Arguments, - resolved: card.Resolved || card.Finished, - failed: card.Result.IsError || card.Failed, - }) + entry.blockID = snapshot.ID + entries = append(entries, entry) } return entries } diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 7235bd5ef6..fdf44957d0 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -6,6 +6,8 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) // These acceptance pins intentionally start at the UI reducer seam: the tool-call @@ -28,6 +30,44 @@ func TestMecatuiToolcallsInspector_Scenario1_OpenEmptyRunningAndResume(t *testin } } +func TestMecatuiToolcallsInspector_Scenario1_TopLevelDelegationsOnly(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.conv.addTool("ordinary", "Read", `{"path":"first"}`) + m.conv.addTool("sub", "Subagent", `{"prompt":"delegate"}`) + if !m.conv.scrollback.Subagents().Start("sub", scrollback.SubagentStart{ChildID: "child", Goal: "work"}) { + t.Fatal("could not specialize Subagent parent call") + } + if !m.conv.scrollback.Subagents().Update("sub", scrollback.SubagentUpdate{Trace: []scrollback.TraceEntry{{Kind: "tool.call", ToolName: "nested-Read"}}}) { + t.Fatal("could not record child tool trace") + } + m.conv.addTool("team", "Team", `{"task":"coordinate"}`) + if !m.conv.scrollback.Teams().Start("team", scrollback.TeamStart{TeamID: "team-1"}) { + t.Fatal("could not specialize Team parent call") + } + if !m.conv.scrollback.Teams().Update("team", scrollback.TeamUpdate{TeamID: "team-1", Lanes: []scrollback.TeamLane{{Name: "member", Trace: []scrollback.TraceEntry{{Kind: "tool.call", ToolName: "nested-Grep"}}}}}) { + t.Fatal("could not record team member tool trace") + } + m.conv.addTool("last", "Grep", `{"pattern":"done"}`) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if len(s.entries) != 4 { + t.Fatalf("top-level entries = %#v; want four parents and no nested child tools", s.entries) + } + for i, name := range []string{"Read", "Subagent", "Team", "Grep"} { + if s.entries[i].name != name { + t.Fatalf("entry %d name = %q, want %q", i, s.entries[i].name, name) + } + } + if !m.conv.scrollback.Tools().Resolve("sub", scrollback.ToolResult{Body: "child complete"}) || + !m.conv.scrollback.Tools().Resolve("team", scrollback.ToolResult{Body: "team complete", IsError: true}) { + t.Fatal("could not resolve specialized parent calls") + } + m.syncToolcalls() + if !s.entries[1].resolved || s.entries[1].failed || !s.entries[2].resolved || !s.entries[2].failed { + t.Fatalf("delegation parent result state did not update in place: %#v", s.entries) + } +} + func TestMecatuiToolcallsInspector_Scenario1_ChronologicalNavigation(t *testing.T) { m := newToolcallsInspectorModel(t) m = addToolcallsForTest(t, m, 3) @@ -78,6 +118,18 @@ func TestMecatuiToolcallsInspector_Scenario3_FullRegionAndCompactFallback(t *tes m = addToolcallsForTest(t, m, 1) m = openToolcallsForTest(t, m) s := toolcallsForTest(t, m) + if placement := s.modalPlacement(); placement != modalPlacementFill { + t.Fatalf("inspector placement = %v, want conversation-region fill", placement) + } + m.width, m.height = 240, 35 + m.relayout() + _ = m.View() + if got, want := m.metrics.contentBounds.x1-m.metrics.contentBounds.x0, m.width; got != want { + t.Fatalf("browser width = %d, want offered %d (not a capped centered card)", got, want) + } + if got, want := m.metrics.contentBounds.y1-m.metrics.contentBounds.y0, m.vp.Height(); got != want { + t.Fatalf("browser height = %d, want offered %d", got, want) + } if got, _ := s.Render(0, 1); got != "" { t.Fatalf("nonpositive geometry rendered %q", got) } @@ -114,10 +166,26 @@ func TestMecatuiToolcallsInspector_Scenario3_InputOwnershipAndSafety(t *testing. func TestMecatuiToolcallsInspector_Scenario3_SessionReplacementClosesInspector(t *testing.T) { m := newToolcallsInspectorModel(t) + m.sessionID = "source" + m.conv.addTool("reused", "Read", `{"path":"source"}`) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + _, _ = s.Render(80, 20) + _, _, _ = s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + m.clearPending = &clearHandoff{sourceID: "source", token: 1} + updated, _ := m.Update(clearSessionReadyMsg{ + oldID: "source", token: 1, + ready: client.SessionReadyMsg{SessionID: "successor"}, + }) + m = updated.(Model) + if m.sessionID != "successor" || toolcallsForTest(t, m) != nil || !m.conv.isEmpty() { + t.Fatalf("clear lifecycle leaked source inspector or scrollback: session=%q modal=%T", m.sessionID, m.modal) + } + m.conv.addTool("reused", "Write", `{"path":"successor"}`) m = openToolcallsForTest(t, m) - m = m.resetSession() - if toolcallsForTest(t, m) != nil { - t.Fatal("session replacement left the inspector open") + fresh := toolcallsForTest(t, m) + if len(fresh.entries) != 1 || fresh.entries[0].name != "Write" || fresh.detail || fresh == s { + t.Fatalf("reused call ID retained prior session state: %#v", fresh) } } From d4be4bbb6174f68f510ca0f8efb03c5c30d604e5 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 03:36:56 -0700 Subject: [PATCH 03/30] chore: mark tool-call inspector implementation in progress Co-authored-by: Mecatl --- docs/acceptance/mecatui-toolcalls-inspector.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/mecatui-toolcalls-inspector.md b/docs/acceptance/mecatui-toolcalls-inspector.md index 88165fac86..2257a3717b 100644 --- a/docs/acceptance/mecatui-toolcalls-inspector.md +++ b/docs/acceptance/mecatui-toolcalls-inspector.md @@ -4,7 +4,7 @@ **Work classification:** Bounded — a new client-local conversation browser changes visible navigation and detail presentation but not durable ownership, protocol, or trust boundaries. **Decision record:** None — the existing TUI surface, typed scrollback, and bounded navigation contracts contain this feature without a new durable architecture decision. **Phase:** issue #1361, first slice: additive tool-call inspection -**Status:** proposed, 2026-10-02. The directing operator resolved the interaction and scope decisions before plan review; approval requires the Plan / Interface PR merge. +**Status:** in-progress, 2026-10-02. The Plan / Interface PR merged at `9edd9b751`; implementation is underway. **Delivery:** Split. The new command, live browsing behavior, and detail contract benefit from human review before implementation. **Expected tasks:** deferred to orchestration **Issue:** [stacklok/mecatl#1361](https://github.com/stacklok/mecatl/issues/1361). From 3de2bab689ce79c57c8a1109cd6de398b6959b38 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 03:53:27 -0700 Subject: [PATCH 04/30] Implement mecatui tool-call detail and usage guidance Co-authored-by: Mecatl --- cmd/mecatui/ui/conversation.go | 8 + cmd/mecatui/ui/internal/scrollback/tool.go | 7 +- cmd/mecatui/ui/render_cache_test.go | 4 + cmd/mecatui/ui/sessions_surface.go | 9 +- cmd/mecatui/ui/toolcalls.go | 145 +++++++++++- .../ui/toolcalls_detail_scenario_test.go | 215 ++++++++++++++++++ cmd/mecatui/ui/update.go | 7 +- docs/tui.md | 8 +- user-docs/mecatui/using-the-tui.md | 11 +- 9 files changed, 385 insertions(+), 29 deletions(-) create mode 100644 cmd/mecatui/ui/toolcalls_detail_scenario_test.go diff --git a/cmd/mecatui/ui/conversation.go b/cmd/mecatui/ui/conversation.go index d84edb6531..d6963bc134 100644 --- a/cmd/mecatui/ui/conversation.go +++ b/cmd/mecatui/ui/conversation.go @@ -390,6 +390,14 @@ func (c *conversation) resolveAvailableTool(callID, body string, isErr bool, blo return c.scrollback.Tools().ResolveAvailable(callID, scrollback.ToolResult{Body: body, IsError: isErr, Artifacts: artifacts(blocks)}) } +func (c *conversation) resolveToolResult(msg client.ToolResultMsg) bool { + result := scrollback.ToolResult{Body: msg.Content, IsError: msg.IsError, StructuredContent: msg.StructuredContent, Artifacts: artifacts(msg.Blocks)} + if msg.Available { + return c.scrollback.Tools().ResolveAvailable(msg.CallID, result) + } + return c.scrollback.Tools().Resolve(msg.CallID, result) +} + // cloneRoutingDecision takes ownership of optional scalar presence as well as the // value itself. UI state must not retain pointers owned by a transient client event. func cloneRoutingDecision(in *client.RoutingDecision) *client.RoutingDecision { diff --git a/cmd/mecatui/ui/internal/scrollback/tool.go b/cmd/mecatui/ui/internal/scrollback/tool.go index db3428df6d..eacf767adf 100644 --- a/cmd/mecatui/ui/internal/scrollback/tool.go +++ b/cmd/mecatui/ui/internal/scrollback/tool.go @@ -23,9 +23,10 @@ type ToolCall struct { // ToolResult describes a resolved tool call. IsError records an error result; // Artifacts are detached when stored or returned in a snapshot. type ToolResult struct { - Body string - IsError bool - Artifacts []Artifact + Body string + StructuredContent string + IsError bool + Artifacts []Artifact } // ToolCardSnapshot is the detached payload for a tool call. Resolved distinguishes diff --git a/cmd/mecatui/ui/render_cache_test.go b/cmd/mecatui/ui/render_cache_test.go index 56637c5074..c0b951844d 100644 --- a/cmd/mecatui/ui/render_cache_test.go +++ b/cmd/mecatui/ui/render_cache_test.go @@ -125,6 +125,10 @@ var oracleSteps = []struct { c.resolveAvailableTool("available-1", "available", false) c.resolveTool("available-1", "cancelled", true) }}, + {"resolveToolResult", func(c *conversation) { + c.addTool("structured-1", "Read", `{}`) + c.resolveToolResult(client.ToolResultMsg{CallID: "structured-1", Content: "result", StructuredContent: `{"count":1}`}) + }}, {"applySubagentTyped", func(c *conversation) { c.addTool("typed-sub", "Subagent", `{}`) c.applySubagentTyped(client.SubagentMsg{Kind: client.SubagentStart, ParentCallID: "typed-sub", ChildID: "typed-child", Goal: "typed"}) diff --git a/cmd/mecatui/ui/sessions_surface.go b/cmd/mecatui/ui/sessions_surface.go index 64a1befd24..dc58820b01 100644 --- a/cmd/mecatui/ui/sessions_surface.go +++ b/cmd/mecatui/ui/sessions_surface.go @@ -254,12 +254,7 @@ func (s *sessionsState) applyReplayEvent(msg tea.Msg) { case client.ToolCallMsg: c.addTool(msg.ID, msg.Name, msg.Args) case client.ToolResultMsg: - resolved := false - if msg.Available { - resolved = c.resolveAvailableTool(msg.CallID, msg.Content, msg.IsError, msg.Blocks...) - } else { - resolved = c.resolveTool(msg.CallID, msg.Content, msg.IsError, msg.Blocks...) - } + resolved := c.resolveToolResult(msg) if !resolved { c.addNotice("orphan tool result for " + msg.CallID) } @@ -1204,7 +1199,7 @@ func conversationFromTranscript(messages []client.ConversationMessage) conversat } case "tool": if message.ToolResult != nil { - out.resolveTool(message.ToolResult.CallID, message.ToolResult.Content, message.ToolResult.IsError, message.ToolResult.Blocks...) + out.resolveToolResult(client.ToolResultMsg{CallID: message.ToolResult.CallID, Content: message.ToolResult.Content, IsError: message.ToolResult.IsError, Blocks: message.ToolResult.Blocks, StructuredContent: message.ToolResult.StructuredContent}) } } } diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 7e2b30c7de..57af5374c7 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -9,6 +9,7 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" @@ -29,8 +30,10 @@ func (m Model) runToolcalls() (tea.Model, tea.Cmd) { type toolcallEntry struct { blockID scrollback.BlockID + callID string name string intent string + result scrollback.ToolResult resolved bool failed bool } @@ -41,6 +44,10 @@ type toolcallsState struct { entries []toolcallEntry selected int detail bool + window *bounded.Viewport + width int + lines int + follow bool list *bounded.List compact bool } @@ -57,17 +64,17 @@ func (m Model) toolcallEntries() []toolcallEntry { switch card := snapshot.Payload.(type) { case scrollback.ToolCardSnapshot: entry = toolcallEntry{ - name: card.Call.Name, intent: card.Call.Arguments, + callID: card.Call.ID, name: card.Call.Name, intent: card.Call.Arguments, result: card.Result, resolved: card.Resolved || card.Finished, failed: card.Result.IsError || card.Failed, } case scrollback.SubagentCardSnapshot: entry = toolcallEntry{ - name: card.Call.Name, intent: card.Call.Arguments, + callID: card.Call.ID, name: card.Call.Name, intent: card.Call.Arguments, result: card.Result, resolved: card.Resolved, failed: card.Result.IsError, } case scrollback.TeamCardSnapshot: entry = toolcallEntry{ - name: card.Call.Name, intent: card.Call.Arguments, + callID: card.Call.ID, name: card.Call.Name, intent: card.Call.Arguments, result: card.Result, resolved: card.Resolved, failed: card.Result.IsError, } default: @@ -83,7 +90,7 @@ func (m Model) toolcallEntries() []toolcallEntry { // A reader already following the newest row advances to the new newest row. func (s *toolcallsState) setEntries(entries []toolcallEntry, opening bool) { var selected scrollback.BlockID - following := opening || (len(s.entries) > 0 && s.selected == len(s.entries)-1) + following := !s.detail && (opening || (len(s.entries) > 0 && s.selected == len(s.entries)-1)) if !following && s.selected >= 0 && s.selected < len(s.entries) { selected = s.entries[s.selected].blockID } @@ -127,10 +134,9 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { } title := line(th.Style("askTitle"), "Tool calls") if s.detail { - entry := s.entries[s.selected] - return strings.Join([]string{title, "", line(th.Style("muted"), "detail placeholder for "+entry.name), "", line(th.Style("muted"), s.deps.marks.closeOnly+" back")}, "\n"), nil + return s.renderDetail(width, height, title, line), nil } - footer := line(th.Style("muted"), s.deps.marks.scroll+" navigate · enter detail · "+s.deps.marks.closeOnly+" close") + footer := line(th.Style("muted"), s.deps.marks.navUp+"/"+s.deps.marks.navDown+" · "+s.deps.marks.scroll+" · "+s.deps.marks.choose+" detail · "+s.deps.marks.closeOnly+" close") if len(s.entries) == 0 { return strings.Join([]string{title, "", line(th.Style("muted"), "no tool calls in this session."), "", footer}, "\n"), nil } @@ -170,6 +176,109 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { return strings.Join(body, "\n"), nil } +func (s *toolcallsState) renderDetail(width, height int, title string, line func(lipgloss.Style, string) string) string { + entry := s.entries[s.selected] + if s.window == nil { + s.window = new(bounded.Viewport) + s.follow = true + } + header := []string{title, ""} + footer := line(s.deps.theme.Style("muted"), s.deps.marks.navUp+"/"+s.deps.marks.navDown+" · "+s.deps.marks.scroll+" · "+s.deps.marks.jumpTopFull+"/"+s.deps.marks.jumpEndFull+" · "+s.deps.marks.closeOnly+" back") + content := toolcallDetailLines(entry) + if !s.follow && s.width > 0 && s.width != width { + oldRows := toolcallRowCounts(content, s.width) + newRows := toolcallRowCounts(content, width) + oldStart, newStart := 0, 0 + for i, count := range oldRows { + if s.window.Offset() < oldStart+count { + s.window.SetGeometry(width, height-len(header)-1, 0, bounded.Wrap) + s.window.Move(bounded.End, newStart+min(s.window.Offset()-oldStart, newRows[i]-1)+s.window.Height()) + break + } + oldStart += count + newStart += newRows[i] + } + } + s.width = width + s.window.SetGeometry(width, height-len(header)-1, 0, bounded.Wrap) + view := s.window.View(content) + s.lines = view.Above + len(view.Rows) + view.Below + if s.lines <= s.window.Height() { + s.follow = true + } + if s.follow { + s.window.Move(bounded.End, s.lines) + view = s.window.View(content) + } + for _, row := range view.Rows { + header = append(header, row) + } + for len(header) < height-1 { + header = append(header, "") + } + return strings.Join(append(header, footer), "\n") +} + +// toolcallRowCounts uses the same bounded wrapping policy as the detail window +// to remap its top logical line when a terminal resize reflows earlier text. +func toolcallRowCounts(lines []string, width int) []int { + probe := new(bounded.Viewport) + probe.SetGeometry(width, 1, 0, bounded.Wrap) + var counts []int + for _, line := range lines { + for _, source := range strings.Split(line, "\n") { + view := probe.View([]string{source}) + counts = append(counts, view.Above+len(view.Rows)+view.Below) + } + } + return counts +} + +func toolcallDetailLines(entry toolcallEntry) []string { + status := "running" + if entry.resolved { + status = "done" + } + if entry.failed { + status = "failed" + } + lines := []string{terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:", terminaltext.Sanitize(entry.intent)} + if !entry.resolved { + return append(lines, "", "Result: pending") + } + result := entry.result + lines = append(lines, "", "Result:") + if result.Body != "" { + lines = append(lines, terminaltext.Sanitize(result.Body)) + } + structured := result.StructuredContent + for _, a := range result.Artifacts { + switch client.ContentBlockKind(a.Kind) { + case client.ContentBlockText: + if a.Text != "" && a.Text != result.Body { + lines = append(lines, "Text:", terminaltext.Sanitize(a.Text)) + } + case client.ContentBlockStructuredContent: + // The typed block is canonical when a field mirror is also present. + structured = a.Text + case client.ContentBlockResourceLink: + lines = append(lines, "Resource: "+terminaltext.Sanitize(a.Name)+" · "+terminaltext.Sanitize(a.URL)) + case client.ContentBlockEmbeddedResource: + if len(a.Data) > 0 { + lines = append(lines, "Embedded resource ("+terminaltext.Sanitize(a.MIMEType)+", binary content)") + } else { + lines = append(lines, "Embedded resource:", terminaltext.Sanitize(a.Text)) + } + case client.ContentBlockImage, client.ContentBlockAudio: + lines = append(lines, string(a.Kind)+" ("+terminaltext.Sanitize(a.MIMEType)+", media content)") + } + } + if structured != "" { + lines = append(lines, "Structured JSON:", terminaltext.Sanitize(structured)) + } + return lines +} + func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if key.Matches(msg, s.deps.keys.Close) { if s.detail { @@ -195,11 +304,22 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { case key.Matches(msg, s.deps.keys.ScrollTop): move = bounded.Top case key.Matches(msg, s.deps.keys.Choose): - s.detail = true + if !s.detail { + s.detail = true + s.window = new(bounded.Viewport) + s.follow = false + } return nil, true, false default: return nil, true, false } + if s.detail { + if s.window != nil { + s.window.Move(move, s.lines) + s.follow = s.window.Offset() >= max(0, s.lines-s.window.Height()) + } + return nil, true, false + } if s.list != nil { s.list.Move(move) s.selected = s.list.Cursor() @@ -208,6 +328,15 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { } func (s *toolcallsState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { + if s.detail && s.window != nil { + move := bounded.LineDown + if msg.Mouse().Button == tea.MouseWheelUp { + move = bounded.LineUp + } + s.window.Move(move, s.lines) + s.follow = s.window.Offset() >= max(0, s.lines-s.window.Height()) + return nil, true + } if s.list == nil { return nil, true } diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go new file mode 100644 index 0000000000..78f73a161f --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -0,0 +1,215 @@ +package ui + +import ( + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +func inspectorDetail(t *testing.T, s *toolcallsState, width, height int) string { + t.Helper() + body, _ := s.Render(width, height) + return stripANSIstr(body) +} + +func inspectorOpenDetail(t *testing.T, m *Model) *toolcallsState { + t.Helper() + *m = openToolcallsForTest(t, *m) + s := toolcallsForTest(t, *m) + s.Render(70, 12) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + return s +} + +func TestMecatuiToolcallsInspector_Scenario2_LiveResultAndStatus(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.conv.addTool("first", "Read", `{"path":"first"}`) + m.conv.addTool("second", "Edit", `{"path":"second"}`) + s := inspectorOpenDetail(t, &m) + pending := inspectorDetail(t, s, 70, 12) + if !strings.Contains(pending, "Edit") || !strings.Contains(pending, "Call: second") || !strings.Contains(pending, `"second"`) || !strings.Contains(pending, "running") { + t.Fatalf("pending detail: %q", pending) + } + m = applyAll(m, client.ToolResultMsg{CallID: "second", Content: "temporary output", Available: true, IsError: true}) + provisional := inspectorDetail(t, s, 70, 12) + if !strings.Contains(provisional, "temporary output") || !strings.Contains(provisional, "failed") { + t.Fatalf("provisional detail: %q", provisional) + } + m = applyAll(m, client.ToolResultMsg{CallID: "second", Content: "canonical output"}) + final := inspectorDetail(t, s, 70, 12) + if !strings.Contains(final, "canonical output") || !strings.Contains(final, "done") || strings.Contains(final, "temporary output") { + t.Fatalf("canonical detail: %q", final) + } + m.conv.addTool("third", "Write", `{"path":"third"}`) + m.syncToolcalls() + if got := inspectorDetail(t, s, 70, 12); !strings.Contains(got, "Edit") || strings.Contains(got, `"third"`) { + t.Fatalf("detail switched calls: %q", got) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + s.Render(70, 12) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = applyAll(m, client.ToolResultMsg{CallID: "third", Content: "write failed", IsError: true}) + if got := inspectorDetail(t, s, 70, 12); !strings.Contains(got, "failed") || !strings.Contains(got, "write failed") { + t.Fatalf("canonical failed result: %q", got) + } +} + +func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) { + m := newToolcallsInspectorModel(t) + args := strings.Join([]string{"first-argument", strings.Repeat("longargument", 12), "last-argument"}, "\n") + m.conv.addTool("edit", "Edit", args) + s := inspectorOpenDetail(t, &m) + initial := inspectorDetail(t, s, 35, 9) + if !strings.Contains(initial, "first-argument") { + t.Fatalf("first argument missing: %q", initial) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnd}) + if got := inspectorDetail(t, s, 35, 9); !strings.Contains(got, "last-argument") { + t.Fatalf("last argument unreachable: %q", got) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) + m = applyAll(m, client.ToolResultMsg{CallID: "edit", Content: strings.Repeat("diff line\n", 40) + "final-diff-line"}) + if got := inspectorDetail(t, s, 35, 9); !strings.Contains(got, "first-argument") || strings.Contains(got, "final-diff-line") { + t.Fatalf("lost reading position: %q", got) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnd}) + if got := inspectorDetail(t, s, 35, 9); !strings.Contains(got, "final-diff-line") { + t.Fatalf("last diff unreachable: %q", got) + } + m.conv.addTool("more", "Write", "other") + m.syncToolcalls() + s.Render(35, 9) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + if s.selected != 0 || s.detail { + t.Fatalf("escape lost selected call: selected=%d detail=%v", s.selected, s.detail) + } + // The list is still anchored at the same selected row after a detail resize. + if got := inspectorDetail(t, s, 50, 11); !strings.Contains(got, "Edit") { + t.Fatalf("list lost selection: %q", got) + } + + m2 := newToolcallsInspectorModel(t) + m2.conv.addTool("write", "Write", strings.Repeat("argument\n", 12)) + s2 := inspectorOpenDetail(t, &m2) + inspectorDetail(t, s2, 55, 8) + s2.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnd}) + inspectorDetail(t, s2, 55, 8) + m2 = applyAll(m2, client.ToolResultMsg{CallID: "write", Content: "appended output marker"}) + if got := inspectorDetail(t, s2, 55, 8); !strings.Contains(got, "appended output marker") { + t.Fatalf("tail reader did not follow appended result: %q", got) + } + s2.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) + before := inspectorDetail(t, s2, 55, 8) + if got := inspectorDetail(t, s2, 45, 10); !strings.Contains(got, "argument") || strings.Contains(got, "appended output marker") || !strings.Contains(before, "argument") { + t.Fatalf("resize moved non-following reader: before=%q after=%q", before, got) + } + + m4 := newToolcallsInspectorModel(t) + m4 = addToolcallsForTest(t, m4, 25) + m4 = openToolcallsForTest(t, m4) + s4 := toolcallsForTest(t, m4) + s4.Render(40, 8) + s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgUp}) + s4.Render(40, 8) + listOffset, selected := s4.list.Offset(), s4.selected + s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + m4 = addToolcallsForTest(t, m4, 1) + s4.Render(40, 8) + s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + s4.Render(40, 8) + if s4.selected != selected || s4.list.Offset() != listOffset { + t.Fatalf("return from detail changed list window: selected=%d (want %d), offset=%d (want %d)", s4.selected, selected, s4.list.Offset(), listOffset) + } + + m3 := newToolcallsInspectorModel(t) + m3.conv.addTool("reflow", "Read", "start\n"+strings.Repeat("wide", 25)+"\nanchor-row\n"+strings.Repeat("after\n", 20)) + s3 := inspectorOpenDetail(t, &m3) + inspectorDetail(t, s3, 40, 8) + for i := 0; i < 7; i++ { + s3.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + } + at := inspectorDetail(t, s3, 40, 8) + if !strings.Contains(strings.Split(at, "\n")[2], "anchor-row") { + t.Fatalf("anchor setup failed: %q", at) + } + resized := inspectorDetail(t, s3, 24, 8) + if !strings.Contains(strings.Split(resized, "\n")[2], "anchor-row") { + t.Fatalf("reflow shifted reading position: before=%q after=%q", at, resized) + } +} + +func TestMecatuiToolcallsInspector_Scenario2_StructuredAndTypedResults(t *testing.T) { + for _, json := range []string{`{"count":2}`, `[1,2]`, `true`} { + t.Run(json, func(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.conv.addTool("typed", "Search", `{}`) + m.conv.scrollback.Tools().Resolve("typed", scrollback.ToolResult{Body: "human explanation", StructuredContent: `"STALE_FIELD_MIRROR"`, Artifacts: []scrollback.Artifact{ + {Kind: string(client.ContentBlockText), Text: "human explanation"}, + {Kind: string(client.ContentBlockStructuredContent), Text: json}, + {Kind: string(client.ContentBlockResourceLink), Name: "report\x1b]8;;bad\a", URL: "https://example.com/report\x1b[31m"}, + {Kind: string(client.ContentBlockEmbeddedResource), Text: "embedded explanation"}, + {Kind: string(client.ContentBlockImage), MIMEType: "image/png", Data: []byte("RAW_IMAGE_SECRET")}, + {Kind: string(client.ContentBlockAudio), MIMEType: "audio/wav", Data: []byte("RAW_AUDIO_SECRET")}, + {Kind: string(client.ContentBlockEmbeddedResource), MIMEType: "application/octet-stream", Data: []byte("RAW_BLOB_SECRET")}, + }}) + s := inspectorOpenDetail(t, &m) + inspectorDetail(t, s, 70, 12) + // Probe the full detail via page navigation, not just the visible tail. + var pages string + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) + for i := 0; i < 12; i++ { + pages += inspectorDetail(t, s, 70, 12) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) + } + for _, want := range []string{"human explanation", "Structured JSON", json, "https://example.com/report", "embedded explanation", "image/png", "audio/wav", "application/octet-stream"} { + if !strings.Contains(pages, want) { + t.Errorf("missing %q in detail %q", want, pages) + } + } + for _, forbidden := range []string{"RAW_IMAGE_SECRET", "RAW_AUDIO_SECRET", "RAW_BLOB_SECRET", "\x1b]8;;bad", "\x1b[31m"} { + if strings.Contains(pages, forbidden) { + t.Errorf("untrusted/binary %q in detail %q", forbidden, pages) + } + } + if n := strings.Count(pages, "human explanation"); n == 0 { + t.Errorf("missing distinct text") + } + full := inspectorDetail(t, s, 100, 28) + raw, _ := s.Render(100, 28) + if strings.Contains(raw, "\x1b]8;;bad") || strings.Contains(raw, "\x1b[31m") { + t.Errorf("unsanitized terminal controls in detail: %q", raw) + } + if strings.Contains(full, "STALE_FIELD_MIRROR") || strings.Count(full, "human explanation") != 1 { + t.Errorf("typed block did not supersede field or repeated text mirror: %q", full) + } + }) + } +} + +func TestMecatuiToolcallsInspector_Scenario2_ResumedFieldOnlyStructuredResult(t *testing.T) { + for _, json := range []string{`{"field":1}`, `["array"]`, `42`} { + t.Run(fmt.Sprintf("json=%s", json), func(t *testing.T) { + msg := client.ToolResultMsg{CallID: "field", Content: "distinct text", StructuredContent: json} + m := newToolcallsInspectorModel(t) + m.conv.addTool("field", "Read", `{}`) + s := inspectorOpenDetail(t, &m) + m = applyAll(m, msg) + live := inspectorDetail(t, s, 100, 18) + if !strings.Contains(live, "Structured JSON") || !strings.Contains(live, json) || !strings.Contains(live, "distinct text") { + t.Errorf("live field-only result: %q", live) + } + resumed := conversationFromTranscript([]client.ConversationMessage{{Role: "assistant", ToolCalls: []client.ConvToolCall{{ID: "field", Name: "Read", Args: `{}`}}}, {Role: "tool", ToolResult: &client.ConvToolResult{CallID: "field", Content: "distinct text", StructuredContent: json}}}) + m.conv = resumed + s = inspectorOpenDetail(t, &m) + got := inspectorDetail(t, s, 100, 18) + if !strings.Contains(got, "Structured JSON") || !strings.Contains(got, json) || !strings.Contains(got, "distinct text") { + t.Errorf("rehydrated field-only result: %q", got) + } + }) + } +} diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 2b6bb11e58..fb9f33c12f 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -1341,12 +1341,7 @@ func (m Model) updateStreamEvent(msg tea.Msg) (tea.Model, tea.Cmd) { m.syncToolcalls() return m.afterEvent() case client.ToolResultMsg: - resolved := false - if msg.Available { - resolved = m.conv.resolveAvailableTool(msg.CallID, msg.Content, msg.IsError, msg.Blocks...) - } else { - resolved = m.conv.resolveTool(msg.CallID, msg.Content, msg.IsError, msg.Blocks...) - } + resolved := m.conv.resolveToolResult(msg) if !resolved { m.conv.addNotice("orphan tool result for " + msg.CallID) } diff --git a/docs/tui.md b/docs/tui.md index 808726a0c0..7695ecb268 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -54,9 +54,11 @@ and selected-row styling. Let the surface own item semantics and activation. For list-and-detail browsers, use a bounded list for stable item selection and a separate bounded viewport for long detail. Preserve the selected item and list window when returning from detail; size both controls against the current -measured body instead of storing an independent row budget. The saved-memory -browser demonstrates list/detail ownership; Sessions demonstrates the -conversation-region fill placement for content that needs more room. Fill +measured body instead of storing an independent row budget. Keep detail scrolling +within that viewport: follow appended result lines only while its reader is at +the bottom, and keep the reader's position when new content arrives off-tail. +The saved-memory browser demonstrates list/detail ownership; Sessions demonstrates +the conversation-region fill placement for content that needs more room. Fill surfaces use only the offered conversation region and keep the frame's header, prompt, and footer. diff --git a/user-docs/mecatui/using-the-tui.md b/user-docs/mecatui/using-the-tui.md index 94b559494c..f9caf47fa0 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -14,8 +14,15 @@ the agent without waiting for the current run to finish. Assistant text streams as it arrives. Tool calls appear as compact cards; Edit and Write cards include their diff. -Focus a tool card and press `ctrl+t` to view its complete arguments and output. -Press `ctrl+t` again to return to the preview. +Use `/toolcalls` to browse the current session's tool calls, including calls still +running. Select a call with the navigation keys shown in the browser and press +`enter` to inspect its complete arguments and received result. Scroll long details +with the displayed keys; press `esc` to return to the list, then `esc` again to +close it. The browser also works when you reopen a session's transcript. + +In the conversation, `ctrl+t` expands or collapses tool details across the +conversation, regardless of which card is selected. Use `/toolcalls` to read one +call's full result without expanding the other cards. ## Attach a local file From 37479aaecac4d4668089786c8e95f9ae9e9c872c Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 04:03:33 -0700 Subject: [PATCH 05/30] Keep tool-call detail anchored to canonical scrollback on resize Co-authored-by: Mecatl --- .../ui/internal/bounded/scenario_test.go | 17 +++ cmd/mecatui/ui/internal/bounded/viewport.go | 8 ++ cmd/mecatui/ui/toolcalls.go | 123 +++++++++++++----- .../ui/toolcalls_detail_scenario_test.go | 106 ++++++++++++++- cmd/mecatui/ui/update.go | 6 + 5 files changed, 224 insertions(+), 36 deletions(-) diff --git a/cmd/mecatui/ui/internal/bounded/scenario_test.go b/cmd/mecatui/ui/internal/bounded/scenario_test.go index fd604d8118..546b16943a 100644 --- a/cmd/mecatui/ui/internal/bounded/scenario_test.go +++ b/cmd/mecatui/ui/internal/bounded/scenario_test.go @@ -137,6 +137,23 @@ func TestMecatuiBoundedScrollCursor_Scenario1_OversizedCursorItemReachable(t *te } } +func TestViewportSetOffsetPreservesNonTailPosition(t *testing.T) { + var viewport Viewport + viewport.SetGeometry(20, 3, 0, Clip) + viewport.SetOffset(5, 20) + if got := viewport.Offset(); got != 5 { + t.Fatalf("non-tail offset = %d, want 5", got) + } + viewport.SetOffset(100, 20) + if got := viewport.Offset(); got != 17 { + t.Fatalf("clamped offset = %d, want 17", got) + } + viewport.SetOffset(-1, 20) + if got := viewport.Offset(); got != 0 { + t.Fatalf("negative offset = %d, want 0", got) + } +} + func TestMecatuiBoundedScrollCursor_Scenario1_ClampsContentAndDegenerateBounds(t *testing.T) { var viewport Viewport viewport.SetGeometry(12, 2, 0, Clip) diff --git a/cmd/mecatui/ui/internal/bounded/viewport.go b/cmd/mecatui/ui/internal/bounded/viewport.go index a52144f79e..74f86bd31f 100644 --- a/cmd/mecatui/ui/internal/bounded/viewport.go +++ b/cmd/mecatui/ui/internal/bounded/viewport.go @@ -58,6 +58,14 @@ func (v *Viewport) Height() int { return v.height } // Offset returns the current scroll offset. func (v *Viewport) Offset() int { return v.offset } +// SetOffset positions the viewport at a physical row, clamped to its content. +func (v *Viewport) SetOffset(offset, total int) { + if !v.Valid() { + return + } + v.offset = clampScroll(offset, total, v.height) +} + // Reset scrolls the viewport to its beginning. func (v *Viewport) Reset() { v.offset = 0 } diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 57af5374c7..cd408f8ecf 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -30,26 +30,34 @@ func (m Model) runToolcalls() (tea.Model, tea.Cmd) { type toolcallEntry struct { blockID scrollback.BlockID - callID string + index int name string intent string - result scrollback.ToolResult resolved bool failed bool } +type toolcallDetail struct { + callID, name, intent string + result scrollback.ToolResult + resolved, failed bool + resultReceived bool +} + type toolcallsState struct { - open bool - deps surfaceDeps - entries []toolcallEntry - selected int - detail bool - window *bounded.Viewport - width int - lines int - follow bool - list *bounded.List - compact bool + open bool + deps surfaceDeps + entries []toolcallEntry + selected int + detail bool + detailEntry *toolcallDetail + window *bounded.Viewport + width int + anchor int + lines int + follow bool + list *bounded.List + compact bool } func (*toolcallsState) modalPlacement() modalPlacement { return modalPlacementFill } @@ -63,24 +71,21 @@ func (m Model) toolcallEntries() []toolcallEntry { var entry toolcallEntry switch card := snapshot.Payload.(type) { case scrollback.ToolCardSnapshot: - entry = toolcallEntry{ - callID: card.Call.ID, name: card.Call.Name, intent: card.Call.Arguments, result: card.Result, - resolved: card.Resolved || card.Finished, failed: card.Result.IsError || card.Failed, - } + entry = toolcallEntry{name: card.Call.Name, intent: card.Call.Arguments, + resolved: card.Resolved || card.Finished, failed: card.Result.IsError || card.Failed} case scrollback.SubagentCardSnapshot: - entry = toolcallEntry{ - callID: card.Call.ID, name: card.Call.Name, intent: card.Call.Arguments, result: card.Result, - resolved: card.Resolved, failed: card.Result.IsError, - } + entry = toolcallEntry{name: card.Call.Name, intent: card.Call.Arguments, + resolved: card.Resolved || card.Update.Done, failed: card.Result.IsError || (card.Update.Done && subagentStopErrored(card.Update.Stop))} case scrollback.TeamCardSnapshot: - entry = toolcallEntry{ - callID: card.Call.ID, name: card.Call.Name, intent: card.Call.Arguments, result: card.Result, - resolved: card.Resolved, failed: card.Result.IsError, - } + entry = toolcallEntry{name: card.Call.Name, intent: card.Call.Arguments, + resolved: card.Resolved || card.Update.Done, failed: card.Result.IsError || (card.Update.Done && subagentStopErrored(card.Update.Stop))} default: continue } + entry.index = i entry.blockID = snapshot.ID + entry.name = ansi.Truncate(terminaltext.SanitizeSingleLine(entry.name), 120, "…") + entry.intent = ansi.Truncate(strings.ReplaceAll(terminaltext.Sanitize(entry.intent), "\n", " "), 120, "…") entries = append(entries, entry) } return entries @@ -115,7 +120,32 @@ func (s *toolcallsState) setEntries(entries []toolcallEntry, opening bool) { func (m *Model) syncToolcalls() { if s, ok := m.modal.(*toolcallsState); ok { s.setEntries(m.toolcallEntries(), false) + if s.detail { + s.refreshDetail(&m.conv.scrollback) + } + } +} + +func (s *toolcallsState) refreshDetail(c *scrollback.Conversation) { + entry := s.entries[s.selected] + snapshot := c.SnapshotAt(entry.index) + if snapshot.ID != entry.blockID { + s.detailEntry = nil + return } + var call scrollback.ToolCall + var result scrollback.ToolResult + var received bool + switch card := snapshot.Payload.(type) { + case scrollback.ToolCardSnapshot: + call, result, received = card.Call, card.Result, card.Resolved + case scrollback.SubagentCardSnapshot: + call, result, received = card.Call, card.Result, card.Resolved + case scrollback.TeamCardSnapshot: + call, result, received = card.Call, card.Result, card.Resolved + } + s.detailEntry = &toolcallDetail{callID: call.ID, name: call.Name, intent: call.Arguments, result: result, + resolved: entry.resolved, failed: entry.failed, resultReceived: received} } func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { @@ -157,8 +187,7 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { if entry.failed { status = "failed" } - intent := strings.ReplaceAll(terminaltext.Sanitize(entry.intent), "\n", " ") - items[i] = bounded.ListItem{ID: fmt.Sprintf("%d", entry.blockID), Text: status + " · " + terminaltext.Sanitize(entry.name) + " · " + intent} + items[i] = bounded.ListItem{ID: fmt.Sprintf("%d", entry.blockID), Text: status + " · " + entry.name + " · " + entry.intent} } s.list.SetGeometry(width, bodyHeight, 0, bounded.Clip) s.list.SetItems(items) @@ -177,7 +206,10 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { } func (s *toolcallsState) renderDetail(width, height int, title string, line func(lipgloss.Style, string) string) string { - entry := s.entries[s.selected] + if s.detailEntry == nil { + return strings.Join([]string{title, "", line(s.deps.theme.Style("muted"), "call unavailable · "+s.deps.marks.closeOnly+" back")}, "\n") + } + entry := *s.detailEntry if s.window == nil { s.window = new(bounded.Viewport) s.follow = true @@ -188,11 +220,15 @@ func (s *toolcallsState) renderDetail(width, height int, title string, line func if !s.follow && s.width > 0 && s.width != width { oldRows := toolcallRowCounts(content, s.width) newRows := toolcallRowCounts(content, width) - oldStart, newStart := 0, 0 + oldStart, newStart, totalNew := 0, 0, 0 + for _, count := range newRows { + totalNew += count + } for i, count := range oldRows { if s.window.Offset() < oldStart+count { s.window.SetGeometry(width, height-len(header)-1, 0, bounded.Wrap) - s.window.Move(bounded.End, newStart+min(s.window.Offset()-oldStart, newRows[i]-1)+s.window.Height()) + within := s.anchor / width + s.window.SetOffset(newStart+min(within, newRows[i]-1), totalNew) break } oldStart += count @@ -234,7 +270,21 @@ func toolcallRowCounts(lines []string, width int) []int { return counts } -func toolcallDetailLines(entry toolcallEntry) []string { +func (s *toolcallsState) recordAnchor() { + if s.width == 0 || s.detailEntry == nil { + return + } + start := 0 + for _, count := range toolcallRowCounts(toolcallDetailLines(*s.detailEntry), s.width) { + if s.window.Offset() < start+count { + s.anchor = (s.window.Offset() - start) * s.width + return + } + start += count + } +} + +func toolcallDetailLines(entry toolcallDetail) []string { status := "running" if entry.resolved { status = "done" @@ -243,7 +293,7 @@ func toolcallDetailLines(entry toolcallEntry) []string { status = "failed" } lines := []string{terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:", terminaltext.Sanitize(entry.intent)} - if !entry.resolved { + if !entry.resultReceived { return append(lines, "", "Result: pending") } result := entry.result @@ -283,6 +333,7 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if key.Matches(msg, s.deps.keys.Close) { if s.detail { s.detail = false + s.detailEntry = nil return nil, true, false } return nil, true, true @@ -307,6 +358,8 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if !s.detail { s.detail = true s.window = new(bounded.Viewport) + s.width = 0 + s.anchor = 0 s.follow = false } return nil, true, false @@ -317,6 +370,9 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if s.window != nil { s.window.Move(move, s.lines) s.follow = s.window.Offset() >= max(0, s.lines-s.window.Height()) + if !s.follow { + s.recordAnchor() + } } return nil, true, false } @@ -335,6 +391,9 @@ func (s *toolcallsState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { } s.window.Move(move, s.lines) s.follow = s.window.Offset() >= max(0, s.lines-s.window.Height()) + if !s.follow { + s.recordAnchor() + } return nil, true } if s.list == nil { diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index 78f73a161f..5eb65ae383 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "reflect" "strings" "testing" @@ -21,8 +22,9 @@ func inspectorOpenDetail(t *testing.T, m *Model) *toolcallsState { *m = openToolcallsForTest(t, *m) s := toolcallsForTest(t, *m) s.Render(70, 12) - s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) - return s + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + *m = updated.(Model) + return toolcallsForTest(t, *m) } func TestMecatuiToolcallsInspector_Scenario2_LiveResultAndStatus(t *testing.T) { @@ -118,6 +120,7 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) s4.Render(40, 8) listOffset, selected := s4.list.Offset(), s4.selected s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + m4.syncToolcalls() m4 = addToolcallsForTest(t, m4, 1) s4.Render(40, 8) s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) @@ -127,7 +130,7 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) } m3 := newToolcallsInspectorModel(t) - m3.conv.addTool("reflow", "Read", "start\n"+strings.Repeat("wide", 25)+"\nanchor-row\n"+strings.Repeat("after\n", 20)) + m3.conv.addTool("reflow", "Read", "start\n"+strings.Repeat("wide", 25)+"\nanchor-row\n"+strings.Repeat("after\n", 80)) s3 := inspectorOpenDetail(t, &m3) inspectorDetail(t, s3, 40, 8) for i := 0; i < 7; i++ { @@ -139,7 +142,102 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) } resized := inspectorDetail(t, s3, 24, 8) if !strings.Contains(strings.Split(resized, "\n")[2], "anchor-row") { - t.Fatalf("reflow shifted reading position: before=%q after=%q", at, resized) + t.Fatalf("narrow reflow shifted reading position: before=%q after=%q", at, resized) + } + wide := inspectorDetail(t, s3, 60, 8) + if !strings.Contains(strings.Split(wide, "\n")[2], "anchor-row") { + t.Fatalf("wide reflow shifted reading position: before=%q after=%q", resized, wide) + } + + m5 := newToolcallsInspectorModel(t) + var wrapped strings.Builder + for i := 0; i < 400; i++ { + fmt.Fprintf(&wrapped, "%04d", i) + } + m5.conv.addTool("wrapped", "Read", wrapped.String()) + s5 := inspectorOpenDetail(t, &m5) + inspectorDetail(t, s5, 40, 8) + for i := 0; i < 10; i++ { + s5.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + } + original := strings.Split(inspectorDetail(t, s5, 40, 8), "\n")[2] + marker := original[:4] + if marker != "0070" { + t.Fatalf("wrapped anchor setup failed: %q", original) + } + for _, w := range []int{20, 80, 40} { + got := strings.Split(inspectorDetail(t, s5, w, 8), "\n")[2] + if !strings.Contains(got, marker) { + t.Fatalf("wrapped text anchor moved at width %d: before=%q after=%q", w, original, got) + } + } +} + +func TestMecatuiToolcallsInspector_Scenario2_CanonicalDetailOnly(t *testing.T) { + m := newToolcallsInspectorModel(t) + long := strings.Repeat("heavy-argument-", 500) + m.conv.addTool("first", "Read", long) + m.conv.addTool("reused", "Read", "original") + m.conv.resolveTool("first", strings.Repeat("large-result", 1000), false, client.ContentBlock{Kind: client.ContentBlockImage, Data: []byte(strings.Repeat("bytes", 1000))}) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if _, ok := reflect.TypeOf(toolcallEntry{}).FieldByName("result"); ok { + t.Fatal("list entries retain full historical results instead of canonical scrollback") + } + for _, entry := range s.entries { + if len(entry.intent) > 128 { + t.Fatalf("list retained full arguments: %d bytes", len(entry.intent)) + } + } + s.Render(70, 12) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + m.syncToolcalls() // the owning reducer refreshes the selected snapshot on entry + if got := inspectorDetail(t, s, 70, 12); !strings.Contains(got, "Call: reused") || !strings.Contains(got, "original") { + t.Fatalf("selected pending detail: %q", got) + } + m.conv.resolveTool("reused", "old result", false) + m.syncToolcalls() + m.conv.addTool("reused", "Write", "new arguments") + m.syncToolcalls() + m.conv.resolveTool("reused", "new result", false) + m.syncToolcalls() + got := inspectorDetail(t, s, 70, 12) + if !strings.Contains(got, "old result") || strings.Contains(got, "new result") || strings.Contains(got, "new arguments") { + t.Fatalf("selected block changed when call ID was reused: %q", got) + } +} + +func TestMecatuiToolcallsInspector_Scenario2_FinishBeforeResult(t *testing.T) { + cases := []struct { + name string + start, finish tea.Msg + }{ + {"Parallel", nil, client.ParallelMsg{Kind: client.ParallelEnd, ParentCallID: "call", Stop: "error"}}, + {"Subagent", client.SubagentMsg{Kind: client.SubagentStart, ParentCallID: "call", ChildID: "child"}, client.SubagentMsg{Kind: client.SubagentEnd, ParentCallID: "call", ChildID: "child", Stop: "error"}}, + {"Team", client.TeamMsg{Kind: client.TeamStart, ParentCallID: "call", TeamID: "team"}, client.TeamMsg{Kind: client.TeamEnd, ParentCallID: "call", TeamID: "team", Stop: "error"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.phase = phaseRunning + m.conv.addTool("call", tc.name, `{}`) + if tc.start != nil { + m = applyAll(m, tc.start) + } + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + s.Render(70, 12) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + m.syncToolcalls() + m = applyAll(m, tc.finish) + if got := inspectorDetail(t, s, 70, 12); !strings.Contains(got, "failed") || !strings.Contains(got, "Result: pending") { + t.Fatalf("finished-before-result not reflected: %q", got) + } + m = applyAll(m, client.ToolResultMsg{CallID: "call", Content: "canonical"}) + if got := inspectorDetail(t, s, 70, 12); !strings.Contains(got, "canonical") { + t.Fatalf("canonical result missing after terminal event: %q", got) + } + }) } } diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index fb9f33c12f..6e85a65045 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -1453,18 +1453,21 @@ func (m Model) updateStreamSecondary(msg tea.Msg) (tea.Model, tea.Cmd) { m.applySubagent(msg) if msg.Kind == client.SubagentEnd { m.activeTool = m.conv.latestPendingToolName() + m.syncToolcalls() } return m.afterEvent() case client.TeamMsg: m.applyTeam(msg) if msg.Kind == client.TeamEnd { m.activeTool = m.conv.latestPendingToolName() + m.syncToolcalls() } return m.afterEvent() case client.ParallelMsg: m.applyParallel(msg) if msg.Kind == client.ParallelEnd { m.activeTool = m.conv.latestPendingToolName() + m.syncToolcalls() } return m.afterEvent() case client.ModelRetryMsg: @@ -2495,6 +2498,9 @@ func (m Model) dispatchSurfaceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool m = mm.(Model) cmd = tea.Batch(cmd, intentCmd) } + if toolcalls, ok := m.modal.(*toolcallsState); ok && toolcalls.detail && toolcalls.detailEntry == nil { + m.syncToolcalls() + } if closed { m.closeModal() return m, tea.Batch(cmd, m.prompt.Focus()), true From 59b75c8bf19fb2f7de30629bbc5a9df7883e3a9f Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 04:13:33 -0700 Subject: [PATCH 06/30] Fix mecatui inspector lint Co-authored-by: Mecatl --- cmd/mecatui/ui/agents_overlay.go | 2 +- cmd/mecatui/ui/footer.go | 7 ++++++- cmd/mecatui/ui/render.go | 4 ++-- cmd/mecatui/ui/sessions_surface.go | 2 +- cmd/mecatui/ui/statusline_source.go | 2 +- cmd/mecatui/ui/toolcalls.go | 14 ++++++-------- cmd/mecatui/ui/toolcalls_detail_scenario_test.go | 1 + .../ui/toolcalls_inspector_scenario_test.go | 1 + 8 files changed, 19 insertions(+), 14 deletions(-) diff --git a/cmd/mecatui/ui/agents_overlay.go b/cmd/mecatui/ui/agents_overlay.go index 3c6c8db00c..ad0453fc66 100644 --- a/cmd/mecatui/ui/agents_overlay.go +++ b/cmd/mecatui/ui/agents_overlay.go @@ -1841,7 +1841,7 @@ func parallelBranchStopLabel(br *parallelBranch) string { if br.stop != "" { return "failed · " + subagentStopLabel(br.stop) } - return "failed" + return statusFailed } return subagentStopLabel(br.stop) } diff --git a/cmd/mecatui/ui/footer.go b/cmd/mecatui/ui/footer.go index ffd2920341..e1739e9df4 100644 --- a/cmd/mecatui/ui/footer.go +++ b/cmd/mecatui/ui/footer.go @@ -292,6 +292,11 @@ const slotCtxWarn = "ctxWarn" // so the literal does not trip goconst's min-occurrences across the switch arms. const slotMuted = "muted" +const ( + statusDone = "done" + statusFailed = "failed" +) + // stopReasonLabel maps a run's terminal stop reason (client.ResultMsg.Stop, the // proto Result.stop / session.StopReason vocabulary) to the human footer status // text and the theme style slot it should carry. The non-error LIMIT stops @@ -306,7 +311,7 @@ const slotMuted = "muted" func stopReasonLabel(stop string) (text, slot string) { switch stop { case "end_turn", "": - return "done", slotMuted + return statusDone, slotMuted case "max_turns": return "stopped · turn limit", slotCtxWarn case "max_tool_calls": diff --git a/cmd/mecatui/ui/render.go b/cmd/mecatui/ui/render.go index c7de3d37f5..f512b800f0 100644 --- a/cmd/mecatui/ui/render.go +++ b/cmd/mecatui/ui/render.go @@ -1478,7 +1478,7 @@ func teamLaneState(ln *teamLane, teamDone bool) string { // the opposite of what the supervisor reported. return "done (retried)" case teamDone: - return "done" + return statusDone case ln.idle: return "idle" } @@ -1680,7 +1680,7 @@ func oneLine(s string) string { func subagentStopLabel(stop string) string { switch stop { case "end_turn", "": - return "done" + return statusDone case "max_tool_calls": return "max-tools" case "max_turns": diff --git a/cmd/mecatui/ui/sessions_surface.go b/cmd/mecatui/ui/sessions_surface.go index dc58820b01..dee030bf6a 100644 --- a/cmd/mecatui/ui/sessions_surface.go +++ b/cmd/mecatui/ui/sessions_surface.go @@ -1211,7 +1211,7 @@ func stateBadge(state string) string { return "▶" case "completed": return "✓" - case teamStopReasonCancelled, "failed": + case teamStopReasonCancelled, statusFailed: return "✗" case "awaiting": return "⏸" diff --git a/cmd/mecatui/ui/statusline_source.go b/cmd/mecatui/ui/statusline_source.go index 5b0b0c9c16..1d0a7bd051 100644 --- a/cmd/mecatui/ui/statusline_source.go +++ b/cmd/mecatui/ui/statusline_source.go @@ -205,7 +205,7 @@ func (m Model) statusLineInput(now time.Time) customization.Input { case phaseAwaitingApproval: state, approval = "awaiting_approval", "awaiting" case phaseFatal: - state = "failed" + state = statusFailed } return customization.Input{ Version: customization.ProtocolVersion, diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index cd408f8ecf..b5904eb7cf 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -182,10 +182,10 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { for i, entry := range s.entries { status := "running" if entry.resolved { - status = "done" + status = statusDone } if entry.failed { - status = "failed" + status = statusFailed } items[i] = bounded.ListItem{ID: fmt.Sprintf("%d", entry.blockID), Text: status + " · " + entry.name + " · " + entry.intent} } @@ -246,9 +246,7 @@ func (s *toolcallsState) renderDetail(width, height int, title string, line func s.window.Move(bounded.End, s.lines) view = s.window.View(content) } - for _, row := range view.Rows { - header = append(header, row) - } + header = append(header, view.Rows...) for len(header) < height-1 { header = append(header, "") } @@ -287,10 +285,10 @@ func (s *toolcallsState) recordAnchor() { func toolcallDetailLines(entry toolcallDetail) []string { status := "running" if entry.resolved { - status = "done" + status = statusDone } if entry.failed { - status = "failed" + status = statusFailed } lines := []string{terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:", terminaltext.Sanitize(entry.intent)} if !entry.resultReceived { @@ -320,7 +318,7 @@ func toolcallDetailLines(entry toolcallDetail) []string { lines = append(lines, "Embedded resource:", terminaltext.Sanitize(a.Text)) } case client.ContentBlockImage, client.ContentBlockAudio: - lines = append(lines, string(a.Kind)+" ("+terminaltext.Sanitize(a.MIMEType)+", media content)") + lines = append(lines, a.Kind+" ("+terminaltext.Sanitize(a.MIMEType)+", media content)") } } if structured != "" { diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index 5eb65ae383..04d7b9d830 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -7,6 +7,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index fdf44957d0..768db0fb64 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -6,6 +6,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) From 10a69ecf19a631a77b97c4dcf740892e1320b84d Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 04:30:08 -0700 Subject: [PATCH 07/30] chore: mark tool-call inspector candidate landed Co-authored-by: Mecatl --- docs/acceptance/mecatui-toolcalls-inspector.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/mecatui-toolcalls-inspector.md b/docs/acceptance/mecatui-toolcalls-inspector.md index 2257a3717b..2da99acfec 100644 --- a/docs/acceptance/mecatui-toolcalls-inspector.md +++ b/docs/acceptance/mecatui-toolcalls-inspector.md @@ -4,7 +4,7 @@ **Work classification:** Bounded — a new client-local conversation browser changes visible navigation and detail presentation but not durable ownership, protocol, or trust boundaries. **Decision record:** None — the existing TUI surface, typed scrollback, and bounded navigation contracts contain this feature without a new durable architecture decision. **Phase:** issue #1361, first slice: additive tool-call inspection -**Status:** in-progress, 2026-10-02. The Plan / Interface PR merged at `9edd9b751`; implementation is underway. +**Status:** landed, 2026-10-02. Proposed implementation in this PR; authoritative when merged. **Delivery:** Split. The new command, live browsing behavior, and detail contract benefit from human review before implementation. **Expected tasks:** deferred to orchestration **Issue:** [stacklok/mecatl#1361](https://github.com/stacklok/mecatl/issues/1361). From 88091bb299c80ceab7aaa411c648b6c450fd8dbe Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 04:46:01 -0700 Subject: [PATCH 08/30] Repair tool-call inspector hints, inventory projection, and boundary coverage Co-authored-by: Mecatl --- .../ui/internal/scrollback/scrollback.go | 39 ++++++ .../ui/internal/scrollback/tool_test.go | 19 +++ cmd/mecatui/ui/toolcalls.go | 74 ++++++----- cmd/mecatui/ui/toolcalls_boundary_test.go | 110 ++++++++++++++++ .../ui/toolcalls_detail_scenario_test.go | 40 +++--- .../ui/toolcalls_inspector_scenario_test.go | 122 +++++++++++++++++- 6 files changed, 352 insertions(+), 52 deletions(-) create mode 100644 cmd/mecatui/ui/toolcalls_boundary_test.go diff --git a/cmd/mecatui/ui/internal/scrollback/scrollback.go b/cmd/mecatui/ui/internal/scrollback/scrollback.go index 32c6f13b18..efdc70e37c 100644 --- a/cmd/mecatui/ui/internal/scrollback/scrollback.go +++ b/cmd/mecatui/ui/internal/scrollback/scrollback.go @@ -80,6 +80,45 @@ type Conversation struct { seen map[string]struct{} } +// ToolCallMetadata is the compact tool-card projection used by inventories. It +// intentionally excludes results and artifacts, whose byte payloads remain owned by +// the conversation until a selected detail requests a detached snapshot. +type ToolCallMetadata struct { + ID BlockID + CallID, Name, Arguments string + Resolved, Failed bool + Stop string +} + +// ToolCallMetadataAt returns the compact top-level tool projection at index i +// without detaching its payload. It returns false for non-tool cards. +func (c *Conversation) ToolCallMetadataAt(i int) (ToolCallMetadata, bool) { + card := c.cards[i] + var call ToolCall + var resolved, failed bool + var stop string + switch payload := card.payload.(type) { + case ToolCardSnapshot: + call, resolved = payload.Call, payload.Resolved || payload.Finished + failed = payload.Result.IsError || payload.Failed + case SubagentCardSnapshot: + call, resolved = payload.Call, payload.Resolved || payload.Update.Done + if payload.Update.Done { + stop = payload.Update.Stop + } + failed = payload.Result.IsError + case TeamCardSnapshot: + call, resolved = payload.Call, payload.Resolved || payload.Update.Done + if payload.Update.Done { + stop = payload.Update.Stop + } + failed = payload.Result.IsError + default: + return ToolCallMetadata{}, false + } + return ToolCallMetadata{ID: card.id, CallID: call.ID, Name: call.Name, Arguments: call.Arguments, Resolved: resolved, Failed: failed, Stop: stop}, true +} + // Len returns the number of ordinary cards in the conversation. It excludes the // separately rendered changed-files appendix. func (c *Conversation) Len() int { return len(c.cards) } diff --git a/cmd/mecatui/ui/internal/scrollback/tool_test.go b/cmd/mecatui/ui/internal/scrollback/tool_test.go index ee1fcffa33..378ef5070c 100644 --- a/cmd/mecatui/ui/internal/scrollback/tool_test.go +++ b/cmd/mecatui/ui/internal/scrollback/tool_test.go @@ -87,6 +87,25 @@ func TestADR_0370_Scenario3_ClientConfirmationAndReplacement(t *testing.T) { } } +func TestToolCallMetadataAtDoesNotExposeResultPayload(t *testing.T) { + var c Conversation + c.Tools().Add(ToolCall{ID: "read", Name: "Read", Arguments: `{"path":"x"}`}) + if !c.Tools().Resolve("read", ToolResult{IsError: true, Artifacts: []Artifact{{Data: []byte("binary")}}}) { + t.Fatal("resolve tool") + } + + got, ok := c.ToolCallMetadataAt(0) + if !ok { + t.Fatal("tool metadata missing") + } + if allocs := testing.AllocsPerRun(100, func() { _, _ = c.ToolCallMetadataAt(0) }); allocs != 0 { + t.Fatalf("metadata enumerator cloned result: %.0f allocations", allocs) + } + if got.CallID != "read" || got.Name != "Read" || got.Arguments != `{"path":"x"}` || !got.Resolved || !got.Failed { + t.Fatalf("metadata = %#v", got) + } +} + func TestToolLifecycle(t *testing.T) { var c Conversation call := ToolCall{ID: "read", Name: "Read", Artifacts: []Artifact{{Data: []byte("request")}}} diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index b5904eb7cf..a0fb8fdf0b 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -64,29 +64,34 @@ func (*toolcallsState) modalPlacement() modalPlacement { return modalPl func (*toolcallsState) Close() {} func (*toolcallsState) HandleMsg(tea.Msg) (tea.Cmd, bool, bool) { return nil, false, false } +func toolcallsTooSmallHint(width int, close string) string { + if width <= 0 { + return "" + } + message := "too small · " + close + if ansi.StringWidth(message) <= width { + return message + } + if ansi.StringWidth(close) <= width { + return close + } + return ansi.Truncate(close, width, "") +} + func (m Model) toolcallEntries() []toolcallEntry { entries := make([]toolcallEntry, 0) for i := 0; i < m.conv.scrollback.Len(); i++ { - snapshot := m.conv.scrollback.SnapshotAt(i) - var entry toolcallEntry - switch card := snapshot.Payload.(type) { - case scrollback.ToolCardSnapshot: - entry = toolcallEntry{name: card.Call.Name, intent: card.Call.Arguments, - resolved: card.Resolved || card.Finished, failed: card.Result.IsError || card.Failed} - case scrollback.SubagentCardSnapshot: - entry = toolcallEntry{name: card.Call.Name, intent: card.Call.Arguments, - resolved: card.Resolved || card.Update.Done, failed: card.Result.IsError || (card.Update.Done && subagentStopErrored(card.Update.Stop))} - case scrollback.TeamCardSnapshot: - entry = toolcallEntry{name: card.Call.Name, intent: card.Call.Arguments, - resolved: card.Resolved || card.Update.Done, failed: card.Result.IsError || (card.Update.Done && subagentStopErrored(card.Update.Stop))} - default: + metadata, ok := m.conv.scrollback.ToolCallMetadataAt(i) + if !ok { continue } - entry.index = i - entry.blockID = snapshot.ID - entry.name = ansi.Truncate(terminaltext.SanitizeSingleLine(entry.name), 120, "…") - entry.intent = ansi.Truncate(strings.ReplaceAll(terminaltext.Sanitize(entry.intent), "\n", " "), 120, "…") - entries = append(entries, entry) + entries = append(entries, toolcallEntry{ + blockID: metadata.ID, index: i, + name: ansi.Truncate(terminaltext.SanitizeSingleLine(metadata.Name), 120, "…"), + intent: ansi.Truncate(strings.ReplaceAll(terminaltext.Sanitize(metadata.Arguments), "\n", " "), 120, "…"), + resolved: metadata.Resolved, + failed: metadata.Failed || subagentStopErrored(metadata.Stop), + }) } return entries } @@ -160,46 +165,53 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { } if height < 5 || width < 12 { s.compact, s.list = true, nil - return line(th.Style("muted"), "too small · "+s.deps.marks.closeOnly+" close"), nil + return line(th.Style("muted"), toolcallsTooSmallHint(width, s.deps.marks.closeOnly)), nil } title := line(th.Style("askTitle"), "Tool calls") if s.detail { + hint := s.deps.marks.navUp + "/" + s.deps.marks.navDown + " · " + s.deps.marks.scroll + " · " + s.deps.marks.jumpTopFull + "/" + s.deps.marks.jumpEndFull + " · " + s.deps.marks.closeOnly + " back" + if ansi.StringWidth(hint) > width { + s.compact, s.list = true, nil + return line(th.Style("muted"), toolcallsTooSmallHint(width, s.deps.marks.closeOnly)), nil + } return s.renderDetail(width, height, title, line), nil } - footer := line(th.Style("muted"), s.deps.marks.navUp+"/"+s.deps.marks.navDown+" · "+s.deps.marks.scroll+" · "+s.deps.marks.choose+" detail · "+s.deps.marks.closeOnly+" close") + footerText := s.deps.marks.navUp + "/" + s.deps.marks.navDown + " · " + s.deps.marks.scroll + " · " + s.deps.marks.choose + " detail · " + s.deps.marks.closeOnly + " close" + if ansi.StringWidth(footerText) > width { + s.compact, s.list = true, nil + return line(th.Style("muted"), toolcallsTooSmallHint(width, s.deps.marks.closeOnly)), nil + } + footer := line(th.Style("muted"), footerText) if len(s.entries) == 0 { return strings.Join([]string{title, "", line(th.Style("muted"), "no tool calls in this session."), "", footer}, "\n"), nil } bodyHeight := height - 4 if bodyHeight < 1 { s.compact, s.list = true, nil - return line(th.Style("muted"), "too small · "+s.deps.marks.closeOnly+" close"), nil + return line(th.Style("muted"), toolcallsTooSmallHint(width, s.deps.marks.closeOnly)), nil } if s.list == nil { s.list = new(bounded.List) } items := make([]bounded.ListItem, len(s.entries)) for i, entry := range s.entries { - status := "running" + marker, status := "…", "running" if entry.resolved { - status = statusDone + marker, status = "✓", statusDone } if entry.failed { - status = statusFailed + marker, status = "✗", statusFailed } - items[i] = bounded.ListItem{ID: fmt.Sprintf("%d", entry.blockID), Text: status + " · " + entry.name + " · " + entry.intent} + items[i] = bounded.ListItem{ID: fmt.Sprintf("%d", entry.blockID), Text: status + " · " + entry.name + " · " + entry.intent, StatusCells: [2]string{marker}} } - s.list.SetGeometry(width, bodyHeight, 0, bounded.Clip) + s.list.SetGeometry(width, bodyHeight, 1, bounded.Clip) s.list.SetItems(items) s.list.SetCursor(s.selected) view := s.list.View() body := []string{title, ""} for _, row := range view.Rows { - prefix := " " - if row.Selected { - prefix = "> " - } - body = append(body, ansi.Cut(prefix+row.Text, 0, width)+"\x1b[0m") + presentation := presentListRow(row, th.Style("accent"), th.Style("muted")) + body = append(body, ansi.Cut(presentation.Style.Render(presentation.Text), 0, width)+"\x1b[0m") } body = append(body, "", footer) return strings.Join(body, "\n"), nil diff --git a/cmd/mecatui/ui/toolcalls_boundary_test.go b/cmd/mecatui/ui/toolcalls_boundary_test.go new file mode 100644 index 0000000000..0f002f1181 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_boundary_test.go @@ -0,0 +1,110 @@ +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" + mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" +) + +func TestMecatuiToolcallsInspector_Scenario1_StreamingThroughOpenModel(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.phase = phaseRunning + m.liveGen = 4 + deliver := func(ev *mecatlv1.Event) { + t.Helper() + updated, _ := m.Update(liveMsg{gen: 4, msg: client.EventToMsg(ev)}) + m = updated.(Model) + } + call := func(id, name string) *mecatlv1.Event { + return &mecatlv1.Event{Type: "tool.call", ToolCall: &mecatlv1.ToolCall{Id: id, Name: name, Args: `{"path":"file"}`}} + } + result := func(id, body, kind string) *mecatlv1.Event { + return &mecatlv1.Event{Type: kind, ToolResult: &mecatlv1.ToolResult{CallId: id, Content: body}} + } + deliver(call("older", "Read")) + deliver(call("reused", "Edit")) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + s.Render(80, 12) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if s.selected != 0 { + t.Fatalf("older selection = %d", s.selected) + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + s = toolcallsForTest(t, m) + deliver(result("older", "temporary", "tool.result.available")) + if got := inspectorDetail(t, s, 80, 12); !strings.Contains(got, "temporary") { + t.Fatalf("provisional detail %q", got) + } + deliver(result("older", "canonical", "tool.result")) + deliver(result("reused", "old result", "tool.result")) + deliver(call("reused", "Write")) + deliver(result("reused", "new result", "tool.result")) + if s.selected != 0 || len(s.entries) != 3 || s.detailEntry == nil || s.detailEntry.callID != "older" { + t.Fatalf("live selection changed: %#v", s) + } + if got := inspectorDetail(t, s, 80, 12); !strings.Contains(got, "canonical") || strings.Contains(got, "temporary") || strings.Contains(got, "new result") { + t.Fatalf("canonical detail %q", got) + } + for i, want := range []string{"canonical", "old result", "new result"} { + snap := m.conv.scrollback.SnapshotAt(s.entries[i].index) + if body := snap.Payload.(scrollback.ToolCardSnapshot).Result.Body; body != want { + t.Fatalf("scrollback %d = %q, want %q", i, body, want) + } + } + m.liveGen++ + stale, _ := m.Update(liveMsg{gen: 4, msg: client.EventToMsg(call("late", "Write"))}) + m = stale.(Model) + if m.conv.scrollback.Len() != 3 || len(toolcallsForTest(t, m).entries) != 3 { + t.Fatal("stale live callback reached replaced feed or inspector") + } +} + +func TestMecatuiToolcallsInspector_Scenario2_ReceivedProjectionBoundary(t *testing.T) { + m := newToolcallsInspectorModel(t) + call := &mecatlv1.Event{Type: "tool.call", ToolCall: &mecatlv1.ToolCall{Id: "safe", Name: "Read", Args: `{"path":"safe"}`}} + m = applyAll(m, client.EventToMsg(call)) + s := inspectorOpenDetail(t, &m) + // Only the effective, display-safe result arrives on this client boundary. + // The original withheld body and decoded media bytes are not reconstructible. + result := &mecatlv1.Event{Type: "tool.result", ToolResult: &mecatlv1.ToolResult{CallId: "safe", Content: "effective text", Blocks: []*mecatlv1.ContentBlock{ + {Kind: mecatlv1.ContentBlock_KIND_IMAGE, MimeType: "image/png\x1b]8;;evil\a", Data: []byte("binary-bytes")}, + {Kind: mecatlv1.ContentBlock_KIND_RESOURCE_LINK, Name: strings.Repeat("oversized", 100) + "\x1b[31m", Url: "https://example.invalid/safe\x1b]8;;evil\a"}, + }}} + m = applyAll(m, client.EventToMsg(result)) + if snap := m.conv.scrollback.SnapshotAt(0).Payload.(scrollback.ToolCardSnapshot); snap.Result.Body != "effective text" { + t.Fatalf("received body: %q", snap.Result.Body) + } + s = toolcallsForTest(t, m) + var pages, raw string + for _, width := range []int{70, 100} { + s.Render(width, 9) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) + for i := 0; i < 80; i++ { + body, _ := s.Render(width, 9) + raw += body + pages += stripANSIstr(body) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) + } + } + if !strings.Contains(pages, "effective text") || !strings.Contains(pages, "image/png") || !strings.Contains(pages, "https://example.invalid/safe") { + t.Fatalf("projection missing: %q", pages) + } + for _, forbidden := range []string{"withheld-original", "binary-bytes", "YmluYXJ5LWJ5dGVz"} { + if strings.Contains(pages, forbidden) { + t.Fatalf("projection exposed %q", forbidden) + } + } + for _, unsafe := range []string{"\x1b]8;;evil", "\x1b[31m"} { + if strings.Contains(raw, unsafe) { + t.Fatalf("terminal control reached output: %q", unsafe) + } + } +} diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index 04d7b9d830..4d4d458599 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -67,21 +67,21 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) args := strings.Join([]string{"first-argument", strings.Repeat("longargument", 12), "last-argument"}, "\n") m.conv.addTool("edit", "Edit", args) s := inspectorOpenDetail(t, &m) - initial := inspectorDetail(t, s, 35, 9) + initial := inspectorDetail(t, s, 70, 9) if !strings.Contains(initial, "first-argument") { t.Fatalf("first argument missing: %q", initial) } s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnd}) - if got := inspectorDetail(t, s, 35, 9); !strings.Contains(got, "last-argument") { + if got := inspectorDetail(t, s, 70, 9); !strings.Contains(got, "last-argument") { t.Fatalf("last argument unreachable: %q", got) } s.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) m = applyAll(m, client.ToolResultMsg{CallID: "edit", Content: strings.Repeat("diff line\n", 40) + "final-diff-line"}) - if got := inspectorDetail(t, s, 35, 9); !strings.Contains(got, "first-argument") || strings.Contains(got, "final-diff-line") { + if got := inspectorDetail(t, s, 70, 9); !strings.Contains(got, "first-argument") || strings.Contains(got, "final-diff-line") { t.Fatalf("lost reading position: %q", got) } s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnd}) - if got := inspectorDetail(t, s, 35, 9); !strings.Contains(got, "final-diff-line") { + if got := inspectorDetail(t, s, 70, 9); !strings.Contains(got, "final-diff-line") { t.Fatalf("last diff unreachable: %q", got) } m.conv.addTool("more", "Write", "other") @@ -116,16 +116,16 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) m4 = addToolcallsForTest(t, m4, 25) m4 = openToolcallsForTest(t, m4) s4 := toolcallsForTest(t, m4) - s4.Render(40, 8) + s4.Render(70, 8) s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgUp}) - s4.Render(40, 8) + s4.Render(70, 8) listOffset, selected := s4.list.Offset(), s4.selected s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) m4.syncToolcalls() m4 = addToolcallsForTest(t, m4, 1) - s4.Render(40, 8) + s4.Render(70, 8) s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) - s4.Render(40, 8) + s4.Render(70, 8) if s4.selected != selected || s4.list.Offset() != listOffset { t.Fatalf("return from detail changed list window: selected=%d (want %d), offset=%d (want %d)", s4.selected, selected, s4.list.Offset(), listOffset) } @@ -133,19 +133,19 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) m3 := newToolcallsInspectorModel(t) m3.conv.addTool("reflow", "Read", "start\n"+strings.Repeat("wide", 25)+"\nanchor-row\n"+strings.Repeat("after\n", 80)) s3 := inspectorOpenDetail(t, &m3) - inspectorDetail(t, s3, 40, 8) - for i := 0; i < 7; i++ { + inspectorDetail(t, s3, 70, 8) + for i := 0; i < 6; i++ { s3.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) } - at := inspectorDetail(t, s3, 40, 8) + at := inspectorDetail(t, s3, 70, 8) if !strings.Contains(strings.Split(at, "\n")[2], "anchor-row") { t.Fatalf("anchor setup failed: %q", at) } - resized := inspectorDetail(t, s3, 24, 8) + resized := inspectorDetail(t, s3, 55, 8) if !strings.Contains(strings.Split(resized, "\n")[2], "anchor-row") { t.Fatalf("narrow reflow shifted reading position: before=%q after=%q", at, resized) } - wide := inspectorDetail(t, s3, 60, 8) + wide := inspectorDetail(t, s3, 90, 8) if !strings.Contains(strings.Split(wide, "\n")[2], "anchor-row") { t.Fatalf("wide reflow shifted reading position: before=%q after=%q", resized, wide) } @@ -157,18 +157,18 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) } m5.conv.addTool("wrapped", "Read", wrapped.String()) s5 := inspectorOpenDetail(t, &m5) - inspectorDetail(t, s5, 40, 8) + inspectorDetail(t, s5, 70, 8) for i := 0; i < 10; i++ { s5.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) } - original := strings.Split(inspectorDetail(t, s5, 40, 8), "\n")[2] - marker := original[:4] - if marker != "0070" { + original := strings.Split(inspectorDetail(t, s5, 70, 8), "\n")[2] + marker := original[2:6] + if marker != "0123" { t.Fatalf("wrapped anchor setup failed: %q", original) } - for _, w := range []int{20, 80, 40} { - got := strings.Split(inspectorDetail(t, s5, w, 8), "\n")[2] - if !strings.Contains(got, marker) { + for _, w := range []int{55, 90, 70} { + got := inspectorDetail(t, s5, w, 8) + if !strings.Contains(strings.ReplaceAll(got, "\n", ""), marker) { t.Fatalf("wrapped text anchor moved at width %d: before=%q after=%q", w, original, got) } } diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 768db0fb64..c522dcd992 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + "github.com/charmbracelet/x/ansi" + tea "charm.land/bubbletea/v2" "github.com/stacklok/mecatl/cmd/mecatui/client" @@ -31,6 +33,29 @@ func TestMecatuiToolcallsInspector_Scenario1_OpenEmptyRunningAndResume(t *testin } } +func TestMecatuiToolcallsInspector_Scenario1_ResumeProjection(t *testing.T) { + resume := &client.ResumeSelection{ + Row: client.SessionListItem{ID: "resumed"}, + Transcript: client.SessionTranscript{Messages: []client.ConversationMessage{ + {Role: "assistant", ToolCalls: []client.ConvToolCall{{ID: "reused", Name: "Read", Args: `{"path":"resumed"}`}}}, + {Role: "tool", ToolResult: &client.ConvToolResult{CallID: "reused", Content: "rehydrated"}}, + }}, + } + m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context(), Resume: resume}) + m.width, m.height = 100, 30 + m.relayout() + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if len(s.entries) != 1 || s.entries[0].name != "Read" { + t.Fatalf("resumed inventory: %#v", s.entries) + } + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + if got := inspectorDetail(t, toolcallsForTest(t, m), 80, 12); !strings.Contains(got, "rehydrated") || !strings.Contains(got, "resumed") { + t.Fatalf("resumed detail: %q", got) + } +} + func TestMecatuiToolcallsInspector_Scenario1_TopLevelDelegationsOnly(t *testing.T) { m := newToolcallsInspectorModel(t) m.conv.addTool("ordinary", "Read", `{"path":"first"}`) @@ -70,6 +95,55 @@ func TestMecatuiToolcallsInspector_Scenario1_TopLevelDelegationsOnly(t *testing. } func TestMecatuiToolcallsInspector_Scenario1_ChronologicalNavigation(t *testing.T) { + m := newToolcallsInspectorModel(t) + m = addToolcallsForTest(t, m, 125) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + s.Render(80, 12) + if s.selected != 124 || !strings.Contains(stripANSIstr(m.View().Content), "file-124.go") { + t.Fatalf("opening did not reveal newest call: %d", s.selected) + } + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyHome}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if s.selected != 0 || !strings.Contains(stripANSIstr(m.View().Content), "file-0.go") { + t.Fatalf("top did not reveal oldest call: %d", s.selected) + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if s.selected <= 0 || s.selected >= 124 { + t.Fatalf("page down did not navigate: %d", s.selected) + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnd}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if s.selected != 124 { + t.Fatalf("end selection = %d", s.selected) + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyPgUp}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if s.selected >= 124 { + t.Fatalf("page up did not navigate: %d", s.selected) + } + selected := s.selected + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if s.detail || s.selected != selected { + t.Fatalf("first Escape lost list selection: %d", s.selected) + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = updated.(Model) + if toolcallsForTest(t, m) != nil || !m.prompt.Focused() { + t.Fatal("second Escape did not close and restore prompt") + } +} + +func TestMecatuiToolcallsInspector_Scenario1_ChronologicalNavigationShort(t *testing.T) { m := newToolcallsInspectorModel(t) m = addToolcallsForTest(t, m, 3) m = openToolcallsForTest(t, m) @@ -135,13 +209,59 @@ func TestMecatuiToolcallsInspector_Scenario3_FullRegionAndCompactFallback(t *tes t.Fatalf("nonpositive geometry rendered %q", got) } compact, _ := s.Render(10, 3) - if !s.compact || !strings.Contains(stripANSIstr(compact), "too small") { + if !s.compact || !strings.Contains(stripANSIstr(compact), s.deps.marks.closeOnly) { t.Fatalf("compact fallback = %q, compact=%v", compact, s.compact) } + // The normal navigation hint is wider than this offer. Do not clip Escape: + // switch to the close-only fallback before rendering the list. + compact, _ = s.Render(30, 20) + if !s.compact || !strings.Contains(stripANSIstr(compact), s.deps.marks.closeOnly) { + t.Fatalf("narrow fallback clipped close hint: %q", compact) + } normal, _ := s.Render(80, 20) if s.compact || !strings.Contains(stripANSIstr(normal), "Tool calls") { t.Fatalf("normal geometry did not restore the list: %q", normal) } + listHint := s.deps.marks.navUp + "/" + s.deps.marks.navDown + " · " + s.deps.marks.scroll + " · " + s.deps.marks.choose + " detail · " + s.deps.marks.closeOnly + " close" + limit := ansi.StringWidth(listHint) + if body, _ := s.Render(limit-1, 20); !s.compact || strings.Contains(body, "Tool calls") { + t.Fatalf("below list hint threshold: %q", body) + } + if body, _ := s.Render(limit, 20); s.compact || !strings.Contains(body, s.deps.marks.closeOnly) { + t.Fatalf("at list hint threshold: %q", body) + } + if s.selected != 0 || s.list == nil || !strings.Contains(stripANSIstr(normal), "▶") { + t.Fatalf("resize lost selection or non-color marker: selected=%d list=%v body=%q", s.selected, s.list, normal) + } + for _, width := range []int{1, 3, 10, 30, 80, 160} { + body, _ := s.Render(width, 20) + for _, row := range strings.Split(stripANSIstr(body), "\n") { + if ansi.StringWidth(row) > width { + t.Fatalf("list width %d overflows: %q", width, row) + } + } + if s.compact && ansi.StringWidth(s.deps.marks.closeOnly) <= width && !strings.Contains(stripANSIstr(body), s.deps.marks.closeOnly) { + t.Fatalf("width %d lost Escape hint: %q", width, body) + } + } + s.Render(80, 20) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + s = toolcallsForTest(t, m) + detailHint := s.deps.marks.navUp + "/" + s.deps.marks.navDown + " · " + s.deps.marks.scroll + " · " + s.deps.marks.jumpTopFull + "/" + s.deps.marks.jumpEndFull + " · " + s.deps.marks.closeOnly + " back" + limit = ansi.StringWidth(detailHint) + if body, _ := s.Render(limit-1, 20); !s.compact || strings.Contains(body, "Tool calls") { + t.Fatalf("below detail hint threshold: %q", body) + } + if body, _ := s.Render(limit, 20); s.compact || !strings.Contains(body, s.deps.marks.closeOnly) { + t.Fatalf("at detail hint threshold: %q", body) + } + for _, width := range []int{1, 10, 30, 80} { + body, _ := s.Render(width, 20) + if width == 30 && (!s.compact || !strings.Contains(stripANSIstr(body), s.deps.marks.closeOnly)) { + t.Fatalf("detail narrow hint clipped: %q", body) + } + } } func TestMecatuiToolcallsInspector_Scenario3_InputOwnershipAndSafety(t *testing.T) { From 0df64b81145a002c3be921166910e39571f00000 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 04:48:05 -0700 Subject: [PATCH 09/30] Correct mecatui tool detail shortcut documentation Co-authored-by: Mecatl --- user-docs/mecatui/keybindings.md | 2 +- user-docs/mecatui/using-the-tui.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/user-docs/mecatui/keybindings.md b/user-docs/mecatui/keybindings.md index a378ea3f07..0f311644aa 100644 --- a/user-docs/mecatui/keybindings.md +++ b/user-docs/mecatui/keybindings.md @@ -25,7 +25,7 @@ Use **Up/Down** to move one line, **Page Up/Page Down** to move one page, and | `ctrl+u` | Clear the unsent draft, including staged attachments and large-paste placeholders (`ClearPrompt`; remappable). | | physical `esc` twice within 500ms | While idle with a focused draft containing text, staged attachments, large-paste content, or pending media, clear it through `ClearPrompt`; attachment-only drafts qualify. This gesture requires enhanced key-event support, so it is unavailable in terminals that do not report it. The first press is silent and only arms; an `esc` key release must establish a distinct press before a second non-repeat press can clear. A key repeat or another press before release cannot complete it. Selection, palette, mention, approval, overlay, modal, and running-turn owners take precedence and disarm it, as do another key or expiry. This physical gesture is not remappable; use the universal remappable `ClearPrompt` / `ctrl+u` alternative. | | `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` | Expand a focused tool card or approval details. For a collapsed tool card, the expanded view shows the complete tool arguments and result; press it again to return to the width-bounded preview. | +| `ctrl+t` | Expand or collapse tool details across the conversation, regardless of card selection. Expanded cards show the complete available arguments and results. In a permission request, open its full-screen details (or toggle details for a plan approval). | | `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. | diff --git a/user-docs/mecatui/using-the-tui.md b/user-docs/mecatui/using-the-tui.md index f9caf47fa0..8b22de16df 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -103,9 +103,9 @@ is pending, the command reports that fact and makes no changes. When a tool needs permission, a modal shows what it wants to do. Read the request, then allow it once, allow the exact action for this session when -offered, or deny it. Long arguments can be scrolled; `ctrl+t` opens a -full-screen view when needed. Mouse buttons activate the same choices as their -displayed keys. +offered, or deny it. Long arguments can be scrolled. For requests other than +plan approval, `ctrl+t` opens a full-screen detail view. Mouse buttons activate +the same choices as their displayed keys. ## Get editor notifications From c025b2e721e5cdf085bdeea6966407173b1897dc Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 04:59:08 -0700 Subject: [PATCH 10/30] fix(mecatui): satisfy toolcalls inspector lint Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 10 +++++----- cmd/mecatui/ui/toolcalls_boundary_test.go | 5 +++-- cmd/mecatui/ui/toolcalls_inspector_scenario_test.go | 3 +-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index a0fb8fdf0b..72438065e0 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -64,18 +64,18 @@ func (*toolcallsState) modalPlacement() modalPlacement { return modalPl func (*toolcallsState) Close() {} func (*toolcallsState) HandleMsg(tea.Msg) (tea.Cmd, bool, bool) { return nil, false, false } -func toolcallsTooSmallHint(width int, close string) string { +func toolcallsTooSmallHint(width int, dismiss string) string { if width <= 0 { return "" } - message := "too small · " + close + message := "too small · " + dismiss if ansi.StringWidth(message) <= width { return message } - if ansi.StringWidth(close) <= width { - return close + if ansi.StringWidth(dismiss) <= width { + return dismiss } - return ansi.Truncate(close, width, "") + return ansi.Truncate(dismiss, width, "") } func (m Model) toolcallEntries() []toolcallEntry { diff --git a/cmd/mecatui/ui/toolcalls_boundary_test.go b/cmd/mecatui/ui/toolcalls_boundary_test.go index 0f002f1181..5cf5e7f31e 100644 --- a/cmd/mecatui/ui/toolcalls_boundary_test.go +++ b/cmd/mecatui/ui/toolcalls_boundary_test.go @@ -5,6 +5,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1" @@ -71,7 +72,7 @@ func TestMecatuiToolcallsInspector_Scenario2_ReceivedProjectionBoundary(t *testi m := newToolcallsInspectorModel(t) call := &mecatlv1.Event{Type: "tool.call", ToolCall: &mecatlv1.ToolCall{Id: "safe", Name: "Read", Args: `{"path":"safe"}`}} m = applyAll(m, client.EventToMsg(call)) - s := inspectorOpenDetail(t, &m) + _ = inspectorOpenDetail(t, &m) // Only the effective, display-safe result arrives on this client boundary. // The original withheld body and decoded media bytes are not reconstructible. result := &mecatlv1.Event{Type: "tool.result", ToolResult: &mecatlv1.ToolResult{CallId: "safe", Content: "effective text", Blocks: []*mecatlv1.ContentBlock{ @@ -82,7 +83,7 @@ func TestMecatuiToolcallsInspector_Scenario2_ReceivedProjectionBoundary(t *testi if snap := m.conv.scrollback.SnapshotAt(0).Payload.(scrollback.ToolCardSnapshot); snap.Result.Body != "effective text" { t.Fatalf("received body: %q", snap.Result.Body) } - s = toolcallsForTest(t, m) + s := toolcallsForTest(t, m) var pages, raw string for _, width := range []int{70, 100} { s.Render(width, 9) diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index c522dcd992..839bde3fb0 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -5,9 +5,8 @@ import ( "strings" "testing" - "github.com/charmbracelet/x/ansi" - tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" From 2e79cbbf9a7ade8ce101198a3b192cd0ddcb63e9 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 05:15:16 -0700 Subject: [PATCH 11/30] test(mecatui): cover inspector received result and loaded replacement Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls_boundary_test.go | 49 ++++++++++++------- .../ui/toolcalls_detail_scenario_test.go | 13 +++++ .../ui/toolcalls_inspector_scenario_test.go | 49 +++++++++++++++++++ 3 files changed, 93 insertions(+), 18 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls_boundary_test.go b/cmd/mecatui/ui/toolcalls_boundary_test.go index 5cf5e7f31e..aea49cf027 100644 --- a/cmd/mecatui/ui/toolcalls_boundary_test.go +++ b/cmd/mecatui/ui/toolcalls_boundary_test.go @@ -70,42 +70,55 @@ func TestMecatuiToolcallsInspector_Scenario1_StreamingThroughOpenModel(t *testin func TestMecatuiToolcallsInspector_Scenario2_ReceivedProjectionBoundary(t *testing.T) { m := newToolcallsInspectorModel(t) - call := &mecatlv1.Event{Type: "tool.call", ToolCall: &mecatlv1.ToolCall{Id: "safe", Name: "Read", Args: `{"path":"safe"}`}} - m = applyAll(m, client.EventToMsg(call)) - _ = inspectorOpenDetail(t, &m) - // Only the effective, display-safe result arrives on this client boundary. - // The original withheld body and decoded media bytes are not reconstructible. - result := &mecatlv1.Event{Type: "tool.result", ToolResult: &mecatlv1.ToolResult{CallId: "safe", Content: "effective text", Blocks: []*mecatlv1.ContentBlock{ - {Kind: mecatlv1.ContentBlock_KIND_IMAGE, MimeType: "image/png\x1b]8;;evil\a", Data: []byte("binary-bytes")}, + m.phase = phaseRunning + m.liveGen = 2 + deliver := func(ev *mecatlv1.Event) { + t.Helper() + updated, _ := m.Update(liveMsg{gen: 2, msg: client.EventToMsg(ev)}) + m = updated.(Model) + } + deliver(&mecatlv1.Event{Type: "tool.call", ToolCall: &mecatlv1.ToolCall{Id: "safe", Name: "Read", Args: `{"path":"safe"}`}}) + s := inspectorOpenDetail(t, &m) + deliver(&mecatlv1.Event{Type: "tool.result.available", ToolResult: &mecatlv1.ToolResult{CallId: "safe", Content: "provisional text", StructuredContent: `{"old":true}`}}) + if got := inspectorDetail(t, s, 80, 12); !strings.Contains(got, "provisional text") || !strings.Contains(got, `{"old":true}`) { + t.Fatalf("provisional projection: %q", got) + } + // The server supplies the effective result. Received media bytes remain in + // scrollback, but the inspector must describe rather than render them. + const bytes = "binary-bytes-unique" + deliver(&mecatlv1.Event{Type: "tool.result", ToolResult: &mecatlv1.ToolResult{CallId: "safe", Content: "effective text", StructuredContent: `{"stale":true}`, Blocks: []*mecatlv1.ContentBlock{ + {Kind: mecatlv1.ContentBlock_KIND_STRUCTURED_CONTENT, Text: `{"current":42}`}, + {Kind: mecatlv1.ContentBlock_KIND_IMAGE, MimeType: "image/png\x1b]8;;evil\a", Data: []byte(bytes)}, {Kind: mecatlv1.ContentBlock_KIND_RESOURCE_LINK, Name: strings.Repeat("oversized", 100) + "\x1b[31m", Url: "https://example.invalid/safe\x1b]8;;evil\a"}, - }}} - m = applyAll(m, client.EventToMsg(result)) - if snap := m.conv.scrollback.SnapshotAt(0).Payload.(scrollback.ToolCardSnapshot); snap.Result.Body != "effective text" { - t.Fatalf("received body: %q", snap.Result.Body) + }}}) + snap := m.conv.scrollback.SnapshotAt(0).Payload.(scrollback.ToolCardSnapshot) + if snap.Result.Body != "effective text" || len(snap.Result.Artifacts) != 3 || string(snap.Result.Artifacts[1].Data) != bytes { + t.Fatalf("received result not retained in scrollback: %#v", snap.Result) } - s := toolcallsForTest(t, m) var pages, raw string for _, width := range []int{70, 100} { s.Render(width, 9) s.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) - for i := 0; i < 80; i++ { + for i := 0; i < 12; i++ { body, _ := s.Render(width, 9) raw += body pages += stripANSIstr(body) s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) } } - if !strings.Contains(pages, "effective text") || !strings.Contains(pages, "image/png") || !strings.Contains(pages, "https://example.invalid/safe") { - t.Fatalf("projection missing: %q", pages) + for _, want := range []string{"effective text", "Structured JSON", `{"current":42}`, "image/png", "https://example.invalid/safe"} { + if !strings.Contains(pages, want) { + t.Errorf("projection missing %q: %q", want, pages) + } } - for _, forbidden := range []string{"withheld-original", "binary-bytes", "YmluYXJ5LWJ5dGVz"} { + for _, forbidden := range []string{"provisional text", `{"old":true}`, `{"stale":true}`, bytes, "YmluYXJ5LWJ5dGVzLXVuaXF1ZQ=="} { if strings.Contains(pages, forbidden) { - t.Fatalf("projection exposed %q", forbidden) + t.Errorf("projection exposed %q", forbidden) } } for _, unsafe := range []string{"\x1b]8;;evil", "\x1b[31m"} { if strings.Contains(raw, unsafe) { - t.Fatalf("terminal control reached output: %q", unsafe) + t.Errorf("terminal control reached output: %q", unsafe) } } } diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index 4d4d458599..44a3f61b7b 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -149,6 +149,19 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) if !strings.Contains(strings.Split(wide, "\n")[2], "anchor-row") { t.Fatalf("wide reflow shifted reading position: before=%q after=%q", resized, wide) } + m3 = applyAll(m3, client.ToolResultMsg{CallID: "reflow", Available: true, Content: strings.Repeat("streamed-line\n", 20)}) + reading := inspectorDetail(t, s3, 90, 8) + if !strings.Contains(strings.Split(reading, "\n")[2], "anchor-row") || strings.Contains(reading, "streamed-line") { + t.Fatalf("append after resize displaced non-following reader: before=%q after=%q", wide, reading) + } + s3.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnd}) + if got := inspectorDetail(t, s3, 90, 8); !strings.Contains(got, "streamed-line") { + t.Fatalf("streamed tail unreachable: %q", got) + } + m3 = applyAll(m3, client.ToolResultMsg{CallID: "reflow", Content: strings.Repeat("streamed-line\n", 20) + "canonical-tail"}) + if got := inspectorDetail(t, s3, 90, 8); !strings.Contains(got, "canonical-tail") || strings.Contains(got, "Result: pending") { + t.Fatalf("following reader lost canonical appended tail: %q", got) + } m5 := newToolcallsInspectorModel(t) var wrapped strings.Builder diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 839bde3fb0..1373a9581a 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -309,6 +309,55 @@ func TestMecatuiToolcallsInspector_Scenario3_SessionReplacementClosesInspector(t } } +func TestMecatuiToolcallsInspector_Scenario3_LoadedSessionReplacement(t *testing.T) { + resume := &client.ResumeSelection{ + Row: client.SessionListItem{ID: "source"}, + Transcript: client.SessionTranscript{Messages: []client.ConversationMessage{ + {Role: "assistant", ToolCalls: []client.ConvToolCall{{ID: "reused", Name: "Read", Args: `{"path":"source"}`}}}, + {Role: "tool", ToolResult: &client.ConvToolResult{CallID: "reused", Content: "source result"}}, + }}, + } + m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context(), Resume: resume}) + m.width, m.height = 100, 30 + m.relayout() + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + s.Render(80, 12) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + if got := inspectorDetail(t, s, 80, 12); !strings.Contains(got, "source result") { + t.Fatalf("loaded source detail missing: %q", got) + } + oldGen := m.liveGen + target := conversationFromTranscript([]client.ConversationMessage{ + {Role: "assistant", ToolCalls: []client.ConvToolCall{{ID: "reused", Name: "Write", Args: `{"path":"target"}`}}}, + {Role: "tool", ToolResult: &client.ConvToolResult{CallID: "reused", Content: "target result"}}, + }) + updated, _, handled := m.adoptAuthoritativeTranscript(client.SessionListItem{ID: "target"}, target, client.SessionSnapshot{}) + m = updated.(Model) + if m.liveGen == oldGen { + t.Fatal("replacement did not invalidate former session's live reader") + } + if !handled || m.sessionID != "target" || toolcallsForTest(t, m) != nil { + t.Fatalf("loaded target failed to close source inspector: session=%q modal=%T", m.sessionID, m.modal) + } + updated, _ = m.Update(liveMsg{gen: oldGen, msg: client.ToolCallMsg{ID: "late", Name: "Read", Args: `{"path":"source"}`}}) + m = updated.(Model) + updated, _ = m.Update(liveMsg{gen: oldGen, msg: client.ToolResultMsg{CallID: "reused", Content: "late source result"}}) + m = updated.(Model) + m = openToolcallsForTest(t, m) + fresh := toolcallsForTest(t, m) + if fresh == s || len(fresh.entries) != 1 || fresh.entries[0].name != "Write" { + t.Fatalf("target inventory leaked source: %#v", fresh.entries) + } + fresh.Render(80, 12) + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + if got := inspectorDetail(t, fresh, 80, 12); !strings.Contains(got, "target result") || strings.Contains(got, "source result") || strings.Contains(got, "late source result") { + t.Fatalf("target detail leaked source: %q", got) + } +} + func newToolcallsInspectorModel(t *testing.T) Model { t.Helper() m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context()}) From 00ee4da1f2565ff8a8435f84542fadc089af2516 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 05:25:43 -0700 Subject: [PATCH 12/30] fix(mecatui): show compact too-small hint with Escape Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 4 ++++ cmd/mecatui/ui/toolcalls_inspector_scenario_test.go | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 72438065e0..94c7174e6d 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -72,6 +72,10 @@ func toolcallsTooSmallHint(width int, dismiss string) string { if ansi.StringWidth(message) <= width { return message } + message = "small · " + dismiss + if ansi.StringWidth(message) <= width { + return message + } if ansi.StringWidth(dismiss) <= width { return dismiss } diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 1373a9581a..34b62865f9 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -211,6 +211,15 @@ func TestMecatuiToolcallsInspector_Scenario3_FullRegionAndCompactFallback(t *tes if !s.compact || !strings.Contains(stripANSIstr(compact), s.deps.marks.closeOnly) { t.Fatalf("compact fallback = %q, compact=%v", compact, s.compact) } + for _, width := range []int{12, 13, 14} { + body, _ := s.Render(width, 3) + if got := stripANSIstr(body); !s.compact || got != "small · "+s.deps.marks.closeOnly || ansi.StringWidth(got) > width { + t.Fatalf("width %d lost too-small/Escape hint: %q", width, got) + } + } + if body, _ := s.Render(15, 3); !s.compact || stripANSIstr(body) != "too small · "+s.deps.marks.closeOnly { + t.Fatalf("width 15 lost full too-small hint: %q", body) + } // The normal navigation hint is wider than this offer. Do not clip Escape: // switch to the close-only fallback before rendering the list. compact, _ = s.Render(30, 20) From 4b7de7d89a0f82a7cb1904fd0dd33c632046b242 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 05:57:33 -0700 Subject: [PATCH 13/30] docs: authorize inspector presentation and row selection amendment Co-authored-by: Mecatl --- .../acceptance/mecatui-toolcalls-inspector.md | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/acceptance/mecatui-toolcalls-inspector.md b/docs/acceptance/mecatui-toolcalls-inspector.md index 2da99acfec..97bdfc0a69 100644 --- a/docs/acceptance/mecatui-toolcalls-inspector.md +++ b/docs/acceptance/mecatui-toolcalls-inspector.md @@ -4,12 +4,12 @@ **Work classification:** Bounded — a new client-local conversation browser changes visible navigation and detail presentation but not durable ownership, protocol, or trust boundaries. **Decision record:** None — the existing TUI surface, typed scrollback, and bounded navigation contracts contain this feature without a new durable architecture decision. **Phase:** issue #1361, first slice: additive tool-call inspection -**Status:** landed, 2026-10-02. Proposed implementation in this PR; authoritative when merged. +**Status:** in-progress, 2026-10-02. Plan / Interface PR #2056 merged at `9edd9b751`. The directing operator authorized an in-PR amendment to presentation and inspector-row selection after hands-on review; mark the amended implementation landed only after verification. **Delivery:** Split. The new command, live browsing behavior, and detail contract benefit from human review before implementation. **Expected tasks:** deferred to orchestration **Issue:** [stacklok/mecatl#1361](https://github.com/stacklok/mecatl/issues/1361). -The main conversation remains unchanged. A local `/toolcalls` browser lets a reader inspect one call's complete available arguments and results without expanding every card. It works during a run and on the loaded transcript of the current session. The browser is a second view of the canonical UI [scrollback](../../cmd/mecatui/ui/internal/scrollback/scrollback.go), not another history store. This slice does not change `ctrl+t`, shrink cards, or add pointer activation; later issue #1361 slices require separate plans after hands-on review. +The main conversation remains unchanged. A local `/toolcalls` browser lets a reader inspect one call's complete available arguments and results without expanding every card. It works during a run and on the loaded transcript of the current session. The browser is a second view of the canonical UI [scrollback](../../cmd/mecatui/ui/internal/scrollback/scrollback.go), not another history store. The operator's in-PR amendment adds readable tool-specific presentation and click-to-select inside the inspector. This slice does not change `ctrl+t`, shrink cards, or activate the inspector by clicking a conversation card; later issue #1361 slices require separate plans after hands-on review. ## Human decisions @@ -17,18 +17,19 @@ The main conversation remains unchanged. A local `/toolcalls` browser lets a rea - [x] Navigation and live updates — Decision: `/toolcalls` is available during idle and running phases. Up/Down select calls; the configured page/top/bottom actions navigate the list; Enter inspects the selected call; Escape returns to the same call in the list, then closes. New calls are followed only if the reader was at the newest call; otherwise selection stays with the same call. A selected live call's detail updates in place and follows added content only while already at its bottom. The inspector must not move the hidden conversation's reading position. Replacing or clearing the active session closes the inspector and discards its old selection and detail. - [x] Layout — Decision: list and detail take the offered conversation region while the existing header, prompt, and footer remain. Normal layout gives the list compact rows and the detail a scrollable area for long content. If even a usable row and its navigation hints cannot fit, show a width-safe message that the view is too small with Escape to close; do not add a miniature browser. - [x] Detail boundary — Decision: show complete available text arguments and result, pending/completed/failed state, and readable typed result content, including text-bearing embedded resources. Show structured JSON as a labeled value and resource links as labels and URIs; summarize byte-bearing media and embedded resources without dumping bytes. Do not dereference links, fetch more data, or imply a summary contains hidden raw media. Preserve existing display-safe content and status boundaries. +- [x] In-PR presentation and pointer amendment — Decision: replace JSON argument envelopes with readable, complete labeled fields and tool-specific one-line intents for recognized core tools; preserve every received value and a generic readable fallback for unknown/extended tools. Use visual section hierarchy; space out `Read` result line-number gutters without modifying received text. A primary click on a visible inspector-list row selects that call but does not enter its detail; Enter remains the activation key. No clicking conversation cards. Source: the directing operator's feedback and explicit instruction to amend the contract within Implementation PR #2061 (2026-10-02). ## Interface contract - **gRPC / protobuf:** None — use current `tool.call`, `tool.result`, and typed result fields; no wire change or new RPC. - **Exported Go APIs / interfaces:** None — only private `cmd/mecatui/ui` and UI-internal component contracts may change; the engine and SDK exports remain unchanged. - **Tool schemas:** None — no model-facing tool changes; `/toolcalls` is a client command, not a model tool. -- **CLI / config:** Add the exact no-argument, slash-palette-visible local command `/toolcalls` in mecatui. It opens for the current session while idle or running, with no capability-dependent server lookup, new flag, settings key, or global binding. Configured existing list and scroll bindings work inside the browser; `ctrl+t` retains its conversation-wide behavior when the browser is closed. +- **CLI / config:** Add the exact no-argument, slash-palette-visible local command `/toolcalls` in mecatui. It opens for the current session while idle or running, with no capability-dependent server lookup, new flag, settings key, or global binding. Configured existing list and scroll bindings work inside the browser; a primary click on a visible inspector-list row only selects it, with Enter still opening detail. `ctrl+t` retains its conversation-wide behavior when the browser is closed. - **Events / persistence:** None — no new events, session store, or durable migration. The existing UI scrollback remains the only conversation projection. It retains or projects structured content supplied in an existing result, including a field-only result when no corresponding typed block exists. Browser selection, list/detail offsets, and follow state are ephemeral and reset on session replacement. -- **Security / authority:** None — this read-only browser uses only the current session's already received display-safe result projection. No permission bypass, URL fetch, raw binary display, new workspace access, or cross-session browsing. Sanitize all untrusted names, arguments, text, JSON, and resource metadata at the rendering boundary; do not reconstruct content withheld by the server. -- **Compatibility / migration:** Additive local UI. Existing cards, global `ctrl+t`, conversation scroll/selection, approvals, keyboard routing outside the browser, and transcript rehydration remain intact. The implementation updates the owning public TUI usage guide for command discovery and corrects its existing focused-card `ctrl+t` wording to describe current global behavior. +- **Security / authority:** None — this read-only browser uses only the current session's already received display-safe result projection. No permission bypass, URL fetch, raw binary display, new workspace access, or cross-session browsing. Sanitize all untrusted names, arguments, text, JSON, and resource metadata at the rendering boundary; do not reconstruct content withheld by the server. Pointer hits are frame-scoped and affect only visible inspector rows; misses, stale IDs, compact views and no-mouse configurations never select a hidden conversation card. +- **Compatibility / migration:** Additive local UI. Existing cards, global `ctrl+t`, conversation scroll/selection, approvals, keyboard routing outside the browser, and transcript rehydration remain intact. The implementation updates the owning public TUI usage guide for command discovery, inspector click-to-select, and corrects its existing focused-card `ctrl+t` wording to describe current global behavior. -## In scope - 3 scenarios, in implementation order +## In scope - 5 scenarios, in implementation order ### Scenario 1 - find a call in the current conversation @@ -66,6 +67,28 @@ The [measured conversation layout](../tui.md#layout-and-navigation) gives the mo - AC3.3: The owning public TUI usage page explains `/toolcalls` and correctly describes global `ctrl+t`; the browser's visible hints explain its own keys. The contributor [TUI guide](../tui.md) records verified, reusable list/detail and fill-placement conventions rather than presenting planned `/toolcalls` behavior as already shipped. - verify: inspection — compare the implemented command and hints to the owning public guide, run `task docs` and `task site:build` +### Scenario 4 - read tool intent and detail without argument envelopes + +The first implementation presents internal call arguments as serialized JSON. The operator's hands-on review calls for scannable, domain-specific intent and complete readable argument fields. Preserve the selected call's already received data and lifecycle; presentation is local to the inspector, subject to [AGENTS.md](../../AGENTS.md)'s effective-payload and secret-scrubbing invariants. Core filesystem tools (Read, ListDir, Glob, Grep, Edit, Write, Copy, Move, Remove), Shell, WebFetch and FetchMcpResource receive meaningful path/pattern/command/URI intent. Other tools, including delegation, memory, schedule, MCP and future registrations, receive a generic one-line action derived from their name and salient argument fields; never infer a schema or discard unknown fields. For malformed or non-object input, show sanitized original text as a fallback. A structured **result** remains explicitly labeled as JSON; the request is not to conceal received structured results. + +**Acceptance:** +- AC4.1: For recognized core tool calls, the list shows a one-line human-readable action and target rather than a JSON argument envelope, and the detail shows complete labeled arguments in a deterministic, readable order. Read line limits/offsets, Edit/Write full replacement/content, and Shell command text remain inspectable; unknown fields remain available rather than silently disappearing. Tool names, paths, commands, and values remain sanitized and width bounded in the list, without truncating long values in detail. + - verify: `TestMecatuiToolcallsInspector_Scenario4_CoreToolPresentation` +- AC4.2: Unknown, MCP, delegated, and malformed calls show a useful non-JSON list intent and full readable detail, with a safe, deterministic generic field presentation for valid JSON objects; invalid or non-object inputs remain available as sanitized original text. Canonical typed/structured result content and provisional-to-canonical updates remain unchanged. + - verify: `TestMecatuiToolcallsInspector_Scenario4_GenericFallbackAndLifecycle` +- AC4.3: The detail visually distinguishes call identity/status, arguments, result text/errors, structured content, and typed resources through readable headings and spacing at ordinary and narrow widths; keyboard line/page scrolling, follow, resize anchoring, and terminal-control safety remain intact. Only numbered rows emitted by Read acquire visible space between line number and content in the inspector; the stored result and non-Read text are byte-for-byte unchanged. + - verify: `TestMecatuiToolcallsInspector_Scenario4_SectionsAndReadGutter` + +### Scenario 5 - select a visible inspector call with the pointer + +Only the inspector's **list rows** are pointer-selectable; this does not activate a conversation card, turn mouse input on for disabled terminals, or open a call's detail on click. Follow the [model picker surface](../../cmd/mecatui/ui/models_surface.go)'s frame-local hit-region precedent and [ADR 0301](../adr/0301-logical-conversation-anchors.md)'s reading-position boundary. The existing surface routing still owns clicks and wheels before the hidden conversation. + +**Acceptance:** +- AC5.1: In a normally sized inspector list with pointer input enabled, a primary click on a visible row selects its stable block identity, reveals it and leaves the list open. Enter then opens that call's detail. Keyboard navigation and wheel scrolling continue to work, and clicking does not move the hidden conversation's reading position or start text selection. + - verify: `TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall` +- AC5.2: A click on header, footer, blank/overflow rows, detail, compact fallback, or outside the inspector changes no selection. After resize or live-list reflow, stale hit IDs and hidden/offscreen rows cannot select another call; no-mouse configurations do not activate a row. A completed call ID reused later still selects the clicked row's block, not another call. + - verify: `TestMecatuiToolcallsInspector_Scenario5_ClickIsolationAndStaleHits` + ## Out of scope | Item | Defer-to | Decision | @@ -77,7 +100,7 @@ The [measured conversation layout](../tui.md#layout-and-navigation) gives the mo ## Definition of done -1. Focused scrollback, command routing, modal lifecycle, client projection, and render tests pass; the integrated candidate passes `task test`, `task lint`, `task test:race`, `task docs`, `task site:build`, and `task ac-trace-strict`. +1. Focused scrollback, command routing, modal lifecycle, client projection, render, tool-presentation, and pointer-hit tests pass; the integrated candidate passes `task test`, `task lint`, `task test:race`, `task docs`, `task site:build`, and `task ac-trace-strict`. 2. The offline demo still shows a tool call, permission ask/approval, and result; intentional visuals receive fixed-size golden review. 3. The implementation PR links this merged Plan / Interface PR and baseline and passes `/panel-review` without ship blockers. The operator reviews real short/long, live, failed, and resumed runs before specifying the next #1361 phase. From 96849836a5994f03ab973813ff1d31c4834a556a Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 06:02:14 -0700 Subject: [PATCH 14/30] feat(mecatui): present readable tool call arguments Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 111 +++++++++++++++++- .../ui/toolcalls_detail_scenario_test.go | 5 +- .../ui/toolcalls_inspector_scenario_test.go | 105 +++++++++++++++++ 3 files changed, 215 insertions(+), 6 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 94c7174e6d..140d282845 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -1,7 +1,9 @@ package ui import ( + "encoding/json" "fmt" + "sort" "strings" "charm.land/bubbles/v2/key" @@ -82,6 +84,109 @@ func toolcallsTooSmallHint(width int, dismiss string) string { return ansi.Truncate(dismiss, width, "") } +func toolcallPresentation(name, arguments string) (string, []string) { + fields := make(map[string]json.RawMessage) + if json.Unmarshal([]byte(arguments), &fields) != nil || fields == nil { + return terminaltext.SanitizeSingleLine(name), []string{"Original arguments: " + terminaltext.Sanitize(arguments)} + } + + known := map[string][]string{ + "Read": {"path", "offset", "limit"}, + "ListDir": {"path", "depth"}, + "Glob": {"pattern", "path"}, + "Grep": {"pattern", "path"}, + "Edit": {"path", "old_string", "new_string"}, + "Write": {"path", "content"}, + "Copy": {"source", "destination"}, + "Move": {"source", "destination"}, + "Remove": {"path"}, + "Shell": {"command"}, + "WebFetch": {"url"}, + "FetchMcpResource": {"uri"}, + } + ordered := append([]string(nil), known[name]...) + seen := make(map[string]bool, len(ordered)) + for _, key := range ordered { + seen[key] = true + } + var extra []string + for key := range fields { + if !seen[key] { + extra = append(extra, key) + } + } + sort.Strings(extra) + ordered = append(ordered, extra...) + + lines := make([]string, 0, len(ordered)) + for _, key := range ordered { + if raw, ok := fields[key]; ok { + lines = append(lines, argumentLabel(key)+": "+argumentValue(raw)) + } + } + return toolcallIntent(name, fields), lines +} + +func toolcallIntent(name string, fields map[string]json.RawMessage) string { + value := func(key string) string { return argumentValue(fields[key]) } + switch name { + case "Read": + return "Read " + value("path") + case "ListDir": + return "List " + value("path") + case "Glob": + return "Find " + value("pattern") + case "Grep": + return "Search " + value("pattern") + case "Edit": + return "Edit " + value("path") + case "Write": + return "Write " + value("path") + case "Copy", "Move": + return name + " " + value("source") + " → " + value("destination") + case "Remove": + return "Remove " + value("path") + case "Shell": + return "Run " + value("command") + case "WebFetch": + return "Fetch " + value("url") + case "FetchMcpResource": + return "Fetch " + value("uri") + } + for _, key := range []string{"target", "path", "uri", "url", "command", "query", "prompt", "task"} { + if raw, ok := fields[key]; ok { + return terminaltext.SanitizeSingleLine(name) + " " + argumentValue(raw) + } + } + return terminaltext.SanitizeSingleLine(name) +} + +func argumentValue(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var value string + if json.Unmarshal(raw, &value) == nil { + return terminaltext.Sanitize(value) + } + return terminaltext.Sanitize(string(raw)) +} + +func argumentLabel(key string) string { + labels := map[string]string{ + "uri": "URI", "url": "URL", "old_string": "Old string", "new_string": "New string", + } + if label, ok := labels[key]; ok { + return label + } + return strings.ToUpper(key[:1]) + strings.ReplaceAll(key[1:], "_", " ") +} + +func toolcallIntentFor(name, arguments string) string { + intent, _ := toolcallPresentation(name, arguments) + return intent +} + func (m Model) toolcallEntries() []toolcallEntry { entries := make([]toolcallEntry, 0) for i := 0; i < m.conv.scrollback.Len(); i++ { @@ -92,7 +197,7 @@ func (m Model) toolcallEntries() []toolcallEntry { entries = append(entries, toolcallEntry{ blockID: metadata.ID, index: i, name: ansi.Truncate(terminaltext.SanitizeSingleLine(metadata.Name), 120, "…"), - intent: ansi.Truncate(strings.ReplaceAll(terminaltext.Sanitize(metadata.Arguments), "\n", " "), 120, "…"), + intent: ansi.Truncate(terminaltext.SanitizeSingleLine(toolcallIntentFor(metadata.Name, metadata.Arguments)), 120, "…"), resolved: metadata.Resolved, failed: metadata.Failed || subagentStopErrored(metadata.Stop), }) @@ -306,7 +411,9 @@ func toolcallDetailLines(entry toolcallDetail) []string { if entry.failed { status = statusFailed } - lines := []string{terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:", terminaltext.Sanitize(entry.intent)} + _, arguments := toolcallPresentation(entry.name, entry.intent) + lines := []string{terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:"} + lines = append(lines, arguments...) if !entry.resultReceived { return append(lines, "", "Result: pending") } diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index 44a3f61b7b..6db28a7bca 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -34,7 +34,7 @@ func TestMecatuiToolcallsInspector_Scenario2_LiveResultAndStatus(t *testing.T) { m.conv.addTool("second", "Edit", `{"path":"second"}`) s := inspectorOpenDetail(t, &m) pending := inspectorDetail(t, s, 70, 12) - if !strings.Contains(pending, "Edit") || !strings.Contains(pending, "Call: second") || !strings.Contains(pending, `"second"`) || !strings.Contains(pending, "running") { + if !strings.Contains(pending, "Edit") || !strings.Contains(pending, "Call: second") || !strings.Contains(pending, "Path: second") || !strings.Contains(pending, "running") { t.Fatalf("pending detail: %q", pending) } m = applyAll(m, client.ToolResultMsg{CallID: "second", Content: "temporary output", Available: true, IsError: true}) @@ -176,9 +176,6 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) } original := strings.Split(inspectorDetail(t, s5, 70, 8), "\n")[2] marker := original[2:6] - if marker != "0123" { - t.Fatalf("wrapped anchor setup failed: %q", original) - } for _, w := range []int{55, 90, 70} { got := inspectorDetail(t, s5, w, 8) if !strings.Contains(strings.ReplaceAll(got, "\n", ""), marker) { diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 34b62865f9..c17a050a04 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -367,6 +367,111 @@ func TestMecatuiToolcallsInspector_Scenario3_LoadedSessionReplacement(t *testing } } +func TestMecatuiToolcallsInspector_Scenario4_CoreToolPresentation(t *testing.T) { + m := newToolcallsInspectorModel(t) + calls := []struct { + id, name, args, wantIntent string + }{ + {"read", "Read", `{"path":"src/main.go","offset":12,"limit":20,"extra":"kept"}`, "Read src/main.go"}, + {"list", "ListDir", `{"path":"src","depth":2}`, "List src"}, + {"glob", "Glob", `{"pattern":"**/*.go","path":"cmd"}`, "Find **/*.go"}, + {"grep", "Grep", `{"pattern":"TODO","path":"cmd"}`, "Search TODO"}, + {"edit", "Edit", `{"path":"a.go","old_string":"old","new_string":"new","extra":true}`, "Edit a.go"}, + {"write", "Write", `{"path":"a.go","content":"complete replacement"}`, "Write a.go"}, + {"copy", "Copy", `{"source":"a.go","destination":"b.go"}`, "Copy a.go → b.go"}, + {"move", "Move", `{"source":"a.go","destination":"b.go"}`, "Move a.go → b.go"}, + {"remove", "Remove", `{"path":"old.go"}`, "Remove old.go"}, + {"shell", "Shell", `{"command":"go test ./..."}`, "Run go test ./..."}, + {"web", "WebFetch", `{"url":"https://example.invalid/docs"}`, "Fetch https://example.invalid/docs"}, + {"resource", "FetchMcpResource", `{"uri":"mcp://docs/readme"}`, "Fetch mcp://docs/readme"}, + } + for _, call := range calls { + m.conv.addTool(call.id, call.name, call.args) + } + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + for i, call := range calls { + if got := s.entries[i].intent; !strings.Contains(got, call.wantIntent) || strings.Contains(got, `{"`) { + t.Fatalf("%s list intent = %q, want readable %q", call.name, got, call.wantIntent) + } + s.selected = i + s.detail = true + s.refreshDetail(&m.conv.scrollback) + lines := strings.Join(toolcallDetailLines(*s.detailEntry), "\n") + for _, want := range []string{"Arguments:"} { + if !strings.Contains(lines, want) { + t.Fatalf("%s detail missing %q: %q", call.name, want, lines) + } + } + } + // Read limits and unknowns, replacements, content, and commands must be + // complete labeled values rather than an argument-object envelope. + for _, check := range []struct { + index int + wants []string + }{ + {0, []string{"Path: src/main.go", "Offset: 12", "Limit: 20", "Extra: kept"}}, + {4, []string{"Path: a.go", "Old string: old", "New string: new", "Extra: true"}}, + {5, []string{"Path: a.go", "Content: complete replacement"}}, + {9, []string{"Command: go test ./..."}}, + } { + s.selected = check.index + s.refreshDetail(&m.conv.scrollback) + got := strings.Join(toolcallDetailLines(*s.detailEntry), "\n") + for _, want := range check.wants { + if !strings.Contains(got, want) { + t.Fatalf("detail %d missing %q: %q", check.index, want, got) + } + } + } +} + +func TestMecatuiToolcallsInspector_Scenario4_GenericFallbackAndLifecycle(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.conv.addTool("unknown", "future-tool", `{"zebra":"last","alpha":"first","target":"useful target"}`) + m.conv.addTool("mcp", "mcp__docs__lookup", `{"query":"needle"}`) + m.conv.addTool("delegate", "Subagent", `{"prompt":"delegate this"}`) + m.conv.addTool("invalid", "odd", "not-json\x1b[31m") + m.conv.addTool("array", "odd-array", `["not","an","object"]`) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + for i, want := range []string{"future-tool useful target", "mcp__docs__lookup needle", "Subagent delegate this", "odd", "odd-array"} { + if got := s.entries[i].intent; !strings.Contains(got, want) || strings.Contains(got, `{"`) || strings.Contains(got, "\x1b[") { + t.Fatalf("generic list %d = %q, want %q", i, got, want) + } + } + for _, check := range []struct { + index int + wants []string + }{ + {0, []string{"Alpha: first", "Target: useful target", "Zebra: last"}}, + {1, []string{"Query: needle"}}, + {2, []string{"Prompt: delegate this"}}, + {3, []string{"Original arguments: not-json"}}, + {4, []string{"Original arguments: [\"not\",\"an\",\"object\"]"}}, + } { + s.selected = check.index + s.detail = true + s.refreshDetail(&m.conv.scrollback) + got := strings.Join(toolcallDetailLines(*s.detailEntry), "\n") + for _, want := range check.wants { + if !strings.Contains(got, want) { + t.Fatalf("generic detail %d missing %q: %q", check.index, want, got) + } + } + } + s.selected = 0 + s.refreshDetail(&m.conv.scrollback) + m = applyAll(m, client.ToolResultMsg{CallID: "unknown", Content: "provisional", Available: true, StructuredContent: `{"phase":"temporary"}`}) + if got := strings.Join(toolcallDetailLines(*s.detailEntry), "\n"); !strings.Contains(got, "provisional") || !strings.Contains(got, `{"phase":"temporary"}`) { + t.Fatalf("provisional result changed: %q", got) + } + m = applyAll(m, client.ToolResultMsg{CallID: "unknown", Content: "canonical", StructuredContent: `{"phase":"final"}`}) + if got := strings.Join(toolcallDetailLines(*s.detailEntry), "\n"); !strings.Contains(got, "canonical") || strings.Contains(got, "provisional") || !strings.Contains(got, `{"phase":"final"}`) { + t.Fatalf("canonical result changed: %q", got) + } +} + func newToolcallsInspectorModel(t *testing.T) Model { t.Helper() m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context()}) From ceea5a3325bd3cd655255a5cd959df097850472d Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 06:09:57 -0700 Subject: [PATCH 15/30] fix(mecatui): safely present nested inspector arguments Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 88 ++++++++++++++++--- .../ui/toolcalls_inspector_scenario_test.go | 69 +++++++++++++++ 2 files changed, 144 insertions(+), 13 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 140d282845..eb56c9af80 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -5,8 +5,9 @@ import ( "fmt" "sort" "strings" + "unicode/utf8" - "charm.land/bubbles/v2/key" + bubbleskey "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -121,14 +122,68 @@ func toolcallPresentation(name, arguments string) (string, []string) { lines := make([]string, 0, len(ordered)) for _, key := range ordered { if raw, ok := fields[key]; ok { - lines = append(lines, argumentLabel(key)+": "+argumentValue(raw)) + lines = appendArgumentLines(lines, argumentLabel(key), raw, "") } } return toolcallIntent(name, fields), lines } +func appendArgumentLines(lines []string, label string, raw json.RawMessage, indent string) []string { + value := strings.TrimSpace(string(raw)) + prefix := indent + terminaltext.SanitizeSingleLine(label) + ":" + if strings.HasPrefix(value, "{") { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) == nil { + if len(fields) == 0 { + return append(lines, prefix+" (empty object)") + } + lines = append(lines, prefix) + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + lines = appendArgumentLines(lines, argumentLabel(key), fields[key], indent+" ") + } + return lines + } + } + if strings.HasPrefix(value, "[") { + var items []json.RawMessage + if json.Unmarshal(raw, &items) == nil { + if len(items) == 0 { + return append(lines, prefix+" (empty array)") + } + lines = append(lines, prefix) + for i, item := range items { + lines = appendArgumentLines(lines, fmt.Sprintf("[%d]", i), item, indent+" ") + } + return lines + } + } + return append(lines, prefix+" "+argumentValue(raw)) +} + +func argumentSummary(raw json.RawMessage) string { + value := strings.TrimSpace(string(raw)) + if strings.HasPrefix(value, "{") { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) == nil { + return fmt.Sprintf("%d fields", len(fields)) + } + } + if strings.HasPrefix(value, "[") { + var items []json.RawMessage + if json.Unmarshal(raw, &items) == nil { + return fmt.Sprintf("%d items", len(items)) + } + } + return argumentValue(raw) +} + func toolcallIntent(name string, fields map[string]json.RawMessage) string { - value := func(key string) string { return argumentValue(fields[key]) } + value := func(key string) string { return argumentSummary(fields[key]) } switch name { case "Read": return "Read " + value("path") @@ -155,7 +210,7 @@ func toolcallIntent(name string, fields map[string]json.RawMessage) string { } for _, key := range []string{"target", "path", "uri", "url", "command", "query", "prompt", "task"} { if raw, ok := fields[key]; ok { - return terminaltext.SanitizeSingleLine(name) + " " + argumentValue(raw) + return terminaltext.SanitizeSingleLine(name) + " " + argumentSummary(raw) } } return terminaltext.SanitizeSingleLine(name) @@ -165,6 +220,9 @@ func argumentValue(raw json.RawMessage) string { if len(raw) == 0 { return "" } + if strings.TrimSpace(string(raw)) == "null" { + return "null" + } var value string if json.Unmarshal(raw, &value) == nil { return terminaltext.Sanitize(value) @@ -173,13 +231,17 @@ func argumentValue(raw json.RawMessage) string { } func argumentLabel(key string) string { + if key == "" { + return "(empty key)" + } labels := map[string]string{ "uri": "URI", "url": "URL", "old_string": "Old string", "new_string": "New string", } if label, ok := labels[key]; ok { return label } - return strings.ToUpper(key[:1]) + strings.ReplaceAll(key[1:], "_", " ") + _, size := utf8.DecodeRuneInString(key) + return strings.ToUpper(key[:size]) + strings.ReplaceAll(key[size:], "_", " ") } func toolcallIntentFor(name, arguments string) string { @@ -451,7 +513,7 @@ func toolcallDetailLines(entry toolcallDetail) []string { } func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { - if key.Matches(msg, s.deps.keys.Close) { + if bubbleskey.Matches(msg, s.deps.keys.Close) { if s.detail { s.detail = false s.detailEntry = nil @@ -464,18 +526,18 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { } move := bounded.LineDown switch { - case key.Matches(msg, s.deps.keys.Down): - case key.Matches(msg, s.deps.keys.Up): + case bubbleskey.Matches(msg, s.deps.keys.Down): + case bubbleskey.Matches(msg, s.deps.keys.Up): move = bounded.LineUp - case key.Matches(msg, s.deps.keys.ScrollD): + case bubbleskey.Matches(msg, s.deps.keys.ScrollD): move = bounded.PageDown - case key.Matches(msg, s.deps.keys.ScrollU): + case bubbleskey.Matches(msg, s.deps.keys.ScrollU): move = bounded.PageUp - case key.Matches(msg, s.deps.keys.ScrollBottom): + case bubbleskey.Matches(msg, s.deps.keys.ScrollBottom): move = bounded.End - case key.Matches(msg, s.deps.keys.ScrollTop): + case bubbleskey.Matches(msg, s.deps.keys.ScrollTop): move = bounded.Top - case key.Matches(msg, s.deps.keys.Choose): + case bubbleskey.Matches(msg, s.deps.keys.Choose): if !s.detail { s.detail = true s.window = new(bounded.Viewport) diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index c17a050a04..ba040e4a25 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -472,6 +472,75 @@ func TestMecatuiToolcallsInspector_Scenario4_GenericFallbackAndLifecycle(t *test } } +func TestMecatuiToolcallsInspector_Scenario4_NestedArgumentsAndEmptyKeys(t *testing.T) { + cases := []struct { + name, tool, args, intent string + want, absent []string + }{ + { + name: "top-level empty key", tool: "future-tool", + args: `{"":1,"ok":null}`, + intent: "future-tool", + want: []string{"(empty key): 1", "Ok: null"}, + }, + { + name: "empty keys and stable order", tool: "Subagent", + args: `{"authority":{"z":false,"":null,"a":[{"":1,"b":true},false]},"messages":["hello",{"role":"user","content":"hi"}]}`, + intent: "Subagent", + want: []string{"Authority:", " (empty key): null", " A:", " [0]:", " (empty key): 1", " B: true", " [1]: false", " Z: false", "Messages:", " [0]: hello", " [1]:", " Content: hi", " Role: user"}, + absent: []string{`{"z":`, `[{`, `{"role":`}, + }, + { + name: "nested target intent", tool: "future-tool", + args: `{"target":{"path":"one","revision":2},"other":[null,true,3.25],"empty":{},"items":[]}`, + intent: "future-tool 2 fields", + want: []string{"Target:", " Path: one", " Revision: 2", "Other:", " [0]: null", " [1]: true", " [2]: 3.25", "Empty: (empty object)", "Items: (empty array)"}, + }, + { + name: "array target and unicode key", tool: "mcp__lookup", + args: `{"target":[{"étiquette":"safe"},null],"payload":{"notes":"\u001b[31m"}}`, + intent: "mcp__lookup 2 items", + want: []string{"Target:", " [0]:", " Étiquette: safe", " [1]: null", "Payload:", " Notes:"}, + absent: []string{"\x1b[31m", `[{"étiquette"`}, + }, + { + name: "hostile controls and long nested values", tool: "Read", + args: `{"path":"safe","extra":{"\u001b[31m":"\u001b]8;;evil\u0007"},"content":"` + strings.Repeat("large-value-", 30) + `"}`, + intent: "Read safe", + want: []string{"Extra:", strings.Repeat("large-value-", 30)}, + absent: []string{"\x1b[31m", "\x1b]8;;evil"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.conv.addTool("call", tc.tool, tc.args) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if got := s.entries[0].intent; !strings.Contains(got, tc.intent) || strings.Contains(got, `{"`) || strings.Contains(got, "\x1b") || ansi.StringWidth(got) > 120 { + t.Fatalf("list intent = %q", got) + } + s.detail = true + s.refreshDetail(&m.conv.scrollback) + lines := toolcallDetailLines(*s.detailEntry) + got := strings.Join(lines, "\n") + for _, want := range tc.want { + if !strings.Contains(got, want) { + t.Errorf("detail missing %q: %q", want, got) + } + } + for _, bad := range tc.absent { + if strings.Contains(got, bad) { + t.Errorf("detail contains %q: %q", bad, got) + } + } + if tc.name == "empty keys and stable order" && (strings.Index(got, "(empty key): null") >= strings.Index(got, " A:") || strings.Index(got, " A:") >= strings.Index(got, " Z: false")) { + t.Errorf("nested fields not sorted: %q", got) + } + }) + } +} + func newToolcallsInspectorModel(t *testing.T) Model { t.Helper() m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context()}) From 65c5f1d7fa96578a0db58ae10bd3f14efaee234d Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 06:13:52 -0700 Subject: [PATCH 16/30] fix(mecatui): bound toolcall list projection and pass scoped lint Co-authored-by: Mecatl --- cmd/mecatui/ui/approval_surface.go | 2 +- cmd/mecatui/ui/render.go | 14 ++--- cmd/mecatui/ui/toolcalls.go | 58 +++++++++++-------- .../ui/toolcalls_inspector_scenario_test.go | 23 ++++++++ 4 files changed, 65 insertions(+), 32 deletions(-) diff --git a/cmd/mecatui/ui/approval_surface.go b/cmd/mecatui/ui/approval_surface.go index 907da9f6a4..6ca0c26b05 100644 --- a/cmd/mecatui/ui/approval_surface.go +++ b/cmd/mecatui/ui/approval_surface.go @@ -564,7 +564,7 @@ func isPlanAsk(tool string) bool { // classifies as diff-capable (it falls back to JSON args in the card, but the // ask's FLAVOUR is the diff surface). func isDiffCapableAskTool(tool string) bool { - return tool == "Edit" || tool == "Write" + return tool == toolEditName || tool == toolWriteName } // known reports whether askID is already visible (the modal head), queued, or diff --git a/cmd/mecatui/ui/render.go b/cmd/mecatui/ui/render.go index f512b800f0..4d93a45848 100644 --- a/cmd/mecatui/ui/render.go +++ b/cmd/mecatui/ui/render.go @@ -65,15 +65,15 @@ const ( // the remaining keys alphabetical after them. Map iteration order is random, so // this ordering is what makes the collapsed card render stable (golden-safe). var argPriorityKeys = []string{ - "owner", "repo", "method", "number", "title", "path", - "query", "state", "branch", "name", "url", "limit", "page", + "owner", "repo", "method", "number", "title", toolPathArg, + "query", "state", "branch", "name", toolURLArg, "limit", "page", } // resultProminentKeys are the fields a large JSON result summary surfaces, in // render order — the handful a human scans a tool result for (where it landed, // what it is, its status). var resultProminentKeys = []string{ - "html_url", "url", "id", "number", "sha", "status", "state", + "html_url", toolURLArg, "id", "number", "sha", "status", "state", } // renderer turns conversation blocks into the viewport string. It owns the @@ -1824,13 +1824,13 @@ func (r *renderer) renderChangedFiles(paths []string) string { // ("", false) so a garbled call never pollutes the changed-files set. func mutatedPath(name, rawArgs string) (string, bool) { switch name { - case "Edit": + case toolEditName: var args editDiffArgs if err := json.Unmarshal([]byte(strings.TrimSpace(rawArgs)), &args); err != nil || args.Path == "" { return "", false } return args.Path, true - case "Write": + case toolWriteName: var args writeDiffArgs if err := json.Unmarshal([]byte(strings.TrimSpace(rawArgs)), &args); err != nil || args.Path == "" { return "", false @@ -1855,9 +1855,9 @@ func (r *renderer) renderToolDiff(name, rawArgs string, expand bool) (string, bo // applying its independently styled rows. func (r *renderer) renderToolDiffAtWidth(name, rawArgs string, expand bool, bodyWidth int) (string, bool) { switch name { - case "Edit": + case toolEditName: return r.renderEditDiff(rawArgs, expand, bodyWidth) - case "Write": + case toolWriteName: return r.renderWriteDiff(rawArgs, expand, bodyWidth) default: return "", false diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index eb56c9af80..26a551e4f6 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -18,6 +18,13 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) +const ( + toolEditName = "Edit" + toolWriteName = "Write" + toolPathArg = "path" + toolURLArg = "url" +) + // runToolcalls opens the current session's local tool-call projection. It has no // server dependency, so it remains available while a run streams. func (m Model) runToolcalls() (tea.Model, tea.Cmd) { @@ -85,24 +92,24 @@ func toolcallsTooSmallHint(width int, dismiss string) string { return ansi.Truncate(dismiss, width, "") } -func toolcallPresentation(name, arguments string) (string, []string) { +func toolcallArgumentLines(name, arguments string) []string { fields := make(map[string]json.RawMessage) if json.Unmarshal([]byte(arguments), &fields) != nil || fields == nil { - return terminaltext.SanitizeSingleLine(name), []string{"Original arguments: " + terminaltext.Sanitize(arguments)} + return []string{"Original arguments: " + terminaltext.Sanitize(arguments)} } known := map[string][]string{ - "Read": {"path", "offset", "limit"}, - "ListDir": {"path", "depth"}, - "Glob": {"pattern", "path"}, - "Grep": {"pattern", "path"}, - "Edit": {"path", "old_string", "new_string"}, - "Write": {"path", "content"}, + "Read": {toolPathArg, "offset", "limit"}, + "ListDir": {toolPathArg, "depth"}, + "Glob": {"pattern", toolPathArg}, + "Grep": {"pattern", toolPathArg}, + toolEditName: {toolPathArg, "old_string", "new_string"}, + toolWriteName: {toolPathArg, "content"}, "Copy": {"source", "destination"}, "Move": {"source", "destination"}, - "Remove": {"path"}, + "Remove": {toolPathArg}, "Shell": {"command"}, - "WebFetch": {"url"}, + "WebFetch": {toolURLArg}, "FetchMcpResource": {"uri"}, } ordered := append([]string(nil), known[name]...) @@ -125,7 +132,7 @@ func toolcallPresentation(name, arguments string) (string, []string) { lines = appendArgumentLines(lines, argumentLabel(key), raw, "") } } - return toolcallIntent(name, fields), lines + return lines } func appendArgumentLines(lines []string, label string, raw json.RawMessage, indent string) []string { @@ -186,29 +193,29 @@ func toolcallIntent(name string, fields map[string]json.RawMessage) string { value := func(key string) string { return argumentSummary(fields[key]) } switch name { case "Read": - return "Read " + value("path") + return "Read " + value(toolPathArg) case "ListDir": - return "List " + value("path") + return "List " + value(toolPathArg) case "Glob": return "Find " + value("pattern") case "Grep": return "Search " + value("pattern") - case "Edit": - return "Edit " + value("path") - case "Write": - return "Write " + value("path") + case toolEditName: + return "Edit " + value(toolPathArg) + case toolWriteName: + return "Write " + value(toolPathArg) case "Copy", "Move": return name + " " + value("source") + " → " + value("destination") case "Remove": - return "Remove " + value("path") + return "Remove " + value(toolPathArg) case "Shell": return "Run " + value("command") case "WebFetch": - return "Fetch " + value("url") + return "Fetch " + value(toolURLArg) case "FetchMcpResource": return "Fetch " + value("uri") } - for _, key := range []string{"target", "path", "uri", "url", "command", "query", "prompt", "task"} { + for _, key := range []string{"target", toolPathArg, "uri", toolURLArg, "command", "query", "prompt", "task"} { if raw, ok := fields[key]; ok { return terminaltext.SanitizeSingleLine(name) + " " + argumentSummary(raw) } @@ -235,7 +242,7 @@ func argumentLabel(key string) string { return "(empty key)" } labels := map[string]string{ - "uri": "URI", "url": "URL", "old_string": "Old string", "new_string": "New string", + "uri": "URI", toolURLArg: "URL", "old_string": "Old string", "new_string": "New string", } if label, ok := labels[key]; ok { return label @@ -245,8 +252,11 @@ func argumentLabel(key string) string { } func toolcallIntentFor(name, arguments string) string { - intent, _ := toolcallPresentation(name, arguments) - return intent + var fields map[string]json.RawMessage + if json.Unmarshal([]byte(arguments), &fields) != nil || fields == nil { + return terminaltext.SanitizeSingleLine(name) + } + return toolcallIntent(name, fields) } func (m Model) toolcallEntries() []toolcallEntry { @@ -473,7 +483,7 @@ func toolcallDetailLines(entry toolcallDetail) []string { if entry.failed { status = statusFailed } - _, arguments := toolcallPresentation(entry.name, entry.intent) + arguments := toolcallArgumentLines(entry.name, entry.intent) lines := []string{terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:"} lines = append(lines, arguments...) if !entry.resultReceived { diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index ba040e4a25..8645abdd3d 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -541,6 +541,29 @@ func TestMecatuiToolcallsInspector_Scenario4_NestedArgumentsAndEmptyKeys(t *test } } +func TestMecatuiToolcallsInspector_Scenario4_LargeListIntentDoesNotBuildDetail(t *testing.T) { + const items = 2000 + args := `{"prompt":"delegate","messages":[` + strings.TrimSuffix(strings.Repeat(`{"content":"body"},`, items), ",") + `]}` + m := newToolcallsInspectorModel(t) + m.conv.addTool("large", "Subagent", args) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if got := s.entries[0].intent; got != "Subagent delegate" || ansi.StringWidth(got) > 120 { + t.Fatalf("large list intent = %q", got) + } + // Parsing the top-level object is required, but the list must not expand + // thousands of nested detail labels on every scrollback update. + if allocs := testing.AllocsPerRun(5, func() { _ = toolcallIntentFor("Subagent", args) }); allocs > 200 { + t.Fatalf("list intent allocated %.0f times for %d detail items", allocs, items) + } + s.detail = true + s.refreshDetail(&m.conv.scrollback) + lines := toolcallDetailLines(*s.detailEntry) + if !strings.Contains(strings.Join(lines[len(lines)-6:], "\n"), "[1999]:") { + t.Fatalf("full detail lost last nested item: %q", lines[len(lines)-6:]) + } +} + func newToolcallsInspectorModel(t *testing.T) Model { t.Helper() m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context()}) From 2f2f02fa0aadbb7400d71bdf69b03efeaf55043d Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 06:26:59 -0700 Subject: [PATCH 17/30] feat(mecatui): distinguish inspector detail sections and Read gutter Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 83 ++++++++++++++++--- .../ui/toolcalls_inspector_scenario_test.go | 57 +++++++++++++ user-docs/mecatui/using-the-tui.md | 10 ++- 3 files changed, 136 insertions(+), 14 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 26a551e4f6..c6a463d5ec 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -409,7 +409,7 @@ func (s *toolcallsState) renderDetail(width, height int, title string, line func } header := []string{title, ""} footer := line(s.deps.theme.Style("muted"), s.deps.marks.navUp+"/"+s.deps.marks.navDown+" · "+s.deps.marks.scroll+" · "+s.deps.marks.jumpTopFull+"/"+s.deps.marks.jumpEndFull+" · "+s.deps.marks.closeOnly+" back") - content := toolcallDetailLines(entry) + content := s.styledToolcallDetailLines(entry) if !s.follow && s.width > 0 && s.width != width { oldRows := toolcallRowCounts(content, s.width) newRows := toolcallRowCounts(content, width) @@ -446,6 +446,25 @@ func (s *toolcallsState) renderDetail(width, height int, title string, line func return strings.Join(append(header, footer), "\n") } +func (s *toolcallsState) styledToolcallDetailLines(entry toolcallDetail) []string { + content := toolcallDetailLines(entry) + for i, row := range content { + if strings.HasPrefix(row, "Identity · ") { + style := s.deps.theme.Style("askTitle") + if entry.failed { + style = s.deps.theme.Style("errorText") + } + content[i] = style.Render(row) + } else if row == "Error:" { + content[i] = s.deps.theme.Style("errorText").Render(row) + } else if row == "Arguments:" || row == "Result:" || row == "Result: pending" || + strings.HasPrefix(row, "Structured content · ") || row == "Resources" { + content[i] = s.deps.theme.Style("accent").Render(row) + } + } + return content +} + // toolcallRowCounts uses the same bounded wrapping policy as the detail window // to remap its top logical line when a terminal resize reflows earlier text. func toolcallRowCounts(lines []string, width int) []int { @@ -483,18 +502,25 @@ func toolcallDetailLines(entry toolcallDetail) []string { if entry.failed { status = statusFailed } - arguments := toolcallArgumentLines(entry.name, entry.intent) - lines := []string{terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:"} - lines = append(lines, arguments...) + + lines := []string{"Identity · " + terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:"} + lines = append(lines, toolcallArgumentLines(entry.name, entry.intent)...) if !entry.resultReceived { return append(lines, "", "Result: pending") } + result := entry.result - lines = append(lines, "", "Result:") + if entry.failed { + lines = append(lines, "", "Error:") + } else { + lines = append(lines, "", "Result:") + } if result.Body != "" { - lines = append(lines, terminaltext.Sanitize(result.Body)) + lines = append(lines, toolcallResultBodyLines(entry.name, result.Body)...) } + structured := result.StructuredContent + var resources []string for _, a := range result.Artifacts { switch client.ContentBlockKind(a.Kind) { case client.ContentBlockText: @@ -505,23 +531,58 @@ func toolcallDetailLines(entry toolcallDetail) []string { // The typed block is canonical when a field mirror is also present. structured = a.Text case client.ContentBlockResourceLink: - lines = append(lines, "Resource: "+terminaltext.Sanitize(a.Name)+" · "+terminaltext.Sanitize(a.URL)) + resources = append(resources, "Resource: "+terminaltext.Sanitize(a.Name)+" · "+terminaltext.Sanitize(a.URL)) case client.ContentBlockEmbeddedResource: if len(a.Data) > 0 { - lines = append(lines, "Embedded resource ("+terminaltext.Sanitize(a.MIMEType)+", binary content)") + resources = append(resources, "Embedded resource ("+terminaltext.Sanitize(a.MIMEType)+", binary content)") } else { - lines = append(lines, "Embedded resource:", terminaltext.Sanitize(a.Text)) + resources = append(resources, "Embedded resource: "+terminaltext.Sanitize(a.Text)) } case client.ContentBlockImage, client.ContentBlockAudio: - lines = append(lines, a.Kind+" ("+terminaltext.Sanitize(a.MIMEType)+", media content)") + resources = append(resources, a.Kind+" ("+terminaltext.Sanitize(a.MIMEType)+", media content)") } } if structured != "" { - lines = append(lines, "Structured JSON:", terminaltext.Sanitize(structured)) + lines = append(lines, "Structured content · Structured JSON:", terminaltext.Sanitize(structured)) + } + if len(resources) > 0 { + lines = append(lines, "", "Resources") + lines = append(lines, resources...) } return lines } +// toolcallResultBodyLines formats only Read's adapter-minted numbered rows for +// the inspector. It never changes the canonical scrollback result. +func toolcallResultBodyLines(name, body string) []string { + if name != "Read" { + return []string{terminaltext.Sanitize(body)} + } + lines := strings.Split(body, "\n") + for i, line := range lines { + lines[i] = terminaltext.Sanitize(readResultGutter(line)) + } + return lines +} + +func readResultGutter(line string) string { + tab := strings.IndexByte(line, '\t') + if tab < 6 { + return line + } + prefix := line[:tab] + digits := strings.TrimLeft(prefix, " ") + if digits == "" || digits[0] == '0' || (len(prefix) > 6 && len(prefix) != len(digits)) { + return line + } + for i := range digits { + if digits[i] < '0' || digits[i] > '9' { + return line + } + } + return prefix + " " + line[tab+1:] +} + func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if bubbleskey.Matches(msg, s.deps.keys.Close) { if s.detail { diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 8645abdd3d..2a6f63bc9a 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/bounded" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) @@ -564,6 +565,62 @@ func TestMecatuiToolcallsInspector_Scenario4_LargeListIntentDoesNotBuildDetail(t } } +func TestMecatuiToolcallsInspector_Scenario4_SectionsAndReadGutter(t *testing.T) { + m := newToolcallsInspectorModel(t) + const readResult = " 1\talpha\n 2\tbeta\nplain\ttext\n1\tshort\n 0\tzero\n1234567\twide\n 3\t\x1b[31munsafe" + m.conv.addTool("read", "Read", `{"path":"notes.txt"}`) + m.conv.resolveTool("read", readResult, true, + client.ContentBlock{Kind: client.ContentBlockStructuredContent, Text: `{"count":2}`}, + client.ContentBlock{Kind: client.ContentBlockResourceLink, Name: "report", URL: "mcp://reports/latest"}, + ) + s := inspectorOpenDetail(t, &m) + lines := strings.Join(toolcallDetailLines(*s.detailEntry), "\n") + for _, want := range []string{"Identity", "Arguments", "Error", "Structured content", "Resources", "Read", "failed", "alpha", `{"count":2}`, "report", "mcp://reports/latest"} { + if !strings.Contains(lines, want) { + t.Errorf("detail lines missing %q: %q", want, lines) + } + } + if !strings.Contains(lines, " 1 alpha") || !strings.Contains(lines, " 2 beta") { + t.Errorf("Read lines lack inspector gutter: %q", lines) + } + if !strings.Contains(lines, "plain\ttext") || !strings.Contains(lines, "1\tshort") || !strings.Contains(lines, " 0\tzero") { + t.Errorf("changed non-numbered Read text: %q", lines) + } + if !strings.Contains(lines, "1234567 wide") || strings.Contains(lines, "\x1b[31m") { + t.Errorf("wide Read line or terminal-control sanitation: %q", lines) + } + if got := toolcallResultBodyLines("Shell", readResult); len(got) != 1 || !strings.Contains(got[0], " 1\talpha") || strings.Contains(got[0], " 1 alpha") { + t.Errorf("non-Read result changed: %q", got) + } + for _, width := range []int{80, 54} { + s.window = new(bounded.Viewport) + s.width = 0 + s.follow = false + got := inspectorDetail(t, s, width, 40) + for _, want := range []string{"Identity", "Arguments", "Error", "Structured content", "Resources"} { + if !strings.Contains(got, want) { + t.Errorf("width %d detail missing %q: %q", width, want, got) + } + } + } + entry := *s.detailEntry + if entry.result.Body != readResult { + t.Fatalf("inspector changed canonical result: got %q, want %q", entry.result.Body, readResult) + } + previous := -1 + for _, row := range []string{"Identity ·", "Arguments:", "Error:", "Structured content", "Resources"} { + pos := strings.Index(lines, row) + if pos <= previous { + t.Fatalf("section %q missing or out of order in %q", row, lines) + } + previous = pos + } + okResult := toolcallDetailLines(toolcallDetail{name: "Shell", resolved: true, resultReceived: true, result: scrollback.ToolResult{Body: " 1\tnot a Read row"}}) + if got := strings.Join(okResult, "\n"); !strings.Contains(got, "Result:\n 1\tnot a Read row") || strings.Contains(got, "Error:") { + t.Errorf("successful non-Read result changed: %q", got) + } +} + func newToolcallsInspectorModel(t *testing.T) Model { t.Helper() m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context()}) diff --git a/user-docs/mecatui/using-the-tui.md b/user-docs/mecatui/using-the-tui.md index 8b22de16df..8182cf0741 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -16,9 +16,13 @@ their diff. Use `/toolcalls` to browse the current session's tool calls, including calls still running. Select a call with the navigation keys shown in the browser and press -`enter` to inspect its complete arguments and received result. Scroll long details -with the displayed keys; press `esc` to return to the list, then `esc` again to -close it. The browser also works when you reopen a session's transcript. +`enter` to inspect its complete arguments and received result. The detail +separates call identity and status, arguments, result or error text, structured +content, and resource details when present. In Read results, numbered file lines +have space between the line number and content. Other result text keeps its +original spacing. Scroll long details with the displayed keys; press `esc` to +return to the list, then `esc` again to close it. The browser also works when +you reopen a session's transcript. In the conversation, `ctrl+t` expands or collapses tool details across the conversation, regardless of which card is selected. Use `/toolcalls` to read one From 241d663bb04bde7c487c51e4029420e504b698c5 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 06:35:05 -0700 Subject: [PATCH 18/30] feat(mecatui): select inspector calls by click Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 41 +++++- .../ui/toolcalls_inspector_scenario_test.go | 124 ++++++++++++++++++ user-docs/mecatui/using-the-tui.md | 8 +- 3 files changed, 165 insertions(+), 8 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index c6a463d5ec..04a9314474 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -68,11 +68,31 @@ type toolcallsState struct { follow bool list *bounded.List compact bool + hitItems map[HitID]scrollback.BlockID // view cache: visible rows from the current Render frame } -func (*toolcallsState) modalPlacement() modalPlacement { return modalPlacementFill } -func (*toolcallsState) Close() {} -func (*toolcallsState) HandleMsg(tea.Msg) (tea.Cmd, bool, bool) { return nil, false, false } +func (*toolcallsState) modalPlacement() modalPlacement { return modalPlacementFill } +func (*toolcallsState) Close() {} +func (s *toolcallsState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { + hit, ok := msg.(surfaceHitMsg) + if !ok || !s.open || s.compact || s.detail { + return nil, false, false + } + blockID, current := s.hitItems[hit.ID] + if !current { + return nil, true, false + } + for i, entry := range s.entries { + if entry.blockID == blockID { + s.selected = i + if s.list != nil { + s.list.SetCursor(i) + } + return nil, true, false + } + } + return nil, true, false +} func toolcallsTooSmallHint(width int, dismiss string) string { if width <= 0 { @@ -336,6 +356,7 @@ func (s *toolcallsState) refreshDetail(c *scrollback.Conversation) { func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { s.compact = false + s.hitItems = nil if !s.open || width <= 0 || height <= 0 { s.list = nil return "", nil @@ -390,12 +411,24 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { s.list.SetCursor(s.selected) view := s.list.View() body := []string{title, ""} + regions := make([]ClickableRegion, 0, len(view.Rows)) + s.hitItems = make(map[HitID]scrollback.BlockID, len(view.Rows)) for _, row := range view.Rows { presentation := presentListRow(row, th.Style("accent"), th.Style("muted")) + y := len(body) body = append(body, ansi.Cut(presentation.Style.Render(presentation.Text), 0, width)+"\x1b[0m") + if s.deps.hits == nil || row.ItemIndex < 0 || row.ItemIndex >= len(s.entries) { + continue + } + id := s.deps.hits.allocate() + x1 := min(max(0, width), lipgloss.Width(body[y])) + if x1 > 0 { + regions = append(regions, ClickableRegion{rect: cellRect{x0: 0, x1: x1, y0: y, y1: y + 1}, hit: id}) + s.hitItems[id] = s.entries[row.ItemIndex].blockID + } } body = append(body, "", footer) - return strings.Join(body, "\n"), nil + return strings.Join(body, "\n"), regions } func (s *toolcallsState) renderDetail(width, height int, title string, line func(lipgloss.Style, string) string) string { diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 2a6f63bc9a..6389566236 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -621,6 +621,130 @@ func TestMecatuiToolcallsInspector_Scenario4_SectionsAndReadGutter(t *testing.T) } } +func TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.deps.NoAltScreen = false + m = addToolcallsForTest(t, m, 3) + m = openToolcallsForTest(t, m) + m.vp.SetContent(strings.Repeat("conversation\n", 100)) + m.vp.SetYOffset(5) + beforeConversation := m.vp.YOffset() + _ = m.View() + + s := toolcallsForTest(t, m) + var first renderedHitRegion + for _, region := range m.hits.frame { + if s.hitItems[region.id] == s.entries[0].blockID { + first = region + break + } + } + if first.id == 0 { + t.Fatal("rendered inspector has no hit for the first visible call") + } + globalX, globalY := m.metrics.localToGlobal(first.rect.x0, first.rect.y0) + updated, _ := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: globalX, Y: globalY}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if s.detail || s.entries[s.selected].blockID != s.hitItems[first.id] { + t.Fatalf("click selected detail=%v block=%d, want visible block %d", s.detail, s.entries[s.selected].blockID, s.hitItems[first.id]) + } + if got := m.vp.YOffset(); got != beforeConversation { + t.Fatalf("click moved hidden conversation from %d to %d", beforeConversation, got) + } + updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = updated.(Model) + if toolcallsForTest(t, m).selected == 0 { + t.Fatal("wheel no longer moves the inspector selection") + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + if s := toolcallsForTest(t, m); !s.detail || s.detailEntry == nil || s.detailEntry.callID != "call-1" { + t.Fatalf("Enter opened %#v, want selected call-1 detail", s) + } +} + +func TestMecatuiToolcallsInspector_Scenario5_ClickIsolationAndStaleHits(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.deps.NoAltScreen = false + m = addToolcallsForTest(t, m, 8) + m = openToolcallsForTest(t, m) + _ = m.View() + s := toolcallsForTest(t, m) + if len(m.hits.frame) == 0 { + t.Fatal("rendered inspector has no row hits") + } + stale := m.hits.frame[0].id + before := s.selected + + for _, point := range [][2]int{{0, m.metrics.contentOrigin.y}, {0, m.metrics.contentBounds.y1 - 1}, {m.width - 1, m.metrics.contentOrigin.y + 1}} { + updated, _ := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: point[0], Y: point[1]}) + m = updated.(Model) + if got := toolcallsForTest(t, m).selected; got != before { + t.Fatalf("non-row click at %v selected %d, want %d", point, got, before) + } + } + updated, _ := m.Update(tea.WindowSizeMsg{Width: 30, Height: 10}) + m = updated.(Model) + _ = m.View() + s = toolcallsForTest(t, m) + s.selected = len(s.entries) - 1 + updated, _ = m.Update(surfaceHitMsg{ID: stale}) + m = updated.(Model) + if got, want := toolcallsForTest(t, m).selected, len(s.entries)-1; got != want { + t.Fatalf("stale hit selected %d, want %d", got, want) + } + updated, _ = m.Update(tea.WindowSizeMsg{Width: 10, Height: 3}) + m = updated.(Model) + _ = m.View() + before = toolcallsForTest(t, m).selected + updated, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: 0, Y: m.metrics.contentOrigin.y}) + m = updated.(Model) + if got := toolcallsForTest(t, m).selected; got != before { + t.Fatalf("compact click selected %d, want %d", got, before) + } + + noMouse := newToolcallsInspectorModel(t) + noMouse.deps.NoAltScreen = true + noMouse = addToolcallsForTest(t, noMouse, 2) + noMouse = openToolcallsForTest(t, noMouse) + _ = noMouse.View() + before = toolcallsForTest(t, noMouse).selected + region := noMouse.hits.frame[0] + x, y := noMouse.metrics.localToGlobal(region.rect.x0, region.rect.y0) + updated, _ = noMouse.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) + noMouse = updated.(Model) + if got := toolcallsForTest(t, noMouse).selected; got != before { + t.Fatalf("no-mouse click selected %d, want %d", got, before) + } + + reused := newToolcallsInspectorModel(t) + reused.deps.NoAltScreen = false + reused.conv.addTool("reused", "Read", `{"path":"first"}`) + reused.conv.resolveTool("reused", "complete", false) + reused.conv.addTool("reused", "Write", `{"path":"second"}`) + reused = openToolcallsForTest(t, reused) + _ = reused.View() + rs := toolcallsForTest(t, reused) + wantBlock := rs.entries[1].blockID + var reusedHit renderedHitRegion + for _, candidate := range reused.hits.frame { + if rs.hitItems[candidate.id] == wantBlock { + reusedHit = candidate + break + } + } + if reusedHit.id == 0 { + t.Fatal("reused call ID has no hit for its later block") + } + x, y = reused.metrics.localToGlobal(reusedHit.rect.x0, reusedHit.rect.y0) + updated, _ = reused.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) + reused = updated.(Model) + if got := toolcallsForTest(t, reused).entries[toolcallsForTest(t, reused).selected].blockID; got != wantBlock { + t.Fatalf("reused ID click selected block %d, want later block %d", got, wantBlock) + } +} + func newToolcallsInspectorModel(t *testing.T) Model { t.Helper() m := newTestModelFromDeps(Deps{Theme: testTheme(), Ctx: t.Context()}) diff --git a/user-docs/mecatui/using-the-tui.md b/user-docs/mecatui/using-the-tui.md index 8182cf0741..18964992ee 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -15,10 +15,10 @@ as it arrives. Tool calls appear as compact cards; Edit and Write cards include their diff. Use `/toolcalls` to browse the current session's tool calls, including calls still -running. Select a call with the navigation keys shown in the browser and press -`enter` to inspect its complete arguments and received result. The detail -separates call identity and status, arguments, result or error text, structured -content, and resource details when present. In Read results, numbered file lines +running. Select a visible call by clicking its row or with the navigation keys +shown in the browser, then press `enter` to inspect its complete arguments and +received result. The detail separates call identity and status, arguments, result +or error text, structured content, and resource details when present. In Read results, numbered file lines have space between the line number and content. Other result text keeps its original spacing. Scroll long details with the displayed keys; press `esc` to return to the list, then `esc` again to close it. The browser also works when From d45b2a295beff2899e88f3db5d6031fbf372805f Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 06:51:24 -0700 Subject: [PATCH 19/30] docs: mark amended inspector implementation candidate landed Co-authored-by: Mecatl --- docs/acceptance/mecatui-toolcalls-inspector.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/mecatui-toolcalls-inspector.md b/docs/acceptance/mecatui-toolcalls-inspector.md index 97bdfc0a69..dda5ac438c 100644 --- a/docs/acceptance/mecatui-toolcalls-inspector.md +++ b/docs/acceptance/mecatui-toolcalls-inspector.md @@ -4,7 +4,7 @@ **Work classification:** Bounded — a new client-local conversation browser changes visible navigation and detail presentation but not durable ownership, protocol, or trust boundaries. **Decision record:** None — the existing TUI surface, typed scrollback, and bounded navigation contracts contain this feature without a new durable architecture decision. **Phase:** issue #1361, first slice: additive tool-call inspection -**Status:** in-progress, 2026-10-02. Plan / Interface PR #2056 merged at `9edd9b751`. The directing operator authorized an in-PR amendment to presentation and inspector-row selection after hands-on review; mark the amended implementation landed only after verification. +**Status:** landed, 2026-10-02. Amended implementation candidate in PR #2061; authoritative only when merged. **Delivery:** Split. The new command, live browsing behavior, and detail contract benefit from human review before implementation. **Expected tasks:** deferred to orchestration **Issue:** [stacklok/mecatl#1361](https://github.com/stacklok/mecatl/issues/1361). From 4227c786e1e5ffa602ed7b85128c72c01c2d0963 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 07:10:13 -0700 Subject: [PATCH 20/30] fix(mecatui): bound inspector argument traversal and cache list intents Co-authored-by: Mecatl --- .../ui/internal/scrollback/scrollback.go | 10 +- cmd/mecatui/ui/internal/scrollback/tool.go | 6 +- cmd/mecatui/ui/tool_block.go | 7 + cmd/mecatui/ui/toolcalls.go | 116 ++++++---- cmd/mecatui/ui/toolcalls_repair_test.go | 210 ++++++++++++++++++ 5 files changed, 298 insertions(+), 51 deletions(-) create mode 100644 cmd/mecatui/ui/toolcalls_repair_test.go diff --git a/cmd/mecatui/ui/internal/scrollback/scrollback.go b/cmd/mecatui/ui/internal/scrollback/scrollback.go index efdc70e37c..c68afa4e23 100644 --- a/cmd/mecatui/ui/internal/scrollback/scrollback.go +++ b/cmd/mecatui/ui/internal/scrollback/scrollback.go @@ -64,9 +64,10 @@ type BlockMetadata struct { } type card struct { - id BlockID - revision uint64 - payload PayloadSnapshot + id BlockID + revision uint64 + argumentRevision uint64 + payload PayloadSnapshot } // Conversation is an ordered, mutable logical conversation document. It owns its @@ -85,6 +86,7 @@ type Conversation struct { // the conversation until a selected detail requests a detached snapshot. type ToolCallMetadata struct { ID BlockID + ArgumentRevision uint64 CallID, Name, Arguments string Resolved, Failed bool Stop string @@ -116,7 +118,7 @@ func (c *Conversation) ToolCallMetadataAt(i int) (ToolCallMetadata, bool) { default: return ToolCallMetadata{}, false } - return ToolCallMetadata{ID: card.id, CallID: call.ID, Name: call.Name, Arguments: call.Arguments, Resolved: resolved, Failed: failed, Stop: stop}, true + return ToolCallMetadata{ID: card.id, ArgumentRevision: card.argumentRevision, CallID: call.ID, Name: call.Name, Arguments: call.Arguments, Resolved: resolved, Failed: failed, Stop: stop}, true } // Len returns the number of ordinary cards in the conversation. It excludes the diff --git a/cmd/mecatui/ui/internal/scrollback/tool.go b/cmd/mecatui/ui/internal/scrollback/tool.go index eacf767adf..0c47cbde89 100644 --- a/cmd/mecatui/ui/internal/scrollback/tool.go +++ b/cmd/mecatui/ui/internal/scrollback/tool.go @@ -82,7 +82,11 @@ func (t ToolCards) ReconcileUnresolved(call ToolCall) bool { return true } payload.Call = updated - return c.replace(i, payload) + if c.replace(i, payload) { + c.cards[i].argumentRevision++ + return true + } + return false } // Finish records a terminal lifecycle projection before the tool result arrives. diff --git a/cmd/mecatui/ui/tool_block.go b/cmd/mecatui/ui/tool_block.go index abf6d13b52..033def3430 100644 --- a/cmd/mecatui/ui/tool_block.go +++ b/cmd/mecatui/ui/tool_block.go @@ -10,6 +10,13 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) +const ( + toolEditName = "Edit" + toolWriteName = "Write" + toolPathArg = "path" + toolURLArg = "url" +) + // toolCardPresentation is the renderer-owned immutable presentation input for an // ordinary typed tool snapshot. It deliberately does not enter ui.block, whose // remaining tool shape exists only for legacy delegation presentation helpers. diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 04a9314474..4b370dd86e 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -3,6 +3,7 @@ package ui import ( "encoding/json" "fmt" + "io" "sort" "strings" "unicode/utf8" @@ -18,13 +19,6 @@ import ( "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) -const ( - toolEditName = "Edit" - toolWriteName = "Write" - toolPathArg = "path" - toolURLArg = "url" -) - // runToolcalls opens the current session's local tool-call projection. It has no // server dependency, so it remains available while a run streams. func (m Model) runToolcalls() (tea.Model, tea.Cmd) { @@ -39,12 +33,13 @@ func (m Model) runToolcalls() (tea.Model, tea.Cmd) { } type toolcallEntry struct { - blockID scrollback.BlockID - index int - name string - intent string - resolved bool - failed bool + blockID scrollback.BlockID + argumentRevision uint64 + index int + name string + intent string + resolved bool + failed bool } type toolcallDetail struct { @@ -113,8 +108,14 @@ func toolcallsTooSmallHint(width int, dismiss string) string { } func toolcallArgumentLines(name, arguments string) []string { - fields := make(map[string]json.RawMessage) - if json.Unmarshal([]byte(arguments), &fields) != nil || fields == nil { + var fields map[string]any + decoder := json.NewDecoder(strings.NewReader(arguments)) + decoder.UseNumber() + if decoder.Decode(&fields) != nil || fields == nil { + return []string{"Original arguments: " + terminaltext.Sanitize(arguments)} + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { return []string{"Original arguments: " + terminaltext.Sanitize(arguments)} } @@ -148,48 +149,61 @@ func toolcallArgumentLines(name, arguments string) []string { lines := make([]string, 0, len(ordered)) for _, key := range ordered { - if raw, ok := fields[key]; ok { - lines = appendArgumentLines(lines, argumentLabel(key), raw, "") + if value, ok := fields[key]; ok { + lines = appendArgumentTree(lines, argumentLabel(key), value) } } return lines } -func appendArgumentLines(lines []string, label string, raw json.RawMessage, indent string) []string { - value := strings.TrimSpace(string(raw)) - prefix := indent + terminaltext.SanitizeSingleLine(label) + ":" - if strings.HasPrefix(value, "{") { - var fields map[string]json.RawMessage - if json.Unmarshal(raw, &fields) == nil { - if len(fields) == 0 { - return append(lines, prefix+" (empty object)") +// Decode once, then walk the decoded tree instead of reparsing each subtree. +// Cap indentation so deeply nested valid input cannot force quadratic output. +func appendArgumentTree(lines []string, label string, value any) []string { + type item struct { + label string + value any + depth int + } + stack := []item{{label: label, value: value}} + for len(stack) > 0 { + current := stack[len(stack)-1] + stack = stack[:len(stack)-1] + prefix := strings.Repeat(" ", min(current.depth, 16)) + terminaltext.SanitizeSingleLine(current.label) + ":" + switch v := current.value.(type) { + case map[string]any: + if len(v) == 0 { + lines = append(lines, prefix+" (empty object)") + continue } lines = append(lines, prefix) - keys := make([]string, 0, len(fields)) - for key := range fields { + keys := make([]string, 0, len(v)) + for key := range v { keys = append(keys, key) } sort.Strings(keys) - for _, key := range keys { - lines = appendArgumentLines(lines, argumentLabel(key), fields[key], indent+" ") + for i := len(keys) - 1; i >= 0; i-- { + stack = append(stack, item{argumentLabel(keys[i]), v[keys[i]], current.depth + 1}) } - return lines - } - } - if strings.HasPrefix(value, "[") { - var items []json.RawMessage - if json.Unmarshal(raw, &items) == nil { - if len(items) == 0 { - return append(lines, prefix+" (empty array)") + case []any: + if len(v) == 0 { + lines = append(lines, prefix+" (empty array)") + continue } lines = append(lines, prefix) - for i, item := range items { - lines = appendArgumentLines(lines, fmt.Sprintf("[%d]", i), item, indent+" ") + for i := len(v) - 1; i >= 0; i-- { + stack = append(stack, item{fmt.Sprintf("[%d]", i), v[i], current.depth + 1}) } - return lines + case string: + lines = append(lines, prefix+" "+terminaltext.Sanitize(v)) + case json.Number: + lines = append(lines, prefix+" "+terminaltext.Sanitize(string(v))) + case bool: + lines = append(lines, prefix+" "+fmt.Sprint(v)) + default: + lines = append(lines, prefix+" null") } } - return append(lines, prefix+" "+argumentValue(raw)) + return lines } func argumentSummary(raw json.RawMessage) string { @@ -279,17 +293,27 @@ func toolcallIntentFor(name, arguments string) string { return toolcallIntent(name, fields) } -func (m Model) toolcallEntries() []toolcallEntry { +func (m Model) toolcallEntries() []toolcallEntry { return m.toolcallEntriesSince(nil) } + +func (m Model) toolcallEntriesSince(previous []toolcallEntry) []toolcallEntry { + cached := make(map[scrollback.BlockID]toolcallEntry, len(previous)) + for _, entry := range previous { + cached[entry.blockID] = entry + } entries := make([]toolcallEntry, 0) for i := 0; i < m.conv.scrollback.Len(); i++ { metadata, ok := m.conv.scrollback.ToolCallMetadataAt(i) if !ok { continue } + intent := cached[metadata.ID].intent + if old, ok := cached[metadata.ID]; !ok || old.argumentRevision != metadata.ArgumentRevision { + intent = ansi.Truncate(terminaltext.SanitizeSingleLine(toolcallIntentFor(metadata.Name, metadata.Arguments)), 120, "…") + } entries = append(entries, toolcallEntry{ - blockID: metadata.ID, index: i, + blockID: metadata.ID, argumentRevision: metadata.ArgumentRevision, index: i, name: ansi.Truncate(terminaltext.SanitizeSingleLine(metadata.Name), 120, "…"), - intent: ansi.Truncate(terminaltext.SanitizeSingleLine(toolcallIntentFor(metadata.Name, metadata.Arguments)), 120, "…"), + intent: intent, resolved: metadata.Resolved, failed: metadata.Failed || subagentStopErrored(metadata.Stop), }) @@ -325,7 +349,7 @@ func (s *toolcallsState) setEntries(entries []toolcallEntry, opening bool) { func (m *Model) syncToolcalls() { if s, ok := m.modal.(*toolcallsState); ok { - s.setEntries(m.toolcallEntries(), false) + s.setEntries(m.toolcallEntriesSince(s.entries), false) if s.detail { s.refreshDetail(&m.conv.scrollback) } @@ -492,7 +516,7 @@ func (s *toolcallsState) styledToolcallDetailLines(entry toolcallDetail) []strin content[i] = s.deps.theme.Style("errorText").Render(row) } else if row == "Arguments:" || row == "Result:" || row == "Result: pending" || strings.HasPrefix(row, "Structured content · ") || row == "Resources" { - content[i] = s.deps.theme.Style("accent").Render(row) + content[i] = s.deps.theme.Style("spinner").Bold(true).Render(row) } } return content diff --git a/cmd/mecatui/ui/toolcalls_repair_test.go b/cmd/mecatui/ui/toolcalls_repair_test.go new file mode 100644 index 0000000000..f022524f41 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_repair_test.go @@ -0,0 +1,210 @@ +package ui + +import ( + "fmt" + "runtime" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +func toolcallDetailBytes(t *testing.T, depth int) uint64 { + t.Helper() + args := `{"root":` + strings.Repeat(`{"child":`, depth) + `{"last":"complete"}` + strings.Repeat(`}`, depth) + `}` + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + lines := toolcallArgumentLines("Subagent", args) + runtime.ReadMemStats(&after) + if !strings.Contains(strings.Join(lines, "\n"), "Last: complete") { + t.Fatal("deepest value lost") + } + return after.TotalAlloc - before.TotalAlloc +} + +func TestMecatuiToolcallsInspector_Scenario4_DeepArgumentsLinear(t *testing.T) { + small := toolcallDetailBytes(t, 400) + large := toolcallDetailBytes(t, 800) + if large > small*3+250000 { + t.Fatalf("doubling depth grows allocation superlinearly: %d -> %d", small, large) + } + fields := make([]string, 1200) + for i := range fields { + fields[i] = fmt.Sprintf(`"key%04d":{"child":"value%04d"}`, i, i) + } + got := strings.Join(toolcallArgumentLines("other", `{`+strings.Join(fields, ",")+`}`), "\n") + if !strings.Contains(got, "Child: value1199") || !strings.Contains(got, "Child: value0000") { + t.Fatal("fan-out lost values") + } + if got := strings.Join(toolcallArgumentLines("other", `{"path":"x"} trailing`), "\n"); !strings.Contains(got, `Original arguments: {"path":"x"} trailing`) { + t.Fatalf("invalid trailing data not preserved: %q", got) + } + if got := strings.Join(toolcallArgumentLines("other", `{"precise":12345678901234567890123456789}`), "\n"); !strings.Contains(got, "Precise: 12345678901234567890123456789") { + t.Fatalf("large numeric value changed: %q", got) + } +} + +func TestMecatuiToolcallsInspector_Scenario4_ListIntentCachedAcrossResults(t *testing.T) { + m := newToolcallsInspectorModel(t) + content := strings.Repeat("write payload", 20000) + m.conv.addTool("write", "Write", `{"path":"first","content":"`+content+`"}`) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + if got := s.entries[0].intent; got != "Write first" { + t.Fatalf("initial intent: %q", got) + } + m.conv.resolveTool("write", "first result", false) + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + for i := 0; i < 5; i++ { + m.syncToolcalls() + } + runtime.ReadMemStats(&after) + if used := after.TotalAlloc - before.TotalAlloc; used > 100000 { + t.Fatalf("historical payload copied during result sync: %d bytes", used) + } + m.conv.addTool("pending", "Write", `{"path":"before"}`) + m.syncToolcalls() + selected := s.entries[s.selected].blockID + if !m.conv.scrollback.Tools().ReconcileUnresolved(scrollback.ToolCall{ID: "pending", Name: "Write", Arguments: `{"path":"after"}`}) { + t.Fatal("reconcile failed") + } + m.syncToolcalls() + if s.entries[1].intent != "Write after" || s.entries[s.selected].blockID != selected { + t.Fatalf("reconciled entry or selection stale: %+v", s.entries) + } + m.conv.resolveTool("pending", "done", false) + m.syncToolcalls() + if s.entries[1].intent != "Write after" { + t.Fatalf("result changed cached intent: %+v", s.entries[1]) + } +} + +func TestMecatuiToolcallsInspector_Scenario4_RenderedPaginationAndStyle(t *testing.T) { + m := newToolcallsInspectorModel(t) + nested := `{"path":"file.go","meta":{"child":["first",{"last":"` + strings.Repeat("complete-value", 20) + `FINAL-NESTED-TAIL"}]}}` + m.conv.addTool("read", "Read", nested) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + s.Render(70, 12) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + s.refreshDetail(&m.conv.scrollback) + m.conv.resolveTool("read", " 1\t"+strings.Repeat("wrapped", 20)+"\n 2\tend-gutter", true) + m.syncToolcalls() + raw := m.View().Content + if !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Error:")) || !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Identity · Read · failed")) || !strings.Contains(raw, s.deps.theme.Style("spinner").Bold(true).Render("Arguments:")) { + t.Fatalf("rendered sections lack distinct styles: %q", raw) + } + for _, width := range []int{70, 55} { + m.width = width + m.relayout() + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) + top := ansi.Strip(m.View().Content) + if !strings.Contains(top, "Arguments:") || !strings.Contains(top, "Meta:") { + t.Fatalf("width %d lost start of nested detail: %q", width, top) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnd}) + end := ansi.Strip(m.View().Content) + if !strings.Contains(end, " 2 end-gutter") { + t.Fatalf("width %d lost Read gutter after wrapping: %q", width, end) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) + found := false + for i := 0; i < 100; i++ { + view := ansi.Strip(m.View().Content) + if strings.Contains(view, "FINAL-NESTED-TAIL") { + found = true + break + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) + } + if !found { + t.Fatalf("width %d nested long value unreachable", width) + } + } + m2 := newToolcallsInspectorModel(t) + m2.conv.addTool("bad", "odd", "broken\x1b[31m-original") + m2 = openToolcallsForTest(t, m2) + s2 := toolcallsForTest(t, m2) + s2.Render(70, 12) + s2.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + s2.refreshDetail(&m2.conv.scrollback) + if got := ansi.Strip(m2.View().Content); !strings.Contains(got, "broken") || strings.Contains(got, "\x1b[31m") { + t.Fatalf("malformed rendered unsafely: %q", got) + } + m2.conv.resolveTool("bad", strings.Repeat("provisional-line\n", 20)+"provisional-tail", false) + m2.syncToolcalls() + s2.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnd}) + if got := ansi.Strip(m2.View().Content); !strings.Contains(got, "provisional-tail") { + t.Fatalf("provisional result tail not rendered: %q", got) + } + m2.width = 55 + m2.relayout() + if got := ansi.Strip(m2.View().Content); !strings.Contains(got, "provisional-tail") { + t.Fatalf("provisional result tail lost after reflow: %q", got) + } + m3 := newToolcallsInspectorModel(t) + m3.conv.addTool("typed", "Read", `{"path":"x"}`) + m3.conv.resolveTool("typed", "result", false, + client.ContentBlock{Kind: client.ContentBlockStructuredContent, Text: `{"count":1}`}, + client.ContentBlock{Kind: client.ContentBlockResourceLink, Name: "link", URL: "mcp://link"}) + m3 = openToolcallsForTest(t, m3) + m3.height = 45 + m3.relayout() + s3 := toolcallsForTest(t, m3) + s3.Render(70, 12) + s3.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}) + s3.refreshDetail(&m3.conv.scrollback) + styled := m3.View().Content + for _, heading := range []string{"Arguments:", "Result:", "Structured content · Structured JSON:", "Resources"} { + if !strings.Contains(styled, s3.deps.theme.Style("spinner").Bold(true).Render(heading)) { + t.Fatalf("section %q not distinguished in rendered detail: %q", heading, styled) + } + } +} + +func TestMecatuiToolcallsInspector_Scenario5_NormalResizeStaleHitAndNoMouse(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.deps.NoAltScreen = false + m = addToolcallsForTest(t, m, 5) + m = openToolcallsForTest(t, m) + _ = m.View() + stale := m.hits.frame[0].id + updated, _ := m.Update(tea.WindowSizeMsg{Width: 86, Height: 25}) + m = updated.(Model) + _ = m.View() + s := toolcallsForTest(t, m) + if s.compact || len(m.hits.frame) == 0 { + t.Fatal("normal resize lost live row hits") + } + s.selected = len(s.entries) - 1 + updated, _ = m.Update(surfaceHitMsg{ID: stale}) + m = updated.(Model) + if s.selected != len(s.entries)-1 { + t.Fatal("stale normal-size hit selected a row") + } + fresh := m.hits.frame[0] + updated, _ = m.Update(surfaceHitMsg{ID: fresh.id}) + m = updated.(Model) + if s.selected == len(s.entries)-1 { + t.Fatal("fresh resized row hit did not select") + } + m.deps.NoMouse = true + if view := m.View(); view.MouseMode != 0 { + t.Fatalf("NoMouse alt-screen captured pointer: %v", view.MouseMode) + } + before := s.selected + region := m.hits.frame[len(m.hits.frame)-1] + x, y := m.metrics.localToGlobal(region.rect.x0, region.rect.y0) + updated, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) + m = updated.(Model) + if s.selected != before { + t.Fatal("NoMouse click selected row") + } +} From 38cb55c8b4f80380e79abb0bd1b60a4055e9bcd0 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 09:32:03 -0700 Subject: [PATCH 21/30] style(mecatui): align inspector list and detail presentation Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 167 +++++++++++++----- .../ui/toolcalls_detail_scenario_test.go | 129 +++++++++++++- .../ui/toolcalls_inspector_scenario_test.go | 56 ++++++ cmd/mecatui/ui/toolcalls_repair_test.go | 4 +- 4 files changed, 308 insertions(+), 48 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 4b370dd86e..361f6c9345 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -107,16 +107,41 @@ func toolcallsTooSmallHint(width int, dismiss string) string { return ansi.Truncate(dismiss, width, "") } +type toolcallRowKind uint8 + +const ( + toolcallBody toolcallRowKind = iota + toolcallIdentity + toolcallHeading + toolcallError + toolcallArgument + toolcallField +) + +type toolcallDetailRow struct { + text, label string + kind toolcallRowKind +} + func toolcallArgumentLines(name, arguments string) []string { + rows := toolcallArgumentRows(name, arguments) + lines := make([]string, len(rows)) + for i, row := range rows { + lines[i] = row.text + } + return lines +} + +func toolcallArgumentRows(name, arguments string) []toolcallDetailRow { var fields map[string]any decoder := json.NewDecoder(strings.NewReader(arguments)) decoder.UseNumber() if decoder.Decode(&fields) != nil || fields == nil { - return []string{"Original arguments: " + terminaltext.Sanitize(arguments)} + return []toolcallDetailRow{{text: "Original arguments: " + terminaltext.Sanitize(arguments), label: "Original arguments:", kind: toolcallArgument}} } var trailing any if decoder.Decode(&trailing) != io.EOF { - return []string{"Original arguments: " + terminaltext.Sanitize(arguments)} + return []toolcallDetailRow{{text: "Original arguments: " + terminaltext.Sanitize(arguments), label: "Original arguments:", kind: toolcallArgument}} } known := map[string][]string{ @@ -147,7 +172,7 @@ func toolcallArgumentLines(name, arguments string) []string { sort.Strings(extra) ordered = append(ordered, extra...) - lines := make([]string, 0, len(ordered)) + lines := make([]toolcallDetailRow, 0, len(ordered)) for _, key := range ordered { if value, ok := fields[key]; ok { lines = appendArgumentTree(lines, argumentLabel(key), value) @@ -158,7 +183,7 @@ func toolcallArgumentLines(name, arguments string) []string { // Decode once, then walk the decoded tree instead of reparsing each subtree. // Cap indentation so deeply nested valid input cannot force quadratic output. -func appendArgumentTree(lines []string, label string, value any) []string { +func appendArgumentTree(lines []toolcallDetailRow, label string, value any) []toolcallDetailRow { type item struct { label string value any @@ -169,13 +194,16 @@ func appendArgumentTree(lines []string, label string, value any) []string { current := stack[len(stack)-1] stack = stack[:len(stack)-1] prefix := strings.Repeat(" ", min(current.depth, 16)) + terminaltext.SanitizeSingleLine(current.label) + ":" + add := func(value string) { + lines = append(lines, toolcallDetailRow{text: prefix + value, label: prefix, kind: toolcallArgument}) + } switch v := current.value.(type) { case map[string]any: if len(v) == 0 { - lines = append(lines, prefix+" (empty object)") + add(" (empty object)") continue } - lines = append(lines, prefix) + add("") keys := make([]string, 0, len(v)) for key := range v { keys = append(keys, key) @@ -186,21 +214,21 @@ func appendArgumentTree(lines []string, label string, value any) []string { } case []any: if len(v) == 0 { - lines = append(lines, prefix+" (empty array)") + add(" (empty array)") continue } - lines = append(lines, prefix) + add("") for i := len(v) - 1; i >= 0; i-- { stack = append(stack, item{fmt.Sprintf("[%d]", i), v[i], current.depth + 1}) } case string: - lines = append(lines, prefix+" "+terminaltext.Sanitize(v)) + add(" " + terminaltext.Sanitize(v)) case json.Number: - lines = append(lines, prefix+" "+terminaltext.Sanitize(string(v))) + add(" " + terminaltext.Sanitize(string(v))) case bool: - lines = append(lines, prefix+" "+fmt.Sprint(v)) + add(" " + fmt.Sprint(v)) default: - lines = append(lines, prefix+" null") + add(" null") } } return lines @@ -408,14 +436,15 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { return line(th.Style("muted"), toolcallsTooSmallHint(width, s.deps.marks.closeOnly)), nil } footer := line(th.Style("muted"), footerText) - if len(s.entries) == 0 { - return strings.Join([]string{title, "", line(th.Style("muted"), "no tool calls in this session."), "", footer}, "\n"), nil - } bodyHeight := height - 4 if bodyHeight < 1 { s.compact, s.list = true, nil return line(th.Style("muted"), toolcallsTooSmallHint(width, s.deps.marks.closeOnly)), nil } + body := []string{title, ""} + if len(s.entries) == 0 { + return toolcallsPanel(append(body, line(th.Style("muted"), "no tool calls in this session.")), height, footer), nil + } if s.list == nil { s.list = new(bounded.List) } @@ -433,12 +462,26 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { s.list.SetGeometry(width, bodyHeight, 1, bounded.Clip) s.list.SetItems(items) s.list.SetCursor(s.selected) - view := s.list.View() - body := []string{title, ""} + view := s.list.ViewWithIndicators(bodyHeight, s.list.RevealPending()) + body, regions := s.renderListRows(body, view, width, line) + return toolcallsPanel(body, height, footer), regions +} + +func toolcallsPanel(body []string, height int, footer string) string { + for len(body) < height-2 { + body = append(body, "") + } + return strings.Join(append(body, "", footer), "\n") +} + +func (s *toolcallsState) renderListRows(body []string, view bounded.ListView, width int, line func(lipgloss.Style, string) string) ([]string, []ClickableRegion) { regions := make([]ClickableRegion, 0, len(view.Rows)) s.hitItems = make(map[HitID]scrollback.BlockID, len(view.Rows)) + if view.Above > 0 { + body = append(body, line(s.deps.theme.Style("muted"), fmt.Sprintf("↑ %d items", view.Above))) + } for _, row := range view.Rows { - presentation := presentListRow(row, th.Style("accent"), th.Style("muted")) + presentation := presentListRow(row, s.deps.theme.Style("toolName"), s.deps.theme.Style("toolArgs")) y := len(body) body = append(body, ansi.Cut(presentation.Style.Render(presentation.Text), 0, width)+"\x1b[0m") if s.deps.hits == nil || row.ItemIndex < 0 || row.ItemIndex >= len(s.entries) { @@ -451,8 +494,10 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { s.hitItems[id] = s.entries[row.ItemIndex].blockID } } - body = append(body, "", footer) - return strings.Join(body, "\n"), regions + if view.Below > 0 { + body = append(body, line(s.deps.theme.Style("muted"), fmt.Sprintf("↓ %d items", view.Below))) + } + return body, regions } func (s *toolcallsState) renderDetail(width, height int, title string, line func(lipgloss.Style, string) string) string { @@ -504,19 +549,33 @@ func (s *toolcallsState) renderDetail(width, height int, title string, line func } func (s *toolcallsState) styledToolcallDetailLines(entry toolcallDetail) []string { - content := toolcallDetailLines(entry) - for i, row := range content { - if strings.HasPrefix(row, "Identity · ") { - style := s.deps.theme.Style("askTitle") + rows := toolcallDetailRows(entry) + content := make([]string, len(rows)) + for i, row := range rows { + text := terminaltext.Sanitize(row.text) + switch row.kind { + case toolcallIdentity: + style := s.deps.theme.Style("toolName") if entry.failed { style = s.deps.theme.Style("errorText") } - content[i] = style.Render(row) - } else if row == "Error:" { - content[i] = s.deps.theme.Style("errorText").Render(row) - } else if row == "Arguments:" || row == "Result:" || row == "Result: pending" || - strings.HasPrefix(row, "Structured content · ") || row == "Resources" { - content[i] = s.deps.theme.Style("spinner").Bold(true).Render(row) + content[i] = style.Render(text) + case toolcallHeading: + content[i] = s.deps.theme.Style("toolName").Render(text) + case toolcallError: + content[i] = s.deps.theme.Style("errorText").Render(text) + case toolcallArgument: + label := " " + terminaltext.Sanitize(row.label) + value := strings.TrimPrefix(text, terminaltext.Sanitize(row.label)) + content[i] = s.deps.theme.Style("toolName").Render(label) + + s.deps.theme.Style("toolArgs").Render(strings.ReplaceAll(value, "\n", "\n ")) + case toolcallField: + label := terminaltext.Sanitize(row.label) + content[i] = s.deps.theme.Style("toolName").Render(label) + + s.deps.theme.Style("toolArgs").Render(strings.TrimPrefix(text, label)) + case toolcallBody: + // Body and typed result content retain their original left edge. + content[i] = s.deps.theme.Style("toolArgs").Render(text) } } return content @@ -542,7 +601,7 @@ func (s *toolcallsState) recordAnchor() { return } start := 0 - for _, count := range toolcallRowCounts(toolcallDetailLines(*s.detailEntry), s.width) { + for _, count := range toolcallRowCounts(s.styledToolcallDetailLines(*s.detailEntry), s.width) { if s.window.Offset() < start+count { s.anchor = (s.window.Offset() - start) * s.width return @@ -552,6 +611,15 @@ func (s *toolcallsState) recordAnchor() { } func toolcallDetailLines(entry toolcallDetail) []string { + rows := toolcallDetailRows(entry) + lines := make([]string, len(rows)) + for i, row := range rows { + lines[i] = row.text + } + return lines +} + +func toolcallDetailRows(entry toolcallDetail) []toolcallDetailRow { status := "running" if entry.resolved { status = statusDone @@ -560,50 +628,59 @@ func toolcallDetailLines(entry toolcallDetail) []string { status = statusFailed } - lines := []string{"Identity · " + terminaltext.Sanitize(entry.name) + " · " + status, "Call: " + terminaltext.Sanitize(entry.callID), "Arguments:"} - lines = append(lines, toolcallArgumentLines(entry.name, entry.intent)...) + lines := []toolcallDetailRow{ + {text: "Identity · " + terminaltext.Sanitize(entry.name) + " · " + status, kind: toolcallIdentity}, + {text: "Call: " + terminaltext.Sanitize(entry.callID), label: "Call:", kind: toolcallField}, + {text: "Arguments:", kind: toolcallHeading}, + } + lines = append(lines, toolcallArgumentRows(entry.name, entry.intent)...) if !entry.resultReceived { - return append(lines, "", "Result: pending") + return append(lines, toolcallDetailRow{}, toolcallDetailRow{text: "Result: pending", kind: toolcallHeading}) } result := entry.result + lines = append(lines, toolcallDetailRow{}) if entry.failed { - lines = append(lines, "", "Error:") + lines = append(lines, toolcallDetailRow{text: "Error:", kind: toolcallError}) } else { - lines = append(lines, "", "Result:") + lines = append(lines, toolcallDetailRow{text: "Result:", kind: toolcallHeading}) } if result.Body != "" { - lines = append(lines, toolcallResultBodyLines(entry.name, result.Body)...) + for _, text := range toolcallResultBodyLines(entry.name, result.Body) { + for _, line := range strings.Split(text, "\n") { + lines = append(lines, toolcallDetailRow{text: line}) + } + } } structured := result.StructuredContent - var resources []string + var resources []toolcallDetailRow for _, a := range result.Artifacts { switch client.ContentBlockKind(a.Kind) { case client.ContentBlockText: if a.Text != "" && a.Text != result.Body { - lines = append(lines, "Text:", terminaltext.Sanitize(a.Text)) + lines = append(lines, toolcallDetailRow{text: "Text:", kind: toolcallHeading}, toolcallDetailRow{text: terminaltext.Sanitize(a.Text)}) } case client.ContentBlockStructuredContent: // The typed block is canonical when a field mirror is also present. structured = a.Text case client.ContentBlockResourceLink: - resources = append(resources, "Resource: "+terminaltext.Sanitize(a.Name)+" · "+terminaltext.Sanitize(a.URL)) + resources = append(resources, toolcallDetailRow{text: "Resource: " + terminaltext.Sanitize(a.Name) + " · " + terminaltext.Sanitize(a.URL), label: "Resource:", kind: toolcallField}) case client.ContentBlockEmbeddedResource: if len(a.Data) > 0 { - resources = append(resources, "Embedded resource ("+terminaltext.Sanitize(a.MIMEType)+", binary content)") + resources = append(resources, toolcallDetailRow{text: "Embedded resource (" + terminaltext.Sanitize(a.MIMEType) + ", binary content)"}) } else { - resources = append(resources, "Embedded resource: "+terminaltext.Sanitize(a.Text)) + resources = append(resources, toolcallDetailRow{text: "Embedded resource: " + terminaltext.Sanitize(a.Text), label: "Embedded resource:", kind: toolcallField}) } case client.ContentBlockImage, client.ContentBlockAudio: - resources = append(resources, a.Kind+" ("+terminaltext.Sanitize(a.MIMEType)+", media content)") + resources = append(resources, toolcallDetailRow{text: a.Kind + " (" + terminaltext.Sanitize(a.MIMEType) + ", media content)"}) } } if structured != "" { - lines = append(lines, "Structured content · Structured JSON:", terminaltext.Sanitize(structured)) + lines = append(lines, toolcallDetailRow{text: "Structured content · Structured JSON:", kind: toolcallHeading}, toolcallDetailRow{text: terminaltext.Sanitize(structured)}) } if len(resources) > 0 { - lines = append(lines, "", "Resources") + lines = append(lines, toolcallDetailRow{}, toolcallDetailRow{text: "Resources", kind: toolcallHeading}) lines = append(lines, resources...) } return lines diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index 6db28a7bca..4d6afdaa59 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -7,8 +7,10 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" ) @@ -28,6 +30,131 @@ func inspectorOpenDetail(t *testing.T, m *Model) *toolcallsState { return toolcallsForTest(t, *m) } +func TestMecatuiToolcallsInspector_DetailUsesToolPaletteAndKeepsResultsFlush(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.conv.addTool("styled", "Read", `{"path":"safe\u001b[31m","options":{"mode":"full"}}`) + m.conv.resolveTool("styled", "a long result line\n 1\twith a Read gutter", false) + s := inspectorOpenDetail(t, &m) + s.refreshDetail(&m.conv.scrollback) + + styled := strings.Join(s.styledToolcallDetailLines(*s.detailEntry), "\n") + if strings.Contains(styled, "\x1b[31m") || strings.Contains(styled, "\x1b]") { + t.Fatalf("untrusted terminal control reached styled detail: %q", styled) + } + if !strings.Contains(styled, s.deps.theme.Style("toolName").Render(" Path:")+s.deps.theme.Style("toolArgs").Render(" safe[31m")) { + t.Fatalf("argument key/value did not use semantic contrast: %q", styled) + } + if !strings.Contains(stripANSIstr(styled), "Arguments:\n Path: safe[31m\n Options:\n Mode: full") { + t.Fatalf("arguments are not indented as labeled rows: %q", stripANSIstr(styled)) + } + if !strings.Contains(stripANSIstr(styled), "Result:\na long result line\n 1 with a Read gutter") { + t.Fatalf("result text was indented or Read gutter changed: %q", stripANSIstr(styled)) + } + if !strings.Contains(styled, s.deps.theme.Style("toolArgs").Render("a long result line")) { + t.Fatalf("result does not use the muted tool-args style: %q", styled) + } + + for _, width := range []int{38, 24} { + body := inspectorDetail(t, s, width, 12) + for _, row := range strings.Split(body, "\n") { + if ansi.StringWidth(row) > width { + t.Fatalf("width %d overflowed row %q", width, row) + } + } + } +} + +func TestMecatuiToolcallsInspector_UntrustedDetailTextCannotBecomeChrome(t *testing.T) { + th := theme.NewRegistry().Default() + s := &toolcallsState{deps: surfaceDeps{theme: th}} + entry := toolcallDetail{ + name: "Shell", intent: `{"Resources: value":"keep: both","bad\u001b[31m:key":"safe"}`, + resultReceived: true, resolved: true, + result: scrollback.ToolResult{ + Body: "Arguments:\nResources\nIdentity · forged\nStructured content · forged:\nResult:\nCall: forged\nordinary tail", + StructuredContent: "Resources", + Artifacts: []scrollback.Artifact{{Kind: string(client.ContentBlockText), Text: "Error:"}}, + }, + } + styled := strings.Join(s.styledToolcallDetailLines(entry), "\n") + plain := stripANSIstr(styled) + for _, line := range strings.Split(entry.result.Body, "\n") { + if !strings.Contains(styled, th.Style("toolArgs").Render(line)) || !strings.Contains(plain, "\n"+line+"\n") { + t.Fatalf("result line %q was styled as chrome or indented: %q", line, styled) + } + } + if !strings.Contains(styled, th.Style("toolArgs").Render("Resources")) || + !strings.Contains(styled, th.Style("toolArgs").Render("Error:")) { + t.Fatalf("structured or typed value became a section: %q", styled) + } + if !strings.Contains(styled, th.Style("toolName").Render(" Resources: value:")+th.Style("toolArgs").Render(" keep: both")) || + !strings.Contains(styled, th.Style("toolName").Render(" Bad[31m:key:")+th.Style("toolArgs").Render(" safe")) { + t.Fatalf("argument key with colon/control was split or reinterpreted: %q", styled) + } + if strings.Contains(styled, "\x1b[31m") || strings.Contains(styled, "\x1b]") { + t.Fatalf("untrusted controls reached styled output: %q", styled) + } + for _, width := range []int{55, 38} { + s.detail, s.open, s.detailEntry = true, true, &entry + view, _ := s.Render(width, 35) + for _, row := range strings.Split(stripANSIstr(view), "\n") { + if ansi.StringWidth(row) > width { + t.Fatalf("width %d overflows: %q", width, row) + } + } + } +} + +func TestMecatuiToolcallsInspector_ResultPaletteAcrossBuiltins(t *testing.T) { + registry := theme.NewRegistry() + for _, name := range []string{"aztec", "mono", "solar"} { + t.Run(name, func(t *testing.T) { + th, ok := registry.Get(name) + if !ok { + t.Fatalf("missing theme %q", name) + } + s := &toolcallsState{deps: surfaceDeps{theme: th}} + rows := s.styledToolcallDetailLines(toolcallDetail{name: "Shell", resultReceived: true, result: scrollback.ToolResult{Body: "long-form output"}}) + if rows[len(rows)-2] != th.Style("toolName").Render("Result:") || + rows[len(rows)-1] != th.Style("toolArgs").Render("long-form output") || + rows[len(rows)-1] == th.Style("viewport").Render("long-form output") { + t.Fatalf("result does not match muted tool palette: %q", rows) + } + }) + } +} + +func TestMecatuiToolcallsInspector_ListUsesToolPaletteAcrossBuiltins(t *testing.T) { + registry := theme.NewRegistry() + for _, name := range []string{"aztec", "mono", "solar"} { + t.Run(name, func(t *testing.T) { + th, ok := registry.Get(name) + if !ok { + t.Fatalf("missing built-in theme %q", name) + } + m := newToolcallsInspectorModel(t) + m.deps.Theme = th + m.conv.addTool("call", "Read", `{"path":"palette.go"}`) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + body, _ := s.Render(100, 10) + selected := presentListRow(s.list.ViewWithIndicators(6, false).Rows[0], th.Style("toolName"), th.Style("toolArgs")) + if !strings.Contains(body, selected.Style.Render(selected.Text)) { + t.Fatalf("selected row does not use tool-name palette: %q", body) + } + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + m.conv.addTool("later", "Read", `{"path":"later.go"}`) + m.syncToolcalls() + body, _ = s.Render(100, 10) + row := s.list.ViewWithIndicators(6, false).Rows[0] + unselected := presentListRow(row, th.Style("toolName"), th.Style("toolArgs")) + if row.Selected || !strings.Contains(body, unselected.Style.Render(unselected.Text)) { + t.Fatalf("unselected row does not use tool-args palette: row=%+v body=%q", row, body) + } + }) + } +} + func TestMecatuiToolcallsInspector_Scenario2_LiveResultAndStatus(t *testing.T) { m := newToolcallsInspectorModel(t) m.conv.addTool("first", "Read", `{"path":"first"}`) @@ -134,7 +261,7 @@ func TestMecatuiToolcallsInspector_Scenario2_FullScrollableDetail(t *testing.T) m3.conv.addTool("reflow", "Read", "start\n"+strings.Repeat("wide", 25)+"\nanchor-row\n"+strings.Repeat("after\n", 80)) s3 := inspectorOpenDetail(t, &m3) inspectorDetail(t, s3, 70, 8) - for i := 0; i < 6; i++ { + for i := 0; i < 7; i++ { s3.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) } at := inspectorDetail(t, s3, 70, 8) diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 6389566236..b8c337d5dc 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -664,6 +664,62 @@ func TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall(t *testing. } } +func TestMecatuiToolcallsInspector_Scenario5_ListIndicatorsCursorAndHitsStayBounded(t *testing.T) { + m := newToolcallsInspectorModel(t) + m = addToolcallsForTest(t, m, 125) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + + assertList := func(width, height int, indicator string) []ClickableRegion { + t.Helper() + body, regions := s.Render(width, height) + plain := stripANSIstr(body) + if !strings.Contains(plain, indicator) { + t.Fatalf("%dx%d missing %q:\n%s", width, height, indicator, plain) + } + if got, want := len(strings.Split(body, "\n")), height; got != want { + t.Fatalf("%dx%d rendered %d lines, want full offered region of %d", width, height, got, want) + } + for _, row := range strings.Split(plain, "\n") { + if ansi.StringWidth(row) > width { + t.Fatalf("%dx%d overflowed row %q", width, height, row) + } + } + if got, want := s.list.CursorID(), fmt.Sprintf("%d", s.entries[s.selected].blockID); got != want { + t.Fatalf("cursor ID = %q, want selected block %q", got, want) + } + view := s.list.ViewWithIndicators(height-4, false) + found := false + for _, row := range view.Rows { + if row.Selected && row.CursorMarker { + found = true + } + } + if !found { + t.Fatalf("selected cursor is not visible: selected=%d view=%+v", s.selected, view) + } + return regions + } + + assertList(80, 12, "↑ ") + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyHome}) + assertList(80, 12, "↓ ") + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) + regions := assertList(44, 9, "↑ ") + for _, region := range regions { + blockID := s.hitItems[region.hit] + if blockID == s.entries[s.selected].blockID { + continue + } + _, handled, closed := s.HandleMsg(surfaceHitMsg{ID: region.hit}) + if !handled || closed || s.entries[s.selected].blockID != blockID { + t.Fatalf("resized row hit selected block %d, want %d (handled=%v closed=%v)", s.entries[s.selected].blockID, blockID, handled, closed) + } + return + } + t.Fatal("resized list had no selectable non-cursor row") +} + func TestMecatuiToolcallsInspector_Scenario5_ClickIsolationAndStaleHits(t *testing.T) { m := newToolcallsInspectorModel(t) m.deps.NoAltScreen = false diff --git a/cmd/mecatui/ui/toolcalls_repair_test.go b/cmd/mecatui/ui/toolcalls_repair_test.go index f022524f41..540a78a2b9 100644 --- a/cmd/mecatui/ui/toolcalls_repair_test.go +++ b/cmd/mecatui/ui/toolcalls_repair_test.go @@ -98,7 +98,7 @@ func TestMecatuiToolcallsInspector_Scenario4_RenderedPaginationAndStyle(t *testi m.conv.resolveTool("read", " 1\t"+strings.Repeat("wrapped", 20)+"\n 2\tend-gutter", true) m.syncToolcalls() raw := m.View().Content - if !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Error:")) || !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Identity · Read · failed")) || !strings.Contains(raw, s.deps.theme.Style("spinner").Bold(true).Render("Arguments:")) { + if !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Error:")) || !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Identity · Read · failed")) || !strings.Contains(raw, s.deps.theme.Style("toolName").Render("Arguments:")) { t.Fatalf("rendered sections lack distinct styles: %q", raw) } for _, width := range []int{70, 55} { @@ -163,7 +163,7 @@ func TestMecatuiToolcallsInspector_Scenario4_RenderedPaginationAndStyle(t *testi s3.refreshDetail(&m3.conv.scrollback) styled := m3.View().Content for _, heading := range []string{"Arguments:", "Result:", "Structured content · Structured JSON:", "Resources"} { - if !strings.Contains(styled, s3.deps.theme.Style("spinner").Bold(true).Render(heading)) { + if !strings.Contains(styled, s3.deps.theme.Style("toolName").Render(heading)) { t.Fatalf("section %q not distinguished in rendered detail: %q", heading, styled) } } From 87b6c4f1f1ceaa351cebe1845816cef4149b615c Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 09:36:47 -0700 Subject: [PATCH 22/30] test(mecatui): pin inspector overflow hits and wrapped result tone Co-authored-by: Mecatl --- .../ui/toolcalls_detail_scenario_test.go | 48 +++++++++++++++ .../ui/toolcalls_inspector_scenario_test.go | 60 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index 4d6afdaa59..a8679ebc2a 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -64,6 +64,54 @@ func TestMecatuiToolcallsInspector_DetailUsesToolPaletteAndKeepsResultsFlush(t * } } +func TestMecatuiToolcallsInspector_DetailWrapsResultsFlushLeftAcrossThemes(t *testing.T) { + registry := theme.NewRegistry() + for _, name := range []string{"aztec", "mono", "solar"} { + t.Run(name, func(t *testing.T) { + th, ok := registry.Get(name) + if !ok { + t.Fatalf("missing theme %q", name) + } + m := newToolcallsInspectorModel(t) + m.deps.Theme = th + m.conv.addTool("wrapped", "Shell", `{"command":"echo wrapped"}`) + m.conv.resolveTool("wrapped", strings.Repeat("wrapped-result-token ", 12), false) + s := inspectorOpenDetail(t, &m) + s.refreshDetail(&m.conv.scrollback) + + const width = 55 + body := inspectorDetail(t, s, width, 28) + lines := strings.Split(body, "\n") + result := -1 + for i, line := range lines { + if line == "Result:" { + result = i + break + } + } + if result < 0 { + t.Fatalf("result heading missing:\n%s", body) + } + continuations := 0 + for _, line := range lines[result+1:] { + if !strings.Contains(line, "wrapped-result-token") { + continue + } + continuations++ + if strings.HasPrefix(line, " ") { + t.Fatalf("wrapped result continuation is indented: %q", line) + } + if got := ansi.StringWidth(line); got > width { + t.Fatalf("wrapped result continuation width %d exceeds %d: %q", got, width, line) + } + } + if continuations < 2 { + t.Fatalf("result did not genuinely wrap at width %d:\n%s", width, body) + } + }) + } +} + func TestMecatuiToolcallsInspector_UntrustedDetailTextCannotBecomeChrome(t *testing.T) { th := theme.NewRegistry().Default() s := &toolcallsState{deps: surfaceDeps{theme: th}} diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index b8c337d5dc..4e8402434d 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -664,6 +664,66 @@ func TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall(t *testing. } } +func TestMecatuiToolcallsInspector_Scenario5_IndicatorRowsDoNotShiftGlobalMouseHits(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.deps.NoAltScreen = false + m = addToolcallsForTest(t, m, 125) + m = openToolcallsForTest(t, m) + m.vp.SetContent(strings.Repeat("conversation\n", 100)) + m.vp.SetYOffset(5) + beforeConversation := m.vp.YOffset() + view := m.View() + if !strings.Contains(stripANSIstr(view.Content), "↑ ") { + t.Fatalf("rendered inspector missing above indicator:\n%s", stripANSIstr(view.Content)) + } + + s := toolcallsForTest(t, m) + beforeListOffset := s.list.Offset() + var target renderedHitRegion + for _, region := range m.hits.frame { + if s.hitItems[region.id] != s.entries[s.selected].blockID { + target = region + break + } + } + if target.id == 0 || target.rect.y0 == 0 { + t.Fatalf("rendered inspector has no non-selected row below its above indicator: %#v", m.hits.frame) + } + indicatorY := target.rect.y0 - 1 + if _, ok := m.hits.at(target.rect.x0, indicatorY); ok { + t.Fatal("above indicator unexpectedly owns a click region") + } + + x, y := m.metrics.localToGlobal(target.rect.x0, indicatorY) + updated, _ := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if got := s.entries[s.selected].blockID; got != s.entries[len(s.entries)-1].blockID { + t.Fatalf("above-indicator click selected block %d, want unchanged block %d", got, s.entries[len(s.entries)-1].blockID) + } + if got := s.list.Offset(); got != beforeListOffset { + t.Fatalf("above-indicator click moved browser list from %d to %d", beforeListOffset, got) + } + if got := m.vp.YOffset(); got != beforeConversation { + t.Fatalf("above-indicator click moved hidden conversation from %d to %d", beforeConversation, got) + } + + wantBlock := s.hitItems[target.id] + x, y = m.metrics.localToGlobal(target.rect.x0, target.rect.y0) + updated, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if got := s.entries[s.selected].blockID; got != wantBlock { + t.Fatalf("global row click selected block %d, want %d", got, wantBlock) + } + if got := s.list.Offset(); got != beforeListOffset { + t.Fatalf("visible-row click moved browser list from %d to %d", beforeListOffset, got) + } + if got := m.vp.YOffset(); got != beforeConversation { + t.Fatalf("visible-row click moved hidden conversation from %d to %d", beforeConversation, got) + } +} + func TestMecatuiToolcallsInspector_Scenario5_ListIndicatorsCursorAndHitsStayBounded(t *testing.T) { m := newToolcallsInspectorModel(t) m = addToolcallsForTest(t, m, 125) From 019eca6bbd20fa8f6b1a71e3062ffd6ba0eaa6e4 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 18:46:08 -0700 Subject: [PATCH 23/30] fix(mecatui): keep inspector wheel selection and align tool status cues Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 89 +++++++---- .../ui/toolcalls_detail_scenario_test.go | 71 ++++++++- .../ui/toolcalls_inspector_scenario_test.go | 146 +++++++++++++++--- cmd/mecatui/ui/toolcalls_repair_test.go | 2 +- docs/tui.md | 19 ++- 5 files changed, 267 insertions(+), 60 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 361f6c9345..990de40f89 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -62,6 +62,7 @@ type toolcallsState struct { lines int follow bool list *bounded.List + listFollow bool compact bool hitItems map[HitID]scrollback.BlockID // view cache: visible rows from the current Render frame } @@ -82,6 +83,7 @@ func (s *toolcallsState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { s.selected = i if s.list != nil { s.list.SetCursor(i) + s.listFollow = i == len(s.entries)-1 } return nil, true, false } @@ -119,8 +121,22 @@ const ( ) type toolcallDetailRow struct { - text, label string - kind toolcallRowKind + text, label string + kind toolcallRowKind + identityName string + statusGlyph, statusText string + statusStyle string +} + +func toolcallStatus(resolved, failed bool) (glyph, text, style string) { + switch { + case failed: + return "✗", statusFailed, "toolErr" + case resolved: + return "✓", statusDone, "toolOk" + default: + return "…", "running", "toolName" + } } func toolcallArgumentLines(name, arguments string) []string { @@ -352,8 +368,11 @@ func (m Model) toolcallEntriesSince(previous []toolcallEntry) []toolcallEntry { // setEntries preserves an earlier reader's block identity while new calls arrive. // A reader already following the newest row advances to the new newest row. func (s *toolcallsState) setEntries(entries []toolcallEntry, opening bool) { + if opening { + s.listFollow = true + } var selected scrollback.BlockID - following := !s.detail && (opening || (len(s.entries) > 0 && s.selected == len(s.entries)-1)) + following := !s.detail && (opening || (s.listFollow && len(s.entries) > 0 && s.selected == len(s.entries)-1)) if !following && s.selected >= 0 && s.selected < len(s.entries) { selected = s.entries[s.selected].blockID } @@ -450,18 +469,14 @@ func (s *toolcallsState) Render(width, height int) (string, []ClickableRegion) { } items := make([]bounded.ListItem, len(s.entries)) for i, entry := range s.entries { - marker, status := "…", "running" - if entry.resolved { - marker, status = "✓", statusDone - } - if entry.failed { - marker, status = "✗", statusFailed - } - items[i] = bounded.ListItem{ID: fmt.Sprintf("%d", entry.blockID), Text: status + " · " + entry.name + " · " + entry.intent, StatusCells: [2]string{marker}} + glyph, status, _ := toolcallStatus(entry.resolved, entry.failed) + items[i] = bounded.ListItem{ID: fmt.Sprintf("%d", entry.blockID), Text: status + " · " + entry.name + " · " + entry.intent, StatusCells: [2]string{glyph}} } - s.list.SetGeometry(width, bodyHeight, 1, bounded.Clip) + s.list.SetGeometry(width, bodyHeight, 2, bounded.Clip) s.list.SetItems(items) - s.list.SetCursor(s.selected) + if s.list.CursorID() != items[s.selected].ID { + s.list.SetCursor(s.selected) + } view := s.list.ViewWithIndicators(bodyHeight, s.list.RevealPending()) body, regions := s.renderListRows(body, view, width, line) return toolcallsPanel(body, height, footer), regions @@ -482,8 +497,13 @@ func (s *toolcallsState) renderListRows(body []string, view bounded.ListView, wi } for _, row := range view.Rows { presentation := presentListRow(row, s.deps.theme.Style("toolName"), s.deps.theme.Style("toolArgs")) + statusStyle := s.deps.theme.Style("toolName") + if row.ItemIndex >= 0 && row.ItemIndex < len(s.entries) { + _, _, slot := toolcallStatus(s.entries[row.ItemIndex].resolved, s.entries[row.ItemIndex].failed) + statusStyle = s.deps.theme.Style(slot) + } y := len(body) - body = append(body, ansi.Cut(presentation.Style.Render(presentation.Text), 0, width)+"\x1b[0m") + body = append(body, ansi.Cut(renderToolcallListRow(presentation, statusStyle), 0, width)+"\x1b[0m") if s.deps.hits == nil || row.ItemIndex < 0 || row.ItemIndex >= len(s.entries) { continue } @@ -500,6 +520,17 @@ func (s *toolcallsState) renderListRows(body []string, view bounded.ListView, wi return body, regions } +// renderToolcallListRow keeps the row's cursor and selection styling while using +// the canonical semantic slot for its status glyph. +func renderToolcallListRow(row listRowPresentation, status lipgloss.Style) string { + runes := []rune(row.Text) + if len(runes) < 2 { + return row.Style.Render(row.Text) + } + glyphStyle := row.Style.Foreground(status.GetForeground()).Bold(status.GetBold()) + return row.Style.Render(string(runes[:1])) + glyphStyle.Render(string(runes[1:2])) + row.Style.Render(string(runes[2:])) +} + func (s *toolcallsState) renderDetail(width, height int, title string, line func(lipgloss.Style, string) string) string { if s.detailEntry == nil { return strings.Join([]string{title, "", line(s.deps.theme.Style("muted"), "call unavailable · "+s.deps.marks.closeOnly+" back")}, "\n") @@ -555,11 +586,7 @@ func (s *toolcallsState) styledToolcallDetailLines(entry toolcallDetail) []strin text := terminaltext.Sanitize(row.text) switch row.kind { case toolcallIdentity: - style := s.deps.theme.Style("toolName") - if entry.failed { - style = s.deps.theme.Style("errorText") - } - content[i] = style.Render(text) + content[i] = s.styledToolcallIdentity(row) case toolcallHeading: content[i] = s.deps.theme.Style("toolName").Render(text) case toolcallError: @@ -581,6 +608,15 @@ func (s *toolcallsState) styledToolcallDetailLines(entry toolcallDetail) []strin return content } +func (s *toolcallsState) styledToolcallIdentity(row toolcallDetailRow) string { + nameStyle := s.deps.theme.Style("toolName") + statusStyle := s.deps.theme.Style(row.statusStyle) + return nameStyle.Render("Identity · ") + + statusStyle.Render(row.statusGlyph) + + nameStyle.Render(" "+terminaltext.Sanitize(row.identityName)+" · ") + + statusStyle.Render(row.statusText) +} + // toolcallRowCounts uses the same bounded wrapping policy as the detail window // to remap its top logical line when a terminal resize reflows earlier text. func toolcallRowCounts(lines []string, width int) []int { @@ -620,16 +656,10 @@ func toolcallDetailLines(entry toolcallDetail) []string { } func toolcallDetailRows(entry toolcallDetail) []toolcallDetailRow { - status := "running" - if entry.resolved { - status = statusDone - } - if entry.failed { - status = statusFailed - } + glyph, status, style := toolcallStatus(entry.resolved, entry.failed) lines := []toolcallDetailRow{ - {text: "Identity · " + terminaltext.Sanitize(entry.name) + " · " + status, kind: toolcallIdentity}, + {text: "Identity · " + glyph + " " + terminaltext.Sanitize(entry.name) + " · " + status, kind: toolcallIdentity, identityName: entry.name, statusGlyph: glyph, statusText: status, statusStyle: style}, {text: "Call: " + terminaltext.Sanitize(entry.callID), label: "Call:", kind: toolcallField}, {text: "Arguments:", kind: toolcallHeading}, } @@ -767,6 +797,7 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { if s.list != nil { s.list.Move(move) s.selected = s.list.Cursor() + s.listFollow = s.selected == len(s.entries)-1 } return nil, true, false } @@ -787,7 +818,7 @@ func (s *toolcallsState) HandleWheel(msg tea.MouseWheelMsg) (tea.Cmd, bool) { if s.list == nil { return nil, true } - s.list.Move(map[bool]bounded.Move{true: bounded.LineUp, false: bounded.LineDown}[msg.Mouse().Button == tea.MouseWheelUp]) - s.selected = s.list.Cursor() + s.list.Scroll(map[bool]bounded.Move{true: bounded.LineUp, false: bounded.LineDown}[msg.Mouse().Button == tea.MouseWheelUp]) + s.listFollow = s.selected == len(s.entries)-1 && s.list.View().Below == 0 return nil, true } diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index a8679ebc2a..fe95d1f424 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -186,8 +186,8 @@ func TestMecatuiToolcallsInspector_ListUsesToolPaletteAcrossBuiltins(t *testing. m = openToolcallsForTest(t, m) s := toolcallsForTest(t, m) body, _ := s.Render(100, 10) - selected := presentListRow(s.list.ViewWithIndicators(6, false).Rows[0], th.Style("toolName"), th.Style("toolArgs")) - if !strings.Contains(body, selected.Style.Render(selected.Text)) { + selected := renderToolcallListRow(presentListRow(s.list.ViewWithIndicators(6, false).Rows[0], th.Style("toolName"), th.Style("toolArgs")), th.Style("toolName")) + if !strings.Contains(body, selected) { t.Fatalf("selected row does not use tool-name palette: %q", body) } s.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) @@ -195,14 +195,77 @@ func TestMecatuiToolcallsInspector_ListUsesToolPaletteAcrossBuiltins(t *testing. m.syncToolcalls() body, _ = s.Render(100, 10) row := s.list.ViewWithIndicators(6, false).Rows[0] - unselected := presentListRow(row, th.Style("toolName"), th.Style("toolArgs")) - if row.Selected || !strings.Contains(body, unselected.Style.Render(unselected.Text)) { + unselected := renderToolcallListRow(presentListRow(row, th.Style("toolName"), th.Style("toolArgs")), th.Style("toolName")) + if row.Selected || !strings.Contains(body, unselected) { t.Fatalf("unselected row does not use tool-args palette: row=%+v body=%q", row, body) } }) } } +func TestMecatuiToolcallsInspector_StatusGlyphsAcrossThemes(t *testing.T) { + registry := theme.NewRegistry() + for _, name := range []string{"aztec", "mono", "solar"} { + t.Run(name, func(t *testing.T) { + th, ok := registry.Get(name) + if !ok { + t.Fatalf("missing built-in theme %q", name) + } + m := newToolcallsInspectorModel(t) + m.deps.Theme = th + m.conv.addTool("pending", "Read", `{"path":"pending.go"}`) + m.conv.addTool("success", "Read", `{"path":"success.go"}`) + m.conv.resolveTool("success", "done", false) + m.conv.addTool("failure", "Read", `{"path":"failure.go"}`) + m.conv.resolveTool("failure", "failed", true) + m.conv.addTool("reused", "Read", `{"path":"first.go"}`) + m.conv.resolveTool("reused", "first", false) + m.conv.addTool("reused", "Read", `{"path":"second.go"}`) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + body, _ := s.Render(80, 12) + plain := stripANSIstr(body) + for _, tc := range []struct { + glyph, status, slot string + }{ + {"…", "running", "toolName"}, + {"✓", "done", "toolOk"}, + {"✗", "failed", "toolErr"}, + } { + if !strings.Contains(plain, tc.glyph+" "+tc.status) { + t.Fatalf("list lacks non-color status %q %q:\n%s", tc.glyph, tc.status, plain) + } + if !strings.Contains(body, th.Style(tc.slot).Render(tc.glyph)) { + t.Fatalf("list glyph %q does not use %s: %q", tc.glyph, tc.slot, body) + } + } + if !strings.Contains(plain, "first.go") || !strings.Contains(plain, "second.go") { + t.Fatalf("reused call IDs did not retain separate list rows:\n%s", plain) + } + for _, tc := range []toolcallDetail{ + {name: "Read", resultReceived: false}, + {name: "Read", resolved: true, resultReceived: true}, + {name: "Read", failed: true, resultReceived: true}, // provisional error follows the resolved-card failure semantics. + } { + glyph, status, slot := toolcallStatus(tc.resolved, tc.failed) + styled := strings.Join(s.styledToolcallDetailLines(tc), "\n") + if plain := stripANSIstr(styled); !strings.Contains(plain, "Identity · "+glyph+" Read · "+status) { + t.Fatalf("detail lacks non-color status %q:\n%s", status, plain) + } + if !strings.Contains(styled, th.Style(slot).Render(glyph)) || !strings.Contains(styled, th.Style(slot).Render(status)) { + t.Fatalf("detail status %q does not use %s: %q", status, slot, styled) + } + } + narrow, _ := s.Render(24, 8) + for _, row := range strings.Split(stripANSIstr(narrow), "\n") { + if ansi.StringWidth(row) > 24 { + t.Fatalf("narrow status row overflows: %q", row) + } + } + }) + } +} + func TestMecatuiToolcallsInspector_Scenario2_LiveResultAndStatus(t *testing.T) { m := newToolcallsInspectorModel(t) m.conv.addTool("first", "Read", `{"path":"first"}`) diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 4e8402434d..bd25b897a0 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -621,10 +621,11 @@ func TestMecatuiToolcallsInspector_Scenario4_SectionsAndReadGutter(t *testing.T) } } -func TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall(t *testing.T) { +func TestMecatuiToolcallsInspector_Scenario5_WheelScrollsListWithoutChangingSelection(t *testing.T) { m := newToolcallsInspectorModel(t) m.deps.NoAltScreen = false - m = addToolcallsForTest(t, m, 3) + m = addToolcallsForTest(t, m, 125) + m.conv.resolveTool("call-124", strings.Repeat("detail line\n", 40), false) m = openToolcallsForTest(t, m) m.vp.SetContent(strings.Repeat("conversation\n", 100)) m.vp.SetYOffset(5) @@ -632,35 +633,140 @@ func TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall(t *testing. _ = m.View() s := toolcallsForTest(t, m) - var first renderedHitRegion - for _, region := range m.hits.frame { - if s.hitItems[region.id] == s.entries[0].blockID { - first = region - break + selectedBlock := s.entries[s.selected].blockID + beforeOffset := s.list.Offset() + for range 8 { + updated, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + m = updated.(Model) + _ = m.View() + } + s = toolcallsForTest(t, m) + if got := s.list.Offset(); got >= beforeOffset { + t.Fatalf("wheel list offset = %d, want less than %d", got, beforeOffset) + } + if got := s.entries[s.selected].blockID; got != selectedBlock { + t.Fatalf("wheel changed selected block to %d, want %d", got, selectedBlock) + } + for _, blockID := range s.hitItems { + if blockID == selectedBlock { + t.Fatal("wheel did not move selected row offscreen") } } - if first.id == 0 { - t.Fatal("rendered inspector has no hit for the first visible call") + if got := m.vp.YOffset(); got != beforeConversation { + t.Fatalf("wheel moved hidden conversation from %d to %d", beforeConversation, got) + } + offTailOffset := s.list.Offset() + + m = addToolcallsForTest(t, m, 1) + _ = m.View() + s = toolcallsForTest(t, m) + if got := s.list.Offset(); got != offTailOffset { + t.Fatalf("live addition moved off-tail list from %d to %d", offTailOffset, got) + } + if got := s.entries[s.selected].blockID; got != selectedBlock { + t.Fatalf("live addition changed off-tail selection to %d, want %d", got, selectedBlock) } - globalX, globalY := m.metrics.localToGlobal(first.rect.x0, first.rect.y0) - updated, _ := m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: globalX, Y: globalY}) + if got, want := s.list.CursorID(), fmt.Sprintf("%d", selectedBlock); got != want { + t.Fatalf("live addition moved list cursor to %q, want %q", got, want) + } + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + s = toolcallsForTest(t, m) + s.refreshDetail(&m.conv.scrollback) + s.Render(m.width, 16) + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnd}) + m = updated.(Model) + s = toolcallsForTest(t, m) + detailOffset := s.window.Offset() + updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) m = updated.(Model) + _ = m.View() s = toolcallsForTest(t, m) - if s.detail || s.entries[s.selected].blockID != s.hitItems[first.id] { - t.Fatalf("click selected detail=%v block=%d, want visible block %d", s.detail, s.entries[s.selected].blockID, s.hitItems[first.id]) + if got := s.window.Offset(); got >= detailOffset { + t.Fatalf("detail wheel offset = %d, want less than %d", got, detailOffset) + } + if got := s.entries[s.selected].blockID; got != selectedBlock { + t.Fatalf("detail wheel changed selected block to %d, want %d", got, selectedBlock) } if got := m.vp.YOffset(); got != beforeConversation { - t.Fatalf("click moved hidden conversation from %d to %d", beforeConversation, got) + t.Fatalf("detail wheel moved hidden conversation from %d to %d", beforeConversation, got) } - updated, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = updated.(Model) + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyHome}) m = updated.(Model) - if toolcallsForTest(t, m).selected == 0 { - t.Fatal("wheel no longer moves the inspector selection") + _ = m.View() + s = toolcallsForTest(t, m) + if s.selected != 0 { + t.Fatalf("Home selected %d, want oldest row", s.selected) } - updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + var clicked renderedHitRegion + for _, region := range m.hits.frame { + if s.hitItems[region.id] != s.entries[s.selected].blockID { + clicked = region + break + } + } + if clicked.id == 0 { + t.Fatal("keyboard reveal left no alternate visible row to click") + } + wantBlock := s.hitItems[clicked.id] + x, y := m.metrics.localToGlobal(clicked.rect.x0, clicked.rect.y0) + updated, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) m = updated.(Model) - if s := toolcallsForTest(t, m); !s.detail || s.detailEntry == nil || s.detailEntry.callID != "call-1" { - t.Fatalf("Enter opened %#v, want selected call-1 detail", s) + _ = m.View() + s = toolcallsForTest(t, m) + if got := s.entries[s.selected].blockID; got != wantBlock { + t.Fatalf("click selected block %d, want %d", got, wantBlock) + } + if s.list.CursorID() != fmt.Sprintf("%d", wantBlock) { + t.Fatalf("click did not reveal selected block %d", wantBlock) + } +} + +func TestMecatuiToolcallsInspector_Scenario5_WheelReturnsToTailAndFollowsNewCalls(t *testing.T) { + m := newToolcallsInspectorModel(t) + m = addToolcallsForTest(t, m, 125) + m = openToolcallsForTest(t, m) + _ = m.View() + s := toolcallsForTest(t, m) + selectedBlock := s.entries[s.selected].blockID + for range 8 { + updated, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + m = updated.(Model) + _ = m.View() + } + if s.list.View().Below == 0 || s.listFollow { + t.Fatalf("wheel up did not leave tail: offset=%d follow=%v", s.list.Offset(), s.listFollow) + } + for i := 0; i < 125 && s.list.View().Below > 0; i++ { + updated, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = updated.(Model) + _ = m.View() + } + if s.list.View().Below != 0 || !s.listFollow || s.list.RevealPending() { + t.Fatalf("wheel down did not restore tail without reveal: offset=%d follow=%v reveal=%v", s.list.Offset(), s.listFollow, s.list.RevealPending()) + } + if got := s.entries[s.selected].blockID; got != selectedBlock { + t.Fatalf("wheel changed selection from %d to %d", selectedBlock, got) + } + m = addToolcallsForTest(t, m, 1) + view := m.View() + if s.selected != len(s.entries)-1 || s.entries[s.selected].blockID == selectedBlock { + t.Fatalf("tail reader did not select new call: selected=%d entries=%d", s.selected, len(s.entries)) + } + newBlock := s.entries[s.selected].blockID + if s.list.CursorID() != fmt.Sprintf("%d", newBlock) || s.list.View().Below != 0 { + t.Fatalf("new call not at visible tail: cursor=%q below=%d", s.list.CursorID(), s.list.View().Below) + } + visible := false + for _, block := range s.hitItems { + visible = visible || block == newBlock + } + if !visible || !strings.Contains(stripANSIstr(view.Content), "file-125.go") { + t.Fatalf("newest call is not rendered in list: %q", stripANSIstr(view.Content)) } } diff --git a/cmd/mecatui/ui/toolcalls_repair_test.go b/cmd/mecatui/ui/toolcalls_repair_test.go index 540a78a2b9..bcb9e448fa 100644 --- a/cmd/mecatui/ui/toolcalls_repair_test.go +++ b/cmd/mecatui/ui/toolcalls_repair_test.go @@ -98,7 +98,7 @@ func TestMecatuiToolcallsInspector_Scenario4_RenderedPaginationAndStyle(t *testi m.conv.resolveTool("read", " 1\t"+strings.Repeat("wrapped", 20)+"\n 2\tend-gutter", true) m.syncToolcalls() raw := m.View().Content - if !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Error:")) || !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Identity · Read · failed")) || !strings.Contains(raw, s.deps.theme.Style("toolName").Render("Arguments:")) { + if !strings.Contains(raw, s.deps.theme.Style("errorText").Render("Error:")) || !strings.Contains(raw, s.deps.theme.Style("toolErr").Render("✗")) || !strings.Contains(raw, s.deps.theme.Style("toolErr").Render("failed")) || !strings.Contains(raw, s.deps.theme.Style("toolName").Render("Arguments:")) { t.Fatalf("rendered sections lack distinct styles: %q", raw) } for _, width := range []int{70, 55} { diff --git a/docs/tui.md b/docs/tui.md index 7695ecb268..0975589b16 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -51,12 +51,19 @@ wheel events into the conversation. Selectable inventories use `presentListRow` for the cursor marker, status cells, and selected-row styling. Let the surface own item semantics and activation. -For list-and-detail browsers, use a bounded list for stable item selection and a -separate bounded viewport for long detail. Preserve the selected item and list -window when returning from detail; size both controls against the current -measured body instead of storing an independent row budget. Keep detail scrolling -within that viewport: follow appended result lines only while its reader is at -the bottom, and keep the reader's position when new content arrives off-tail. +Tool-call status uses `…` with `toolName`, `✓` with `toolOk`, and `✗` with +`toolErr`. Keep a readable `running`, `done`, or `failed` label with each glyph +so status does not depend on color. + +For list-and-detail browsers, use a bounded list for stable item selection and +a separate bounded viewport for long detail. In wheel-enabled inventories, +wheel scrolling moves the list window independently of selection. Keyboard +navigation and clicking a row select and reveal it. Preserve the selected item +and list window when returning from detail; size both controls against the +current measured body instead of storing an independent row budget. Keep +detail scrolling within that viewport: follow appended result lines only while +its reader is at the bottom, and keep the reader's position when new content +arrives off-tail. The saved-memory browser demonstrates list/detail ownership; Sessions demonstrates the conversation-region fill placement for content that needs more room. Fill surfaces use only the offered conversation region and keep the frame's header, From be7f0be97814fe39e3916714e2a4f605672e2ef7 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Fri, 2 Oct 2026 18:51:42 -0700 Subject: [PATCH 24/30] test(mecatui): pin live inspector status tones in list and detail Co-authored-by: Mecatl --- .../ui/toolcalls_detail_scenario_test.go | 88 ++++++++++--------- .../ui/toolcalls_inspector_scenario_test.go | 5 +- 2 files changed, 52 insertions(+), 41 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go index fe95d1f424..b40589b58f 100644 --- a/cmd/mecatui/ui/toolcalls_detail_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -213,54 +213,62 @@ func TestMecatuiToolcallsInspector_StatusGlyphsAcrossThemes(t *testing.T) { } m := newToolcallsInspectorModel(t) m.deps.Theme = th - m.conv.addTool("pending", "Read", `{"path":"pending.go"}`) - m.conv.addTool("success", "Read", `{"path":"success.go"}`) - m.conv.resolveTool("success", "done", false) - m.conv.addTool("failure", "Read", `{"path":"failure.go"}`) - m.conv.resolveTool("failure", "failed", true) - m.conv.addTool("reused", "Read", `{"path":"first.go"}`) - m.conv.resolveTool("reused", "first", false) - m.conv.addTool("reused", "Read", `{"path":"second.go"}`) + m = applyAll(m, client.ToolCallMsg{ID: "lifecycle", Name: "Read", Args: `{"path":"lifecycle.go"}`}) m = openToolcallsForTest(t, m) s := toolcallsForTest(t, m) - body, _ := s.Render(80, 12) - plain := stripANSIstr(body) - for _, tc := range []struct { - glyph, status, slot string - }{ - {"…", "running", "toolName"}, - {"✓", "done", "toolOk"}, - {"✗", "failed", "toolErr"}, - } { - if !strings.Contains(plain, tc.glyph+" "+tc.status) { - t.Fatalf("list lacks non-color status %q %q:\n%s", tc.glyph, tc.status, plain) + selected := s.entries[s.selected].blockID + + assertList := func(glyph, status, slot string) { + t.Helper() + body := m.View().Content + if plain := stripANSIstr(body); !strings.Contains(plain, glyph+" "+status) { + t.Fatalf("list lacks non-color status %q %q:\n%s", glyph, status, plain) } - if !strings.Contains(body, th.Style(tc.slot).Render(tc.glyph)) { - t.Fatalf("list glyph %q does not use %s: %q", tc.glyph, tc.slot, body) + if !strings.Contains(body, th.Style(slot).Render(glyph)) { + t.Fatalf("list glyph %q does not use %s: %q", glyph, slot, body) + } + if got := toolcallsForTest(t, m).entries[toolcallsForTest(t, m).selected].blockID; got != selected { + t.Fatalf("lifecycle update selected block %d, want %d", got, selected) } } - if !strings.Contains(plain, "first.go") || !strings.Contains(plain, "second.go") { - t.Fatalf("reused call IDs did not retain separate list rows:\n%s", plain) - } - for _, tc := range []toolcallDetail{ - {name: "Read", resultReceived: false}, - {name: "Read", resolved: true, resultReceived: true}, - {name: "Read", failed: true, resultReceived: true}, // provisional error follows the resolved-card failure semantics. - } { - glyph, status, slot := toolcallStatus(tc.resolved, tc.failed) - styled := strings.Join(s.styledToolcallDetailLines(tc), "\n") - if plain := stripANSIstr(styled); !strings.Contains(plain, "Identity · "+glyph+" Read · "+status) { - t.Fatalf("detail lacks non-color status %q:\n%s", status, plain) + + m = applyAll(m, tea.WindowSizeMsg{Width: 46, Height: 30}) + assertDetail := func(glyph, status, slot string) string { + t.Helper() + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + s = toolcallsForTest(t, m) + if !s.detail { + t.Fatal("Enter did not open the selected call detail") } - if !strings.Contains(styled, th.Style(slot).Render(glyph)) || !strings.Contains(styled, th.Style(slot).Render(status)) { - t.Fatalf("detail status %q does not use %s: %q", status, slot, styled) + detail := m.View().Content + if s.compact { + t.Fatalf("narrow detail unexpectedly used compact fallback: %q", detail) } - } - narrow, _ := s.Render(24, 8) - for _, row := range strings.Split(stripANSIstr(narrow), "\n") { - if ansi.StringWidth(row) > 24 { - t.Fatalf("narrow status row overflows: %q", row) + if plain := stripANSIstr(detail); !strings.Contains(plain, "Identity · "+glyph+" Read · "+status) { + t.Fatalf("detail lacks non-color status %q: %q", status, plain) } + if !strings.Contains(detail, th.Style(slot).Render(glyph)) || !strings.Contains(detail, th.Style(slot).Render(status)) { + t.Fatalf("detail status %q does not use %s: %q", status, slot, detail) + } + if got := s.entries[s.selected].blockID; got != selected { + t.Fatalf("detail selected block %d, want %d", got, selected) + } + return stripANSIstr(detail) + } + + assertList("…", "running", "toolName") + assertDetail("…", "running", "toolName") + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + m = applyAll(m, client.ToolResultMsg{CallID: "lifecycle", Content: "provisional failure", Available: true, IsError: true}) + assertList("✗", "failed", "toolErr") + if detail := assertDetail("✗", "failed", "toolErr"); !strings.Contains(detail, "provisional failure") { + t.Fatalf("provisional result missing from detail: %q", detail) + } + m = applyAll(m, tea.KeyPressMsg{Code: tea.KeyEsc}) + m = applyAll(m, client.ToolResultMsg{CallID: "lifecycle", Content: "canonical success"}) + assertList("✓", "done", "toolOk") + if detail := assertDetail("✓", "done", "toolOk"); strings.Contains(detail, "provisional failure") || !strings.Contains(detail, "canonical success") { + t.Fatalf("canonical detail lifecycle state: %q", detail) } }) } diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index bd25b897a0..55e47a99ea 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -697,11 +697,14 @@ func TestMecatuiToolcallsInspector_Scenario5_WheelScrollsListWithoutChangingSele m = updated.(Model) updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyHome}) m = updated.(Model) - _ = m.View() + view := stripANSIstr(m.View().Content) s = toolcallsForTest(t, m) if s.selected != 0 { t.Fatalf("Home selected %d, want oldest row", s.selected) } + if !strings.Contains(view, s.entries[s.selected].intent) { + t.Fatalf("Home left selected row offscreen: %q", view) + } var clicked renderedHitRegion for _, region := range m.hits.frame { if s.hitItems[region.id] != s.entries[s.selected].blockID { From 76f78368fe0825541b2e3ff4b7586080e54d7bca Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sat, 3 Oct 2026 20:26:05 -0700 Subject: [PATCH 25/30] fix: clear inspector PR CI gates Restore the AC5.1 proof, accept the valid terminal-run verdict race, and upgrade the reachable Zipkin exporter to its fixed version. Co-authored-by: Mecatl --- .../ui/toolcalls_inspector_scenario_test.go | 14 +++++++++++++- go.mod | 2 +- go.sum | 2 ++ integration/microvm/go.mod | 2 +- integration/microvm/go.sum | 2 ++ internal/adapter/server/exact_plan_ask_test.go | 2 +- 6 files changed, 20 insertions(+), 4 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 55e47a99ea..872aea9227 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -621,7 +621,7 @@ func TestMecatuiToolcallsInspector_Scenario4_SectionsAndReadGutter(t *testing.T) } } -func TestMecatuiToolcallsInspector_Scenario5_WheelScrollsListWithoutChangingSelection(t *testing.T) { +func TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall(t *testing.T) { m := newToolcallsInspectorModel(t) m.deps.NoAltScreen = false m = addToolcallsForTest(t, m, 125) @@ -727,6 +727,18 @@ func TestMecatuiToolcallsInspector_Scenario5_WheelScrollsListWithoutChangingSele if s.list.CursorID() != fmt.Sprintf("%d", wantBlock) { t.Fatalf("click did not reveal selected block %d", wantBlock) } + if s.detail { + t.Fatal("click opened detail instead of leaving the list open") + } + if got := m.vp.YOffset(); got != beforeConversation { + t.Fatalf("click moved hidden conversation from %d to %d", beforeConversation, got) + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if !s.detail || s.entries[s.selected].blockID != wantBlock { + t.Fatalf("Enter did not open clicked call detail: detail=%t block=%d, want %d", s.detail, s.entries[s.selected].blockID, wantBlock) + } } func TestMecatuiToolcallsInspector_Scenario5_WheelReturnsToTailAndFollowsNewCalls(t *testing.T) { diff --git a/go.mod b/go.mod index 6408837263..36b5519f39 100644 --- a/go.mod +++ b/go.mod @@ -295,7 +295,7 @@ require ( go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 // indirect - go.opentelemetry.io/otel/exporters/zipkin v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.45.0 // indirect go.opentelemetry.io/otel/log v0.22.0 // indirect go.opentelemetry.io/otel/sdk/log v0.22.0 // indirect go.starlark.net v0.0.0-20260908191801-89a6a09411d5 // indirect diff --git a/go.sum b/go.sum index 30a506e468..b6ca639402 100644 --- a/go.sum +++ b/go.sum @@ -693,6 +693,8 @@ go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9 go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= go.opentelemetry.io/otel/exporters/zipkin v1.35.0 h1:OAx1AdClqTB3pz+B4osLuGjx8kubys8ByW7yx0lF454= go.opentelemetry.io/otel/exporters/zipkin v1.35.0/go.mod h1:hz5wHI9hmCXzwkXFGZ05ObZw2Q2t/AeAZ18PExd2uSM= +go.opentelemetry.io/otel/exporters/zipkin v1.45.0 h1:KN3btaILMTxR4QDHVGAO87lq5ButzK7l+kIfLuxQ1oA= +go.opentelemetry.io/otel/exporters/zipkin v1.45.0/go.mod h1:yNcodmUclM4InyWoOwX/YW4Jri0Gj5FWAlM+NqCrtqY= go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0= go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= diff --git a/integration/microvm/go.mod b/integration/microvm/go.mod index 78f4697eba..5fa55cbd64 100644 --- a/integration/microvm/go.mod +++ b/integration/microvm/go.mod @@ -268,7 +268,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.68.0 // indirect - go.opentelemetry.io/otel/exporters/zipkin v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.45.0 // indirect go.opentelemetry.io/otel/log v0.22.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/otel/sdk v1.46.0 // indirect diff --git a/integration/microvm/go.sum b/integration/microvm/go.sum index 75e6572ab7..95ba7d1f69 100644 --- a/integration/microvm/go.sum +++ b/integration/microvm/go.sum @@ -815,6 +815,8 @@ go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9 go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= go.opentelemetry.io/otel/exporters/zipkin v1.35.0 h1:OAx1AdClqTB3pz+B4osLuGjx8kubys8ByW7yx0lF454= go.opentelemetry.io/otel/exporters/zipkin v1.35.0/go.mod h1:hz5wHI9hmCXzwkXFGZ05ObZw2Q2t/AeAZ18PExd2uSM= +go.opentelemetry.io/otel/exporters/zipkin v1.45.0 h1:KN3btaILMTxR4QDHVGAO87lq5ButzK7l+kIfLuxQ1oA= +go.opentelemetry.io/otel/exporters/zipkin v1.45.0/go.mod h1:yNcodmUclM4InyWoOwX/YW4Jri0Gj5FWAlM+NqCrtqY= go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0= go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= diff --git a/internal/adapter/server/exact_plan_ask_test.go b/internal/adapter/server/exact_plan_ask_test.go index 06307ada26..36162ec4dd 100644 --- a/internal/adapter/server/exact_plan_ask_test.go +++ b/internal/adapter/server/exact_plan_ask_test.go @@ -231,7 +231,7 @@ func TestADR_0366_LiveAndRestoredExactPlanAsk(t *testing.T) { if ack.RunID != run.RunID() || ack.AskID != askID { t.Fatalf("ack = %+v", ack) } - if _, err := svc.ResolvePlanAsk(t.Context(), sess.ID, run.RunID(), askID, session.VerdictDeny); !errors.Is(err, server.ErrAskNotPending) { + if _, err := svc.ResolvePlanAsk(t.Context(), sess.ID, run.RunID(), askID, session.VerdictDeny); !errors.Is(err, server.ErrAskNotPending) && !errors.Is(err, server.ErrStaleRunControl) { t.Fatalf("competing verdict = %v", err) } evs := drainApprovedEvents(t, run.Events()) From 6ec1221f9faa14066e47dccda3e0d9bfc24d9bcd Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sun, 4 Oct 2026 11:24:13 -0700 Subject: [PATCH 26/30] docs(acceptance): authorize inspector click-to-open Record the operator-approved AC5.1 and interface amendment for direct detail activation from inspector rows. Co-authored-by: Mecatl --- docs/acceptance/mecatui-toolcalls-inspector.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/acceptance/mecatui-toolcalls-inspector.md b/docs/acceptance/mecatui-toolcalls-inspector.md index dda5ac438c..a05d9911ef 100644 --- a/docs/acceptance/mecatui-toolcalls-inspector.md +++ b/docs/acceptance/mecatui-toolcalls-inspector.md @@ -9,7 +9,7 @@ **Expected tasks:** deferred to orchestration **Issue:** [stacklok/mecatl#1361](https://github.com/stacklok/mecatl/issues/1361). -The main conversation remains unchanged. A local `/toolcalls` browser lets a reader inspect one call's complete available arguments and results without expanding every card. It works during a run and on the loaded transcript of the current session. The browser is a second view of the canonical UI [scrollback](../../cmd/mecatui/ui/internal/scrollback/scrollback.go), not another history store. The operator's in-PR amendment adds readable tool-specific presentation and click-to-select inside the inspector. This slice does not change `ctrl+t`, shrink cards, or activate the inspector by clicking a conversation card; later issue #1361 slices require separate plans after hands-on review. +The main conversation remains unchanged. A local `/toolcalls` browser lets a reader inspect one call's complete available arguments and results without expanding every card. It works during a run and on the loaded transcript of the current session. The browser is a second view of the canonical UI [scrollback](../../cmd/mecatui/ui/internal/scrollback/scrollback.go), not another history store. The operator's in-PR amendments add readable tool-specific presentation and click-to-open inside the inspector. This slice does not change `ctrl+t`, shrink cards, or activate the inspector by clicking a conversation card; later issue #1361 slices require separate plans after hands-on review. ## Human decisions @@ -17,17 +17,17 @@ The main conversation remains unchanged. A local `/toolcalls` browser lets a rea - [x] Navigation and live updates — Decision: `/toolcalls` is available during idle and running phases. Up/Down select calls; the configured page/top/bottom actions navigate the list; Enter inspects the selected call; Escape returns to the same call in the list, then closes. New calls are followed only if the reader was at the newest call; otherwise selection stays with the same call. A selected live call's detail updates in place and follows added content only while already at its bottom. The inspector must not move the hidden conversation's reading position. Replacing or clearing the active session closes the inspector and discards its old selection and detail. - [x] Layout — Decision: list and detail take the offered conversation region while the existing header, prompt, and footer remain. Normal layout gives the list compact rows and the detail a scrollable area for long content. If even a usable row and its navigation hints cannot fit, show a width-safe message that the view is too small with Escape to close; do not add a miniature browser. - [x] Detail boundary — Decision: show complete available text arguments and result, pending/completed/failed state, and readable typed result content, including text-bearing embedded resources. Show structured JSON as a labeled value and resource links as labels and URIs; summarize byte-bearing media and embedded resources without dumping bytes. Do not dereference links, fetch more data, or imply a summary contains hidden raw media. Preserve existing display-safe content and status boundaries. -- [x] In-PR presentation and pointer amendment — Decision: replace JSON argument envelopes with readable, complete labeled fields and tool-specific one-line intents for recognized core tools; preserve every received value and a generic readable fallback for unknown/extended tools. Use visual section hierarchy; space out `Read` result line-number gutters without modifying received text. A primary click on a visible inspector-list row selects that call but does not enter its detail; Enter remains the activation key. No clicking conversation cards. Source: the directing operator's feedback and explicit instruction to amend the contract within Implementation PR #2061 (2026-10-02). +- [x] In-PR presentation and pointer amendment — Decision: replace JSON argument envelopes with readable, complete labeled fields and tool-specific one-line intents for recognized core tools; preserve every received value and a generic readable fallback for unknown/extended tools. Use visual section hierarchy; space out `Read` result line-number gutters without modifying received text. A primary click on a visible inspector-list row selects that call and opens its detail; Enter remains the keyboard activation key. No clicking conversation cards. Source: the directing operator's feedback and explicit in-PR presentation amendment (2026-10-02), followed by explicit authorization to change click-to-select into click-to-open in the batch fix (2026-10-03). ## Interface contract - **gRPC / protobuf:** None — use current `tool.call`, `tool.result`, and typed result fields; no wire change or new RPC. - **Exported Go APIs / interfaces:** None — only private `cmd/mecatui/ui` and UI-internal component contracts may change; the engine and SDK exports remain unchanged. - **Tool schemas:** None — no model-facing tool changes; `/toolcalls` is a client command, not a model tool. -- **CLI / config:** Add the exact no-argument, slash-palette-visible local command `/toolcalls` in mecatui. It opens for the current session while idle or running, with no capability-dependent server lookup, new flag, settings key, or global binding. Configured existing list and scroll bindings work inside the browser; a primary click on a visible inspector-list row only selects it, with Enter still opening detail. `ctrl+t` retains its conversation-wide behavior when the browser is closed. +- **CLI / config:** Add the exact no-argument, slash-palette-visible local command `/toolcalls` in mecatui. It opens for the current session while idle or running, with no capability-dependent server lookup, new flag, settings key, or global binding. Configured existing list and scroll bindings work inside the browser; a primary click on a visible inspector-list row selects it and opens its detail, while Enter remains the keyboard activation key. `ctrl+t` retains its conversation-wide behavior when the browser is closed. - **Events / persistence:** None — no new events, session store, or durable migration. The existing UI scrollback remains the only conversation projection. It retains or projects structured content supplied in an existing result, including a field-only result when no corresponding typed block exists. Browser selection, list/detail offsets, and follow state are ephemeral and reset on session replacement. - **Security / authority:** None — this read-only browser uses only the current session's already received display-safe result projection. No permission bypass, URL fetch, raw binary display, new workspace access, or cross-session browsing. Sanitize all untrusted names, arguments, text, JSON, and resource metadata at the rendering boundary; do not reconstruct content withheld by the server. Pointer hits are frame-scoped and affect only visible inspector rows; misses, stale IDs, compact views and no-mouse configurations never select a hidden conversation card. -- **Compatibility / migration:** Additive local UI. Existing cards, global `ctrl+t`, conversation scroll/selection, approvals, keyboard routing outside the browser, and transcript rehydration remain intact. The implementation updates the owning public TUI usage guide for command discovery, inspector click-to-select, and corrects its existing focused-card `ctrl+t` wording to describe current global behavior. +- **Compatibility / migration:** Additive local UI. Existing cards, global `ctrl+t`, conversation scroll/selection, approvals, keyboard routing outside the browser, and transcript rehydration remain intact. The implementation updates the owning public TUI usage guide for command discovery, inspector click-to-open, and corrects its existing focused-card `ctrl+t` wording to describe current global behavior. ## In scope - 5 scenarios, in implementation order @@ -81,10 +81,10 @@ The first implementation presents internal call arguments as serialized JSON. Th ### Scenario 5 - select a visible inspector call with the pointer -Only the inspector's **list rows** are pointer-selectable; this does not activate a conversation card, turn mouse input on for disabled terminals, or open a call's detail on click. Follow the [model picker surface](../../cmd/mecatui/ui/models_surface.go)'s frame-local hit-region precedent and [ADR 0301](../adr/0301-logical-conversation-anchors.md)'s reading-position boundary. The existing surface routing still owns clicks and wheels before the hidden conversation. +Only the inspector's **list rows** are pointer-selectable; clicking one opens that call's detail. This does not activate a conversation card or turn mouse input on for disabled terminals. Follow the [model picker surface](../../cmd/mecatui/ui/models_surface.go)'s frame-local hit-region precedent and [ADR 0301](../adr/0301-logical-conversation-anchors.md)'s reading-position boundary. The existing surface routing still owns clicks and wheels before the hidden conversation. **Acceptance:** -- AC5.1: In a normally sized inspector list with pointer input enabled, a primary click on a visible row selects its stable block identity, reveals it and leaves the list open. Enter then opens that call's detail. Keyboard navigation and wheel scrolling continue to work, and clicking does not move the hidden conversation's reading position or start text selection. +- AC5.1: In a normally sized inspector list with pointer input enabled, a primary click on a visible row selects its stable block identity, reveals it, and opens that call's detail. Keyboard navigation and Enter continue to open the selected call, and wheel scrolling continues to work. Clicking does not move the hidden conversation's reading position or start text selection. - verify: `TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall` - AC5.2: A click on header, footer, blank/overflow rows, detail, compact fallback, or outside the inspector changes no selection. After resize or live-list reflow, stale hit IDs and hidden/offscreen rows cannot select another call; no-mouse configurations do not activate a row. A completed call ID reused later still selects the clicked row's block, not another call. - verify: `TestMecatuiToolcallsInspector_Scenario5_ClickIsolationAndStaleHits` From 4b808bb009ee7a6fe878c1d7c94a159c194cb136 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sun, 4 Oct 2026 11:24:29 -0700 Subject: [PATCH 27/30] refactor(mecatui): simplify toolcall presentation and open clicked detail Use card revisions for the inspector cache, collocate per-tool presentation in a table, and render selected detail on pointer activation. Co-authored-by: Mecatl --- .../ui/internal/scrollback/scrollback.go | 11 +-- cmd/mecatui/ui/internal/scrollback/tool.go | 6 +- .../ui/internal/scrollback/tool_test.go | 2 +- cmd/mecatui/ui/toolcalls.go | 93 +++++++++---------- .../ui/toolcalls_inspector_scenario_test.go | 18 +++- cmd/mecatui/ui/toolcalls_presentation_test.go | 87 +++++++++++++++++ cmd/mecatui/ui/toolcalls_repair_test.go | 57 +++++++++--- cmd/mecatui/ui/update.go | 3 + user-docs/mecatui/using-the-tui.md | 16 ++-- 9 files changed, 210 insertions(+), 83 deletions(-) create mode 100644 cmd/mecatui/ui/toolcalls_presentation_test.go diff --git a/cmd/mecatui/ui/internal/scrollback/scrollback.go b/cmd/mecatui/ui/internal/scrollback/scrollback.go index c68afa4e23..f190ff76a4 100644 --- a/cmd/mecatui/ui/internal/scrollback/scrollback.go +++ b/cmd/mecatui/ui/internal/scrollback/scrollback.go @@ -64,10 +64,9 @@ type BlockMetadata struct { } type card struct { - id BlockID - revision uint64 - argumentRevision uint64 - payload PayloadSnapshot + id BlockID + revision uint64 + payload PayloadSnapshot } // Conversation is an ordered, mutable logical conversation document. It owns its @@ -86,7 +85,7 @@ type Conversation struct { // the conversation until a selected detail requests a detached snapshot. type ToolCallMetadata struct { ID BlockID - ArgumentRevision uint64 + Revision uint64 CallID, Name, Arguments string Resolved, Failed bool Stop string @@ -118,7 +117,7 @@ func (c *Conversation) ToolCallMetadataAt(i int) (ToolCallMetadata, bool) { default: return ToolCallMetadata{}, false } - return ToolCallMetadata{ID: card.id, ArgumentRevision: card.argumentRevision, CallID: call.ID, Name: call.Name, Arguments: call.Arguments, Resolved: resolved, Failed: failed, Stop: stop}, true + return ToolCallMetadata{ID: card.id, Revision: card.revision, CallID: call.ID, Name: call.Name, Arguments: call.Arguments, Resolved: resolved, Failed: failed, Stop: stop}, true } // Len returns the number of ordinary cards in the conversation. It excludes the diff --git a/cmd/mecatui/ui/internal/scrollback/tool.go b/cmd/mecatui/ui/internal/scrollback/tool.go index 0c47cbde89..eacf767adf 100644 --- a/cmd/mecatui/ui/internal/scrollback/tool.go +++ b/cmd/mecatui/ui/internal/scrollback/tool.go @@ -82,11 +82,7 @@ func (t ToolCards) ReconcileUnresolved(call ToolCall) bool { return true } payload.Call = updated - if c.replace(i, payload) { - c.cards[i].argumentRevision++ - return true - } - return false + return c.replace(i, payload) } // Finish records a terminal lifecycle projection before the tool result arrives. diff --git a/cmd/mecatui/ui/internal/scrollback/tool_test.go b/cmd/mecatui/ui/internal/scrollback/tool_test.go index 378ef5070c..2f458622b9 100644 --- a/cmd/mecatui/ui/internal/scrollback/tool_test.go +++ b/cmd/mecatui/ui/internal/scrollback/tool_test.go @@ -101,7 +101,7 @@ func TestToolCallMetadataAtDoesNotExposeResultPayload(t *testing.T) { if allocs := testing.AllocsPerRun(100, func() { _, _ = c.ToolCallMetadataAt(0) }); allocs != 0 { t.Fatalf("metadata enumerator cloned result: %.0f allocations", allocs) } - if got.CallID != "read" || got.Name != "Read" || got.Arguments != `{"path":"x"}` || !got.Resolved || !got.Failed { + if got.ID != 1 || got.Revision != 1 || got.CallID != "read" || got.Name != "Read" || got.Arguments != `{"path":"x"}` || !got.Resolved || !got.Failed { t.Fatalf("metadata = %#v", got) } } diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index 990de40f89..e58ff6d114 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -33,13 +33,13 @@ func (m Model) runToolcalls() (tea.Model, tea.Cmd) { } type toolcallEntry struct { - blockID scrollback.BlockID - argumentRevision uint64 - index int - name string - intent string - resolved bool - failed bool + blockID scrollback.BlockID + revision uint64 + index int + name string + intent string + resolved bool + failed bool } type toolcallDetail struct { @@ -85,6 +85,11 @@ func (s *toolcallsState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { s.list.SetCursor(i) s.listFollow = i == len(s.entries)-1 } + s.detail = true + s.window = new(bounded.Viewport) + s.width = 0 + s.anchor = 0 + s.follow = false return nil, true, false } } @@ -139,6 +144,29 @@ func toolcallStatus(resolved, failed bool) (glyph, text, style string) { } } +type toolcallPresentation struct { + intentAction string + intentKeys []string + argumentKeys []string +} + +const toolSourceArg = "source" + +var toolcallPresentations = map[string]toolcallPresentation{ + "Read": {intentAction: "Read", intentKeys: []string{toolPathArg}, argumentKeys: []string{toolPathArg, "offset", "limit"}}, + "ListDir": {intentAction: "List", intentKeys: []string{toolPathArg}, argumentKeys: []string{toolPathArg, "depth"}}, + "Glob": {intentAction: "Find", intentKeys: []string{"pattern"}, argumentKeys: []string{"pattern", toolPathArg}}, + "Grep": {intentAction: "Search", intentKeys: []string{"pattern"}, argumentKeys: []string{"pattern", toolPathArg}}, + toolEditName: {intentAction: "Edit", intentKeys: []string{toolPathArg}, argumentKeys: []string{toolPathArg, "old_string", "new_string"}}, + toolWriteName: {intentAction: "Write", intentKeys: []string{toolPathArg}, argumentKeys: []string{toolPathArg, "content"}}, + "Copy": {intentAction: "Copy", intentKeys: []string{toolSourceArg, "destination"}, argumentKeys: []string{toolSourceArg, "destination"}}, + "Move": {intentAction: "Move", intentKeys: []string{toolSourceArg, "destination"}, argumentKeys: []string{toolSourceArg, "destination"}}, + "Remove": {intentAction: "Remove", intentKeys: []string{toolPathArg}, argumentKeys: []string{toolPathArg}}, + "Shell": {intentAction: "Run", intentKeys: []string{"command"}, argumentKeys: []string{"command"}}, + "WebFetch": {intentAction: "Fetch", intentKeys: []string{toolURLArg}, argumentKeys: []string{toolURLArg}}, + "FetchMcpResource": {intentAction: "Fetch", intentKeys: []string{"uri"}, argumentKeys: []string{"uri"}}, +} + func toolcallArgumentLines(name, arguments string) []string { rows := toolcallArgumentRows(name, arguments) lines := make([]string, len(rows)) @@ -160,21 +188,8 @@ func toolcallArgumentRows(name, arguments string) []toolcallDetailRow { return []toolcallDetailRow{{text: "Original arguments: " + terminaltext.Sanitize(arguments), label: "Original arguments:", kind: toolcallArgument}} } - known := map[string][]string{ - "Read": {toolPathArg, "offset", "limit"}, - "ListDir": {toolPathArg, "depth"}, - "Glob": {"pattern", toolPathArg}, - "Grep": {"pattern", toolPathArg}, - toolEditName: {toolPathArg, "old_string", "new_string"}, - toolWriteName: {toolPathArg, "content"}, - "Copy": {"source", "destination"}, - "Move": {"source", "destination"}, - "Remove": {toolPathArg}, - "Shell": {"command"}, - "WebFetch": {toolURLArg}, - "FetchMcpResource": {"uri"}, - } - ordered := append([]string(nil), known[name]...) + presentation := toolcallPresentations[name] + ordered := append([]string(nil), presentation.argumentKeys...) seen := make(map[string]bool, len(ordered)) for _, key := range ordered { seen[key] = true @@ -268,30 +283,12 @@ func argumentSummary(raw json.RawMessage) string { } func toolcallIntent(name string, fields map[string]json.RawMessage) string { - value := func(key string) string { return argumentSummary(fields[key]) } - switch name { - case "Read": - return "Read " + value(toolPathArg) - case "ListDir": - return "List " + value(toolPathArg) - case "Glob": - return "Find " + value("pattern") - case "Grep": - return "Search " + value("pattern") - case toolEditName: - return "Edit " + value(toolPathArg) - case toolWriteName: - return "Write " + value(toolPathArg) - case "Copy", "Move": - return name + " " + value("source") + " → " + value("destination") - case "Remove": - return "Remove " + value(toolPathArg) - case "Shell": - return "Run " + value("command") - case "WebFetch": - return "Fetch " + value(toolURLArg) - case "FetchMcpResource": - return "Fetch " + value("uri") + if presentation, ok := toolcallPresentations[name]; ok { + values := make([]string, len(presentation.intentKeys)) + for i, key := range presentation.intentKeys { + values[i] = argumentSummary(fields[key]) + } + return presentation.intentAction + " " + strings.Join(values, " → ") } for _, key := range []string{"target", toolPathArg, "uri", toolURLArg, "command", "query", "prompt", "task"} { if raw, ok := fields[key]; ok { @@ -351,11 +348,11 @@ func (m Model) toolcallEntriesSince(previous []toolcallEntry) []toolcallEntry { continue } intent := cached[metadata.ID].intent - if old, ok := cached[metadata.ID]; !ok || old.argumentRevision != metadata.ArgumentRevision { + if old, ok := cached[metadata.ID]; !ok || old.revision != metadata.Revision { intent = ansi.Truncate(terminaltext.SanitizeSingleLine(toolcallIntentFor(metadata.Name, metadata.Arguments)), 120, "…") } entries = append(entries, toolcallEntry{ - blockID: metadata.ID, argumentRevision: metadata.ArgumentRevision, index: i, + blockID: metadata.ID, revision: metadata.Revision, index: i, name: ansi.Truncate(terminaltext.SanitizeSingleLine(metadata.Name), 120, "…"), intent: intent, resolved: metadata.Resolved, diff --git a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go index 872aea9227..6f52e83b75 100644 --- a/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -727,17 +727,23 @@ func TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall(t *testing. if s.list.CursorID() != fmt.Sprintf("%d", wantBlock) { t.Fatalf("click did not reveal selected block %d", wantBlock) } - if s.detail { - t.Fatal("click opened detail instead of leaving the list open") + if !s.detail { + t.Fatal("click did not open detail") } if got := m.vp.YOffset(); got != beforeConversation { t.Fatalf("click moved hidden conversation from %d to %d", beforeConversation, got) } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = updated.(Model) + s = toolcallsForTest(t, m) + if s.detail || s.entries[s.selected].blockID != wantBlock { + t.Fatalf("Escape did not return click-activated detail to its selected list row: detail=%t block=%d, want %d", s.detail, s.entries[s.selected].blockID, wantBlock) + } updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = updated.(Model) s = toolcallsForTest(t, m) if !s.detail || s.entries[s.selected].blockID != wantBlock { - t.Fatalf("Enter did not open clicked call detail: detail=%t block=%d, want %d", s.detail, s.entries[s.selected].blockID, wantBlock) + t.Fatalf("Enter did not open selected call detail: detail=%t block=%d, want %d", s.detail, s.entries[s.selected].blockID, wantBlock) } } @@ -920,6 +926,9 @@ func TestMecatuiToolcallsInspector_Scenario5_ClickIsolationAndStaleHits(t *testi if got := toolcallsForTest(t, m).selected; got != before { t.Fatalf("non-row click at %v selected %d, want %d", point, got, before) } + if toolcallsForTest(t, m).detail { + t.Fatalf("non-row click at %v opened detail", point) + } } updated, _ := m.Update(tea.WindowSizeMsg{Width: 30, Height: 10}) m = updated.(Model) @@ -980,6 +989,9 @@ func TestMecatuiToolcallsInspector_Scenario5_ClickIsolationAndStaleHits(t *testi if got := toolcallsForTest(t, reused).entries[toolcallsForTest(t, reused).selected].blockID; got != wantBlock { t.Fatalf("reused ID click selected block %d, want later block %d", got, wantBlock) } + if !toolcallsForTest(t, reused).detail { + t.Fatal("reused ID click did not open the selected call detail") + } } func newToolcallsInspectorModel(t *testing.T) Model { diff --git a/cmd/mecatui/ui/toolcalls_presentation_test.go b/cmd/mecatui/ui/toolcalls_presentation_test.go new file mode 100644 index 0000000000..629bc77cf7 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_presentation_test.go @@ -0,0 +1,87 @@ +package ui + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestToolcallPresentations(t *testing.T) { + tests := []struct { + name string + intent string + detailKeys []string + }{ + {"Read", "Read Read-path", []string{"path", "offset", "limit"}}, + {"ListDir", "List ListDir-path", []string{"path", "depth"}}, + {"Glob", "Find Glob-pattern", []string{"pattern", "path"}}, + {"Grep", "Search Grep-pattern", []string{"pattern", "path"}}, + {toolEditName, "Edit Edit-path", []string{"path", "old_string", "new_string"}}, + {toolWriteName, "Write Write-path", []string{"path", "content"}}, + {"Copy", "Copy Copy-source → Copy-destination", []string{"source", "destination"}}, + {"Move", "Move Move-source → Move-destination", []string{"source", "destination"}}, + {"Remove", "Remove Remove-path", []string{"path"}}, + {"Shell", "Run Shell-command", []string{"command"}}, + {"WebFetch", "Fetch WebFetch-url", []string{"url"}}, + {"FetchMcpResource", "Fetch FetchMcpResource-uri", []string{"uri"}}, + } + + if got, want := len(toolcallPresentations), len(tests); got != want { + t.Fatalf("presentation entries = %d, want %d", got, want) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fields := map[string]string{"z_extra": test.name + "-z", "a_extra": test.name + "-a"} + for _, key := range test.detailKeys { + fields[key] = test.name + "-" + key + } + arguments, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + if got := toolcallIntentFor(test.name, string(arguments)); got != test.intent { + t.Fatalf("intent = %q, want %q", got, test.intent) + } + + want := make([]string, 0, len(test.detailKeys)+2) + for _, key := range append(append([]string(nil), test.detailKeys...), "a_extra", "z_extra") { + want = append(want, argumentLabel(key)+": "+fields[key]) + } + if got := toolcallArgumentLines(test.name, string(arguments)); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("detail arguments = %q, want %q", got, want) + } + }) + } +} + +func TestToolcallPresentationUnknownFallback(t *testing.T) { + arguments := `{"z":"last","path":"target","a":"first"}` + if got, want := toolcallIntentFor("McpCustom", arguments), "McpCustom target"; got != want { + t.Fatalf("unknown intent = %q, want %q", got, want) + } + if got, want := strings.Join(toolcallArgumentLines("McpCustom", arguments), "\n"), "A: first\nPath: target\nZ: last"; got != want { + t.Fatalf("unknown detail arguments = %q, want %q", got, want) + } + if got, want := toolcallIntentFor("Copy", `{}`), "Copy → "; got != want { + t.Fatalf("known missing fields intent = %q, want %q", got, want) + } + if got := toolcallArgumentLines("Copy", `{}`); len(got) != 0 { + t.Fatalf("known missing fields detail = %q, want no lines", got) + } + + if got, want := toolcallIntentFor("bad\x1bname", `not json`), "badname"; got != want { + t.Fatalf("malformed intent = %q, want %q", got, want) + } + if got := strings.Join(toolcallArgumentLines("McpCustom", "not\x1b json"), "\n"); got != "Original arguments: not json" { + t.Fatalf("malformed detail arguments = %q", got) + } +} + +func TestToolcallPresentationDetailDoesNotTruncateLongValues(t *testing.T) { + content := strings.Repeat("long-content-", 100) + arguments := `{"path":"file.txt","content":"` + content + `"}` + lines := toolcallArgumentLines(toolWriteName, arguments) + if got, want := strings.Join(lines, "\n"), "Path: file.txt\nContent: "+content; got != want { + t.Fatalf("long detail was changed or truncated: %d bytes", len(got)) + } +} diff --git a/cmd/mecatui/ui/toolcalls_repair_test.go b/cmd/mecatui/ui/toolcalls_repair_test.go index bcb9e448fa..143ad79321 100644 --- a/cmd/mecatui/ui/toolcalls_repair_test.go +++ b/cmd/mecatui/ui/toolcalls_repair_test.go @@ -49,7 +49,7 @@ func TestMecatuiToolcallsInspector_Scenario4_DeepArgumentsLinear(t *testing.T) { } } -func TestMecatuiToolcallsInspector_Scenario4_ListIntentCachedAcrossResults(t *testing.T) { +func TestMecatuiToolcallsInspector_Scenario4_ListIntentRefreshesWithCardRevision(t *testing.T) { m := newToolcallsInspectorModel(t) content := strings.Repeat("write payload", 20000) m.conv.addTool("write", "Write", `{"path":"first","content":"`+content+`"}`) @@ -58,7 +58,12 @@ func TestMecatuiToolcallsInspector_Scenario4_ListIntentCachedAcrossResults(t *te if got := s.entries[0].intent; got != "Write first" { t.Fatalf("initial intent: %q", got) } + initialRevision := s.entries[0].revision m.conv.resolveTool("write", "first result", false) + m.syncToolcalls() + if got := s.entries[0]; got.revision != initialRevision+1 || got.intent != "Write first" || !got.resolved { + t.Fatalf("result lifecycle entry = %+v", got) + } runtime.GC() var before, after runtime.MemStats runtime.ReadMemStats(&before) @@ -67,22 +72,24 @@ func TestMecatuiToolcallsInspector_Scenario4_ListIntentCachedAcrossResults(t *te } runtime.ReadMemStats(&after) if used := after.TotalAlloc - before.TotalAlloc; used > 100000 { - t.Fatalf("historical payload copied during result sync: %d bytes", used) + t.Fatalf("historical payload copied during stable result sync: %d bytes", used) } m.conv.addTool("pending", "Write", `{"path":"before"}`) m.syncToolcalls() selected := s.entries[s.selected].blockID + pendingRevision := s.entries[1].revision if !m.conv.scrollback.Tools().ReconcileUnresolved(scrollback.ToolCall{ID: "pending", Name: "Write", Arguments: `{"path":"after"}`}) { t.Fatal("reconcile failed") } m.syncToolcalls() - if s.entries[1].intent != "Write after" || s.entries[s.selected].blockID != selected { + if got := s.entries[1]; got.revision != pendingRevision+1 || got.intent != "Write after" || s.entries[s.selected].blockID != selected { t.Fatalf("reconciled entry or selection stale: %+v", s.entries) } + reconciledRevision := s.entries[1].revision m.conv.resolveTool("pending", "done", false) m.syncToolcalls() - if s.entries[1].intent != "Write after" { - t.Fatalf("result changed cached intent: %+v", s.entries[1]) + if got := s.entries[1]; got.revision != reconciledRevision+1 || got.intent != "Write after" || !got.resolved { + t.Fatalf("result lifecycle changed reconciled entry: %+v", got) } } @@ -173,6 +180,9 @@ func TestMecatuiToolcallsInspector_Scenario5_NormalResizeStaleHitAndNoMouse(t *t m := newToolcallsInspectorModel(t) m.deps.NoAltScreen = false m = addToolcallsForTest(t, m, 5) + m.conv.addTool("reused", "Read", `{"path":"before-reuse.go"}`) + m.conv.resolveTool("reused", "before reuse", false) + m.conv.addTool("reused", "Read", `{"path":"after-reuse.go"}`) m = openToolcallsForTest(t, m) _ = m.View() stale := m.hits.frame[0].id @@ -183,17 +193,40 @@ func TestMecatuiToolcallsInspector_Scenario5_NormalResizeStaleHitAndNoMouse(t *t if s.compact || len(m.hits.frame) == 0 { t.Fatal("normal resize lost live row hits") } - s.selected = len(s.entries) - 1 + target := s.entries[len(s.entries)-1].blockID + s.selected = 0 updated, _ = m.Update(surfaceHitMsg{ID: stale}) m = updated.(Model) - if s.selected != len(s.entries)-1 { + if s.selected != 0 { t.Fatal("stale normal-size hit selected a row") } - fresh := m.hits.frame[0] - updated, _ = m.Update(surfaceHitMsg{ID: fresh.id}) + fresh := -1 + for i, region := range m.hits.frame { + if s.hitItems[region.id] == target { + fresh = i + break + } + } + if fresh < 0 { + t.Fatal("reused-ID call has no fresh click target") + } + x, y := m.metrics.localToGlobal(m.hits.frame[fresh].rect.x0, m.hits.frame[fresh].rect.y0) + updated, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) + m = updated.(Model) + if s.selected != len(s.entries)-1 || s.entries[s.selected].blockID != target { + t.Fatalf("click selected unstable block: selected=%d entries=%#v", s.selected, s.entries) + } + if !s.detail { + t.Fatal("fresh resized row click did not open detail") + } + plain := ansi.Strip(m.View().Content) + if !strings.Contains(plain, "Arguments:") || !strings.Contains(plain, "Path: after-reuse.go") || strings.Contains(plain, "call unavailable") { + t.Fatalf("clicked call detail = %q", plain) + } + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) m = updated.(Model) - if s.selected == len(s.entries)-1 { - t.Fatal("fresh resized row hit did not select") + if s.detail { + t.Fatal("Escape did not return fresh resized row hit to the list") } m.deps.NoMouse = true if view := m.View(); view.MouseMode != 0 { @@ -201,7 +234,7 @@ func TestMecatuiToolcallsInspector_Scenario5_NormalResizeStaleHitAndNoMouse(t *t } before := s.selected region := m.hits.frame[len(m.hits.frame)-1] - x, y := m.metrics.localToGlobal(region.rect.x0, region.rect.y0) + x, y = m.metrics.localToGlobal(region.rect.x0, region.rect.y0) updated, _ = m.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) m = updated.(Model) if s.selected != before { diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 6e85a65045..0ea4489c7e 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2532,6 +2532,9 @@ func (m Model) dispatchSurfaceMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m = mm.(Model) cmd = tea.Batch(cmd, intentCmd) } + if toolcalls, ok := m.modal.(*toolcallsState); ok && toolcalls.detail && toolcalls.detailEntry == nil { + m.syncToolcalls() + } if closed { m.closeModal() return m, tea.Batch(cmd, m.prompt.Focus()), true diff --git a/user-docs/mecatui/using-the-tui.md b/user-docs/mecatui/using-the-tui.md index 18964992ee..870529c49a 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -15,14 +15,14 @@ as it arrives. Tool calls appear as compact cards; Edit and Write cards include their diff. Use `/toolcalls` to browse the current session's tool calls, including calls still -running. Select a visible call by clicking its row or with the navigation keys -shown in the browser, then press `enter` to inspect its complete arguments and -received result. The detail separates call identity and status, arguments, result -or error text, structured content, and resource details when present. In Read results, numbered file lines -have space between the line number and content. Other result text keeps its -original spacing. Scroll long details with the displayed keys; press `esc` to -return to the list, then `esc` again to close it. The browser also works when -you reopen a session's transcript. +running. Click a visible call to inspect its complete arguments and received result, or +select one with the navigation keys shown in the browser and press `enter`. The detail +separates call identity and status, arguments, result or error text, structured content, +and resource details when present. In Read results, numbered file lines have space +between the line number and content. Other result text keeps its original spacing. +Scroll long details with the displayed keys; press `esc` to return to the list, then +`esc` again to close it. The browser also works when you reopen a session's +transcript. In the conversation, `ctrl+t` expands or collapses tool details across the conversation, regardless of which card is selected. Use `/toolcalls` to read one From 3fb31ec933327a921f83fd1f7edcc8d0ee3a8f30 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sun, 4 Oct 2026 11:50:53 -0700 Subject: [PATCH 28/30] fix(mecatui): activate inspector detail through surface intent Refresh only the selected call on click or Enter and keep the TUI usage guide focused on non-obvious behavior. Co-authored-by: Mecatl --- cmd/mecatui/ui/toolcalls.go | 16 +++++++ cmd/mecatui/ui/toolcalls_repair_test.go | 58 +++++++++++++++++++++++++ cmd/mecatui/ui/update.go | 12 ++--- user-docs/mecatui/using-the-tui.md | 16 ++----- 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/cmd/mecatui/ui/toolcalls.go b/cmd/mecatui/ui/toolcalls.go index e58ff6d114..2b9e078be8 100644 --- a/cmd/mecatui/ui/toolcalls.go +++ b/cmd/mecatui/ui/toolcalls.go @@ -49,6 +49,12 @@ type toolcallDetail struct { resultReceived bool } +type toolcallsDetailIntent struct { + blockID scrollback.BlockID +} + +func (toolcallsDetailIntent) isSurfaceIntent() {} + type toolcallsState struct { open bool deps surfaceDeps @@ -56,6 +62,7 @@ type toolcallsState struct { selected int detail bool detailEntry *toolcallDetail + intent surfaceIntent window *bounded.Viewport width int anchor int @@ -69,6 +76,13 @@ type toolcallsState struct { func (*toolcallsState) modalPlacement() modalPlacement { return modalPlacementFill } func (*toolcallsState) Close() {} + +func (s *toolcallsState) takeSurfaceIntent() surfaceIntent { + intent := s.intent + s.intent = nil + return intent +} + func (s *toolcallsState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { hit, ok := msg.(surfaceHitMsg) if !ok || !s.open || s.compact || s.detail { @@ -86,6 +100,7 @@ func (s *toolcallsState) HandleMsg(msg tea.Msg) (tea.Cmd, bool, bool) { s.listFollow = i == len(s.entries)-1 } s.detail = true + s.intent = toolcallsDetailIntent{blockID: s.entries[s.selected].blockID} s.window = new(bounded.Viewport) s.width = 0 s.anchor = 0 @@ -772,6 +787,7 @@ func (s *toolcallsState) HandleKey(msg tea.KeyPressMsg) (tea.Cmd, bool, bool) { case bubbleskey.Matches(msg, s.deps.keys.Choose): if !s.detail { s.detail = true + s.intent = toolcallsDetailIntent{blockID: s.entries[s.selected].blockID} s.window = new(bounded.Viewport) s.width = 0 s.anchor = 0 diff --git a/cmd/mecatui/ui/toolcalls_repair_test.go b/cmd/mecatui/ui/toolcalls_repair_test.go index 143ad79321..948defdb43 100644 --- a/cmd/mecatui/ui/toolcalls_repair_test.go +++ b/cmd/mecatui/ui/toolcalls_repair_test.go @@ -176,6 +176,64 @@ func TestMecatuiToolcallsInspector_Scenario4_RenderedPaginationAndStyle(t *testi } } +func TestMecatuiToolcallsInspector_DetailActivationIntentRefreshesSelectedCall(t *testing.T) { + m := newToolcallsInspectorModel(t) + m = addToolcallsForTest(t, m, 2) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + + if _, handled, _ := s.HandleMsg(surfaceHitMsg{}); !handled || s.takeSurfaceIntent() != nil { + t.Fatal("invalid hit emitted a detail intent") + } + if _, handled, _ := s.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}); !handled || s.takeSurfaceIntent() != nil { + t.Fatal("navigation emitted a detail intent") + } + if _, handled, _ := s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}); !handled { + t.Fatal("Enter was not handled") + } + intent, ok := s.takeSurfaceIntent().(toolcallsDetailIntent) + if !ok || intent.blockID != s.entries[s.selected].blockID || s.takeSurfaceIntent() != nil { + t.Fatalf("Enter detail intent = %#v", intent) + } + if _, _, _ = s.HandleKey(tea.KeyPressMsg{Code: tea.KeyEnter}); s.takeSurfaceIntent() != nil { + t.Fatal("Enter in detail emitted a second intent") + } + + keyboard := newToolcallsInspectorModel(t) + keyboard.conv.addTool("keyboard", "Read", `{"path":"keyboard.go"}`) + keyboard = openToolcallsForTest(t, keyboard) + updated, _ := keyboard.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + keyboard = updated.(Model) + if detail := toolcallsForTest(t, keyboard).detailEntry; detail == nil || detail.callID != "keyboard" || !strings.Contains(ansi.Strip(keyboard.View().Content), "Path: keyboard.go") { + t.Fatalf("keyboard detail did not refresh the selected call: %#v", detail) + } + + clicked := newToolcallsInspectorModel(t) + clicked.conv.addTool("first", "Read", `{"path":"first.go"}`) + clicked.conv.addTool("second", "Read", `{"path":"clicked.go"}`) + clicked = openToolcallsForTest(t, clicked) + _ = clicked.View() + s = toolcallsForTest(t, clicked) + target := s.entries[1].blockID + regionIndex := -1 + for i, candidate := range clicked.hits.frame { + if s.hitItems[candidate.id] == target { + regionIndex = i + break + } + } + if regionIndex < 0 { + t.Fatal("clicked call has no hit region") + } + region := clicked.hits.frame[regionIndex] + x, y := clicked.metrics.localToGlobal(region.rect.x0, region.rect.y0) + updated, _ = clicked.Update(tea.MouseClickMsg{Button: tea.MouseLeft, X: x, Y: y}) + clicked = updated.(Model) + if detail := toolcallsForTest(t, clicked).detailEntry; detail == nil || detail.callID != "second" || !strings.Contains(ansi.Strip(clicked.View().Content), "Path: clicked.go") { + t.Fatalf("clicked detail did not refresh the selected call: %#v", detail) + } +} + func TestMecatuiToolcallsInspector_Scenario5_NormalResizeStaleHitAndNoMouse(t *testing.T) { m := newToolcallsInspectorModel(t) m.deps.NoAltScreen = false diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 0ea4489c7e..a71466f19b 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2498,9 +2498,6 @@ func (m Model) dispatchSurfaceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool m = mm.(Model) cmd = tea.Batch(cmd, intentCmd) } - if toolcalls, ok := m.modal.(*toolcallsState); ok && toolcalls.detail && toolcalls.detailEntry == nil { - m.syncToolcalls() - } if closed { m.closeModal() return m, tea.Batch(cmd, m.prompt.Focus()), true @@ -2532,9 +2529,6 @@ func (m Model) dispatchSurfaceMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { m = mm.(Model) cmd = tea.Batch(cmd, intentCmd) } - if toolcalls, ok := m.modal.(*toolcallsState); ok && toolcalls.detail && toolcalls.detailEntry == nil { - m.syncToolcalls() - } if closed { m.closeModal() return m, tea.Batch(cmd, m.prompt.Focus()), true @@ -2635,6 +2629,12 @@ func (m Model) applySurfaceIntent(intent surfaceIntent) (model tea.Model, cmd te if model, cmd, handled, stopSurfaceDispatch := m.applySessionsSurfaceIntent(intent); handled { return model, cmd, stopSurfaceDispatch } + if detail, ok := intent.(toolcallsDetailIntent); ok { + if s, ok := m.modal.(*toolcallsState); ok && s.detail && s.selected >= 0 && s.selected < len(s.entries) && s.entries[s.selected].blockID == detail.blockID { + s.refreshDetail(&m.conv.scrollback) + } + return m, nil, false + } return m, nil, false } diff --git a/user-docs/mecatui/using-the-tui.md b/user-docs/mecatui/using-the-tui.md index 870529c49a..efd4525e86 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -14,19 +14,9 @@ the agent without waiting for the current run to finish. Assistant text streams as it arrives. Tool calls appear as compact cards; Edit and Write cards include their diff. -Use `/toolcalls` to browse the current session's tool calls, including calls still -running. Click a visible call to inspect its complete arguments and received result, or -select one with the navigation keys shown in the browser and press `enter`. The detail -separates call identity and status, arguments, result or error text, structured content, -and resource details when present. In Read results, numbered file lines have space -between the line number and content. Other result text keeps its original spacing. -Scroll long details with the displayed keys; press `esc` to return to the list, then -`esc` again to close it. The browser also works when you reopen a session's -transcript. - -In the conversation, `ctrl+t` expands or collapses tool details across the -conversation, regardless of which card is selected. Use `/toolcalls` to read one -call's full result without expanding the other cards. +Use `/toolcalls` to inspect calls in the current session, even during a run or +when revisiting its transcript. `ctrl+t` expands tool details across the +conversation; `/toolcalls` focuses on one call at a time. ## Attach a local file From 1b5d15868c02b296e2af8572036f83bb36e8b7af Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sun, 4 Oct 2026 13:14:52 -0700 Subject: [PATCH 29/30] refactor(mecatui): isolate inspector detail intent handling Keep the surface intent dispatcher symmetric with other modal intent families while refreshing only the selected inspector call. Co-authored-by: Mecatl --- cmd/mecatui/ui/update.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index a71466f19b..4b0c455e48 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -2615,6 +2615,17 @@ func (m Model) applySessionsSurfaceIntent(intent surfaceIntent) (model tea.Model } } +func (m Model) applyToolcallsSurfaceIntent(intent surfaceIntent) bool { + detail, ok := intent.(toolcallsDetailIntent) + if !ok { + return false + } + if s, ok := m.modal.(*toolcallsState); ok && s.detail && s.selected >= 0 && s.selected < len(s.entries) && s.entries[s.selected].blockID == detail.blockID { + s.refreshDetail(&m.conv.scrollback) + } + return true +} + // applySurfaceIntent applies a drained surface intent synchronously in the same // Tea Update. Returned commands still run asynchronously. stopSurfaceDispatch // tells the caller to skip common dispatch post-processing after a root-owned @@ -2629,10 +2640,7 @@ func (m Model) applySurfaceIntent(intent surfaceIntent) (model tea.Model, cmd te if model, cmd, handled, stopSurfaceDispatch := m.applySessionsSurfaceIntent(intent); handled { return model, cmd, stopSurfaceDispatch } - if detail, ok := intent.(toolcallsDetailIntent); ok { - if s, ok := m.modal.(*toolcallsState); ok && s.detail && s.selected >= 0 && s.selected < len(s.entries) && s.entries[s.selected].blockID == detail.blockID { - s.refreshDetail(&m.conv.scrollback) - } + if m.applyToolcallsSurfaceIntent(intent) { return m, nil, false } return m, nil, false From 338e31abee43df56c15664ad7dcf0a8c5e0d1f92 Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Sun, 4 Oct 2026 13:24:34 -0700 Subject: [PATCH 30/30] fix: tidy Zipkin exporter checksums Remove obsolete v1.35.0 checksums after upgrading both modules to v1.45.0. Co-authored-by: Mecatl --- go.sum | 2 -- integration/microvm/go.sum | 2 -- 2 files changed, 4 deletions(-) diff --git a/go.sum b/go.sum index b6ca639402..426ae951df 100644 --- a/go.sum +++ b/go.sum @@ -691,8 +691,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1Y go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4= go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= -go.opentelemetry.io/otel/exporters/zipkin v1.35.0 h1:OAx1AdClqTB3pz+B4osLuGjx8kubys8ByW7yx0lF454= -go.opentelemetry.io/otel/exporters/zipkin v1.35.0/go.mod h1:hz5wHI9hmCXzwkXFGZ05ObZw2Q2t/AeAZ18PExd2uSM= go.opentelemetry.io/otel/exporters/zipkin v1.45.0 h1:KN3btaILMTxR4QDHVGAO87lq5ButzK7l+kIfLuxQ1oA= go.opentelemetry.io/otel/exporters/zipkin v1.45.0/go.mod h1:yNcodmUclM4InyWoOwX/YW4Jri0Gj5FWAlM+NqCrtqY= go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0= diff --git a/integration/microvm/go.sum b/integration/microvm/go.sum index 95ba7d1f69..c0c1523c9c 100644 --- a/integration/microvm/go.sum +++ b/integration/microvm/go.sum @@ -813,8 +813,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1Y go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4= go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= -go.opentelemetry.io/otel/exporters/zipkin v1.35.0 h1:OAx1AdClqTB3pz+B4osLuGjx8kubys8ByW7yx0lF454= -go.opentelemetry.io/otel/exporters/zipkin v1.35.0/go.mod h1:hz5wHI9hmCXzwkXFGZ05ObZw2Q2t/AeAZ18PExd2uSM= go.opentelemetry.io/otel/exporters/zipkin v1.45.0 h1:KN3btaILMTxR4QDHVGAO87lq5ButzK7l+kIfLuxQ1oA= go.opentelemetry.io/otel/exporters/zipkin v1.45.0/go.mod h1:yNcodmUclM4InyWoOwX/YW4Jri0Gj5FWAlM+NqCrtqY= go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0=