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/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/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/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/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/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/internal/scrollback/scrollback.go b/cmd/mecatui/ui/internal/scrollback/scrollback.go index 32c6f13b18..f190ff76a4 100644 --- a/cmd/mecatui/ui/internal/scrollback/scrollback.go +++ b/cmd/mecatui/ui/internal/scrollback/scrollback.go @@ -80,6 +80,46 @@ 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 + Revision uint64 + 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, 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 // separately rendered changed-files appendix. func (c *Conversation) Len() int { return len(c.cards) } 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/internal/scrollback/tool_test.go b/cmd/mecatui/ui/internal/scrollback/tool_test.go index ee1fcffa33..2f458622b9 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.ID != 1 || got.Revision != 1 || 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/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/render.go b/cmd/mecatui/ui/render.go index c7de3d37f5..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 @@ -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": @@ -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/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/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/sessions_surface.go b/cmd/mecatui/ui/sessions_surface.go index 64a1befd24..dee030bf6a 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}) } } } @@ -1216,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/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 new file mode 100644 index 0000000000..2b9e078be8 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls.go @@ -0,0 +1,837 @@ +package ui + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "unicode/utf8" + + bubbleskey "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/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" +) + +// 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 + revision uint64 + index int + name string + intent string + resolved bool + failed bool +} + +type toolcallDetail struct { + callID, name, intent string + result scrollback.ToolResult + resolved, failed bool + resultReceived bool +} + +type toolcallsDetailIntent struct { + blockID scrollback.BlockID +} + +func (toolcallsDetailIntent) isSurfaceIntent() {} + +type toolcallsState struct { + open bool + deps surfaceDeps + entries []toolcallEntry + selected int + detail bool + detailEntry *toolcallDetail + intent surfaceIntent + window *bounded.Viewport + width int + anchor int + 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 +} + +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 { + 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) + 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 + s.follow = false + return nil, true, false + } + } + return nil, true, false +} + +func toolcallsTooSmallHint(width int, dismiss string) string { + if width <= 0 { + return "" + } + message := "too small · " + dismiss + if ansi.StringWidth(message) <= width { + return message + } + message = "small · " + dismiss + if ansi.StringWidth(message) <= width { + return message + } + if ansi.StringWidth(dismiss) <= width { + return dismiss + } + 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 + 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" + } +} + +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)) + 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 []toolcallDetailRow{{text: "Original arguments: " + terminaltext.Sanitize(arguments), label: "Original arguments:", kind: toolcallArgument}} + } + var trailing any + if decoder.Decode(&trailing) != io.EOF { + return []toolcallDetailRow{{text: "Original arguments: " + terminaltext.Sanitize(arguments), label: "Original arguments:", kind: toolcallArgument}} + } + + presentation := toolcallPresentations[name] + ordered := append([]string(nil), presentation.argumentKeys...) + 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([]toolcallDetailRow, 0, len(ordered)) + for _, key := range ordered { + if value, ok := fields[key]; ok { + lines = appendArgumentTree(lines, argumentLabel(key), value) + } + } + return lines +} + +// 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 []toolcallDetailRow, label string, value any) []toolcallDetailRow { + 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) + ":" + 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 { + add(" (empty object)") + continue + } + add("") + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + for i := len(keys) - 1; i >= 0; i-- { + stack = append(stack, item{argumentLabel(keys[i]), v[keys[i]], current.depth + 1}) + } + case []any: + if len(v) == 0 { + add(" (empty array)") + continue + } + add("") + for i := len(v) - 1; i >= 0; i-- { + stack = append(stack, item{fmt.Sprintf("[%d]", i), v[i], current.depth + 1}) + } + case string: + add(" " + terminaltext.Sanitize(v)) + case json.Number: + add(" " + terminaltext.Sanitize(string(v))) + case bool: + add(" " + fmt.Sprint(v)) + default: + add(" null") + } + } + return lines +} + +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 { + 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 { + return terminaltext.SanitizeSingleLine(name) + " " + argumentSummary(raw) + } + } + return terminaltext.SanitizeSingleLine(name) +} + +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) + } + return terminaltext.Sanitize(string(raw)) +} + +func argumentLabel(key string) string { + if key == "" { + return "(empty key)" + } + labels := map[string]string{ + "uri": "URI", toolURLArg: "URL", "old_string": "Old string", "new_string": "New string", + } + if label, ok := labels[key]; ok { + return label + } + _, size := utf8.DecodeRuneInString(key) + return strings.ToUpper(key[:size]) + strings.ReplaceAll(key[size:], "_", " ") +} + +func toolcallIntentFor(name, arguments string) string { + 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 { 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.revision != metadata.Revision { + intent = ansi.Truncate(terminaltext.SanitizeSingleLine(toolcallIntentFor(metadata.Name, metadata.Arguments)), 120, "…") + } + entries = append(entries, toolcallEntry{ + blockID: metadata.ID, revision: metadata.Revision, index: i, + name: ansi.Truncate(terminaltext.SanitizeSingleLine(metadata.Name), 120, "…"), + intent: intent, + resolved: metadata.Resolved, + failed: metadata.Failed || subagentStopErrored(metadata.Stop), + }) + } + 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) { + if opening { + s.listFollow = true + } + var selected scrollback.BlockID + 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 + } + 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.toolcallEntriesSince(s.entries), 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) { + s.compact = false + s.hitItems = nil + 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"), 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 + } + 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) + 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) + } + items := make([]bounded.ListItem, len(s.entries)) + for i, entry := range s.entries { + 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, 2, bounded.Clip) + s.list.SetItems(items) + 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 +} + +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, 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(renderToolcallListRow(presentation, statusStyle), 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 + } + } + if view.Below > 0 { + body = append(body, line(s.deps.theme.Style("muted"), fmt.Sprintf("↓ %d items", view.Below))) + } + 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") + } + entry := *s.detailEntry + 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 := s.styledToolcallDetailLines(entry) + if !s.follow && s.width > 0 && s.width != width { + oldRows := toolcallRowCounts(content, s.width) + newRows := toolcallRowCounts(content, width) + 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) + within := s.anchor / width + s.window.SetOffset(newStart+min(within, newRows[i]-1), totalNew) + 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) + } + header = append(header, view.Rows...) + for len(header) < height-1 { + header = append(header, "") + } + return strings.Join(append(header, footer), "\n") +} + +func (s *toolcallsState) styledToolcallDetailLines(entry toolcallDetail) []string { + rows := toolcallDetailRows(entry) + content := make([]string, len(rows)) + for i, row := range rows { + text := terminaltext.Sanitize(row.text) + switch row.kind { + case toolcallIdentity: + content[i] = s.styledToolcallIdentity(row) + 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 +} + +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 { + 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 (s *toolcallsState) recordAnchor() { + if s.width == 0 || s.detailEntry == nil { + return + } + start := 0 + 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 + } + start += count + } +} + +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 { + glyph, status, style := toolcallStatus(entry.resolved, entry.failed) + + lines := []toolcallDetailRow{ + {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}, + } + lines = append(lines, toolcallArgumentRows(entry.name, entry.intent)...) + if !entry.resultReceived { + return append(lines, toolcallDetailRow{}, toolcallDetailRow{text: "Result: pending", kind: toolcallHeading}) + } + + result := entry.result + lines = append(lines, toolcallDetailRow{}) + if entry.failed { + lines = append(lines, toolcallDetailRow{text: "Error:", kind: toolcallError}) + } else { + lines = append(lines, toolcallDetailRow{text: "Result:", kind: toolcallHeading}) + } + if 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 []toolcallDetailRow + for _, a := range result.Artifacts { + switch client.ContentBlockKind(a.Kind) { + case client.ContentBlockText: + if a.Text != "" && a.Text != result.Body { + 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, 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, toolcallDetailRow{text: "Embedded resource (" + terminaltext.Sanitize(a.MIMEType) + ", binary content)"}) + } else { + resources = append(resources, toolcallDetailRow{text: "Embedded resource: " + terminaltext.Sanitize(a.Text), label: "Embedded resource:", kind: toolcallField}) + } + case client.ContentBlockImage, client.ContentBlockAudio: + resources = append(resources, toolcallDetailRow{text: a.Kind + " (" + terminaltext.Sanitize(a.MIMEType) + ", media content)"}) + } + } + if structured != "" { + lines = append(lines, toolcallDetailRow{text: "Structured content · Structured JSON:", kind: toolcallHeading}, toolcallDetailRow{text: terminaltext.Sanitize(structured)}) + } + if len(resources) > 0 { + lines = append(lines, toolcallDetailRow{}, toolcallDetailRow{text: "Resources", kind: toolcallHeading}) + 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 { + s.detail = false + s.detailEntry = nil + return nil, true, false + } + return nil, true, true + } + if s.compact || len(s.entries) == 0 { + return nil, true, false + } + move := bounded.LineDown + switch { + case bubbleskey.Matches(msg, s.deps.keys.Down): + case bubbleskey.Matches(msg, s.deps.keys.Up): + move = bounded.LineUp + case bubbleskey.Matches(msg, s.deps.keys.ScrollD): + move = bounded.PageDown + case bubbleskey.Matches(msg, s.deps.keys.ScrollU): + move = bounded.PageUp + case bubbleskey.Matches(msg, s.deps.keys.ScrollBottom): + move = bounded.End + case bubbleskey.Matches(msg, s.deps.keys.ScrollTop): + move = bounded.Top + 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 + 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()) + if !s.follow { + s.recordAnchor() + } + } + return nil, true, false + } + 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 +} + +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()) + if !s.follow { + s.recordAnchor() + } + return nil, true + } + if s.list == nil { + return nil, true + } + 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_boundary_test.go b/cmd/mecatui/ui/toolcalls_boundary_test.go new file mode 100644 index 0000000000..aea49cf027 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_boundary_test.go @@ -0,0 +1,124 @@ +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) + 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"}, + }}}) + 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) + } + 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 < 12; i++ { + body, _ := s.Render(width, 9) + raw += body + pages += stripANSIstr(body) + s.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgDown}) + } + } + 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{"provisional text", `{"old":true}`, `{"stale":true}`, bytes, "YmluYXJ5LWJ5dGVzLXVuaXF1ZQ=="} { + if strings.Contains(pages, forbidden) { + t.Errorf("projection exposed %q", forbidden) + } + } + for _, unsafe := range []string{"\x1b]8;;evil", "\x1b[31m"} { + if strings.Contains(raw, 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 new file mode 100644 index 0000000000..b40589b58f --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_detail_scenario_test.go @@ -0,0 +1,570 @@ +package ui + +import ( + "fmt" + "reflect" + "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/theme" + "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) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + *m = updated.(Model) + 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_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}} + 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 := 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}) + m.conv.addTool("later", "Read", `{"path":"later.go"}`) + m.syncToolcalls() + body, _ = s.Render(100, 10) + row := s.list.ViewWithIndicators(6, false).Rows[0] + 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 = applyAll(m, client.ToolCallMsg{ID: "lifecycle", Name: "Read", Args: `{"path":"lifecycle.go"}`}) + m = openToolcallsForTest(t, m) + s := toolcallsForTest(t, m) + 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(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) + } + } + + 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") + } + detail := m.View().Content + if s.compact { + t.Fatalf("narrow detail unexpectedly used compact fallback: %q", detail) + } + 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) + } + }) + } +} + +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, "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}) + 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, 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, 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, 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, 70, 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(70, 8) + s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyPgUp}) + 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(70, 8) + s4.HandleKey(tea.KeyPressMsg{Code: tea.KeyEsc}) + 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) + } + + 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, 70, 8) + for i := 0; i < 7; i++ { + s3.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + } + 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, 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, 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) + } + 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 + 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, 70, 8) + for i := 0; i < 10; i++ { + s5.HandleKey(tea.KeyPressMsg{Code: tea.KeyDown}) + } + original := strings.Split(inspectorDetail(t, s5, 70, 8), "\n")[2] + marker := original[2:6] + 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) + } + } +} + +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) + } + }) + } +} + +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/toolcalls_inspector_scenario_test.go b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go new file mode 100644 index 0000000000..6f52e83b75 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_inspector_scenario_test.go @@ -0,0 +1,1032 @@ +package ui + +import ( + "fmt" + "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/bounded" + "github.com/stacklok/mecatl/cmd/mecatui/ui/internal/scrollback" +) + +// 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_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"}`) + 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, 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) + 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 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) + } + compact, _ := s.Render(10, 3) + 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) + 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) { + 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.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) + 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) + } +} + +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 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 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 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 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 TestMecatuiToolcallsInspector_Scenario5_ClickSelectsVisibleCall(t *testing.T) { + m := newToolcallsInspectorModel(t) + m.deps.NoAltScreen = false + 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) + beforeConversation := m.vp.YOffset() + _ = m.View() + + s := toolcallsForTest(t, m) + 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 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) + } + 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 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("detail wheel moved hidden conversation from %d to %d", beforeConversation, got) + } + + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = updated.(Model) + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyHome}) + m = updated.(Model) + 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 { + 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) + _ = 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) + } + 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 selected call detail: detail=%t block=%d, want %d", s.detail, s.entries[s.selected].blockID, 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)) + } +} + +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) + 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 + 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) + } + 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) + _ = 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) + } + if !toolcallsForTest(t, reused).detail { + t.Fatal("reused ID click did not open the selected call detail") + } +} + +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/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 new file mode 100644 index 0000000000..948defdb43 --- /dev/null +++ b/cmd/mecatui/ui/toolcalls_repair_test.go @@ -0,0 +1,301 @@ +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_ListIntentRefreshesWithCardRevision(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) + } + 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) + 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 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 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 got := s.entries[1]; got.revision != reconciledRevision+1 || got.intent != "Write after" || !got.resolved { + t.Fatalf("result lifecycle changed reconciled entry: %+v", got) + } +} + +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("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} { + 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("toolName").Render(heading)) { + t.Fatalf("section %q not distinguished in rendered detail: %q", heading, styled) + } + } +} + +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 + 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 + 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") + } + target := s.entries[len(s.entries)-1].blockID + s.selected = 0 + updated, _ = m.Update(surfaceHitMsg{ID: stale}) + m = updated.(Model) + if s.selected != 0 { + t.Fatal("stale normal-size hit selected a row") + } + 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.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 { + 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") + } +} diff --git a/cmd/mecatui/ui/update.go b/cmd/mecatui/ui/update.go index 457267a1e4..4b0c455e48 100644 --- a/cmd/mecatui/ui/update.go +++ b/cmd/mecatui/ui/update.go @@ -1338,19 +1338,16 @@ 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 - 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) } 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 @@ -1456,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: @@ -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,6 +2640,9 @@ 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 m.applyToolcallsSurfaceIntent(intent) { + return m, nil, false + } return m, nil, false } diff --git a/docs/acceptance/mecatui-toolcalls-inspector.md b/docs/acceptance/mecatui-toolcalls-inspector.md index 88165fac86..a05d9911ef 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:** proposed, 2026-10-02. The directing operator resolved the interaction and scope decisions before plan review; approval requires the Plan / Interface PR merge. +**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). -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 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,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 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; `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. -- **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-open, 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; 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 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` + ## 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. diff --git a/docs/tui.md b/docs/tui.md index 808726a0c0..0975589b16 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -51,12 +51,21 @@ 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. The saved-memory -browser demonstrates list/detail ownership; Sessions demonstrates the -conversation-region fill placement for content that needs more room. Fill +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, prompt, and footer. 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..426ae951df 100644 --- a/go.sum +++ b/go.sum @@ -691,8 +691,8 @@ 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= 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..c0c1523c9c 100644 --- a/integration/microvm/go.sum +++ b/integration/microvm/go.sum @@ -813,8 +813,8 @@ 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= 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()) 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 94b559494c..efd4525e86 100644 --- a/user-docs/mecatui/using-the-tui.md +++ b/user-docs/mecatui/using-the-tui.md @@ -14,8 +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. -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 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 @@ -96,9 +97,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