Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7491472
feat(mecatui): add tool-call inspector core
jbeda Oct 2, 2026
b8ee76b
fix(mecatui): include delegated parents and fill inspector region
jbeda Oct 2, 2026
d4be4bb
chore: mark tool-call inspector implementation in progress
jbeda Oct 2, 2026
3de2bab
Implement mecatui tool-call detail and usage guidance
jbeda Oct 2, 2026
37479aa
Keep tool-call detail anchored to canonical scrollback on resize
jbeda Oct 2, 2026
59b75c8
Fix mecatui inspector lint
jbeda Oct 2, 2026
10a69ec
chore: mark tool-call inspector candidate landed
jbeda Oct 2, 2026
88091bb
Repair tool-call inspector hints, inventory projection, and boundary …
jbeda Oct 2, 2026
0df64b8
Correct mecatui tool detail shortcut documentation
jbeda Oct 2, 2026
c025b2e
fix(mecatui): satisfy toolcalls inspector lint
jbeda Oct 2, 2026
2e79cbb
test(mecatui): cover inspector received result and loaded replacement
jbeda Oct 2, 2026
00ee4da
fix(mecatui): show compact too-small hint with Escape
jbeda Oct 2, 2026
4b7de7d
docs: authorize inspector presentation and row selection amendment
jbeda Oct 2, 2026
9684983
feat(mecatui): present readable tool call arguments
jbeda Oct 2, 2026
ceea5a3
fix(mecatui): safely present nested inspector arguments
jbeda Oct 2, 2026
65c5f1d
fix(mecatui): bound toolcall list projection and pass scoped lint
jbeda Oct 2, 2026
2f2f02f
feat(mecatui): distinguish inspector detail sections and Read gutter
jbeda Oct 2, 2026
241d663
feat(mecatui): select inspector calls by click
jbeda Oct 2, 2026
d45b2a2
docs: mark amended inspector implementation candidate landed
jbeda Oct 2, 2026
4227c78
fix(mecatui): bound inspector argument traversal and cache list intents
jbeda Oct 2, 2026
38cb55c
style(mecatui): align inspector list and detail presentation
jbeda Oct 2, 2026
87b6c4f
test(mecatui): pin inspector overflow hits and wrapped result tone
jbeda Oct 2, 2026
019eca6
fix(mecatui): keep inspector wheel selection and align tool status cues
jbeda Oct 3, 2026
be7f0be
test(mecatui): pin live inspector status tones in list and detail
jbeda Oct 3, 2026
76f7836
fix: clear inspector PR CI gates
jbeda Oct 4, 2026
6ec1221
docs(acceptance): authorize inspector click-to-open
jbeda Oct 4, 2026
4b808bb
refactor(mecatui): simplify toolcall presentation and open clicked de…
jbeda Oct 4, 2026
3fb31ec
fix(mecatui): activate inspector detail through surface intent
jbeda Oct 4, 2026
1b5d158
refactor(mecatui): isolate inspector detail intent handling
jbeda Oct 4, 2026
338e31a
fix: tidy Zipkin exporter checksums
jbeda Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/mecatui/ui/agents_overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/ui/approval_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion cmd/mecatui/ui/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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},
Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/ui/builtins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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",
}
Expand Down
8 changes: 8 additions & 0 deletions cmd/mecatui/ui/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion cmd/mecatui/ui/footer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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":
Expand Down
17 changes: 17 additions & 0 deletions cmd/mecatui/ui/internal/bounded/scenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions cmd/mecatui/ui/internal/bounded/viewport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
40 changes: 40 additions & 0 deletions cmd/mecatui/ui/internal/scrollback/scrollback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
7 changes: 4 additions & 3 deletions cmd/mecatui/ui/internal/scrollback/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions cmd/mecatui/ui/internal/scrollback/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")}}}
Expand Down
6 changes: 3 additions & 3 deletions cmd/mecatui/ui/palette_bounded_list_scenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 22 additions & 22 deletions cmd/mecatui/ui/palette_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,20 @@ 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.
if !strings.Contains(view, "/clear") || !strings.Contains(view, "/help") ||
!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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -241,21 +241,21 @@ func TestPalettePrefixFilters(t *testing.T) {

// TestPaletteNavigateAndComplete verifies ↓ moves the selection and enter
// completes a workspace command into the input as "/<name> ", 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).
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading