handle agent tool

Kujtim Hoxha created

Change summary

internal/tui/components/anim/anim.go              | 23 ++++
internal/tui/components/chat/list.go              | 76 ++++++++++++-
internal/tui/components/chat/messages/messages.go |  3 
internal/tui/components/chat/messages/renderer.go | 55 ++++++++++
internal/tui/components/chat/messages/tool.go     | 89 +++++++++++++++-
internal/tui/components/core/list/list.go         |  3 
6 files changed, 227 insertions(+), 22 deletions(-)

Detailed changes

internal/tui/components/anim/anim.go 🔗

@@ -75,6 +75,11 @@ func cycleColors(id string) tea.Cmd {
 	})
 }
 
+type Animation interface {
+	util.Model
+	ID() string
+}
+
 // anim is the model that manages the animation that displays while the
 // output is being generated.
 type anim struct {
@@ -88,7 +93,15 @@ type anim struct {
 	id              string
 }
 
-func New(cyclingCharsSize uint, label string) util.Model {
+type animOption func(*anim)
+
+func WithId(id string) animOption {
+	return func(a *anim) {
+		a.id = id
+	}
+}
+
+func New(cyclingCharsSize uint, label string, opts ...animOption) Animation {
 	// #nosec G115
 	n := min(int(cyclingCharsSize), maxCyclingChars)
 
@@ -105,6 +118,10 @@ func New(cyclingCharsSize uint, label string) util.Model {
 		id:       id.String(),
 	}
 
+	for _, opt := range opts {
+		opt(&c)
+	}
+
 	// If we're in truecolor mode (and there are enough cycling characters)
 	// color the cycling characters with a gradient ramp.
 	const minRampSize = 3
@@ -204,6 +221,10 @@ func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	}
 }
 
+func (a anim) ID() string {
+	return a.id
+}
+
 func (a *anim) updateChars(chars *[]cyclingChar) {
 	for i, c := range *chars {
 		switch c.state(a.start) {

internal/tui/components/chat/list.go 🔗

@@ -8,7 +8,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/llm/agent"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/pubsub"
 	"github.com/opencode-ai/opencode/internal/session"
@@ -106,9 +106,54 @@ func (m *messageListCmp) View() string {
 }
 
 // handleChildSession handles messages from child sessions (agent tools).
-// TODO: update the agent tool message with the changes
-func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
-	// Implementation pending
+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
+	var cmds []tea.Cmd
+	if len(event.Payload.ToolCalls()) == 0 {
+		return nil
+	}
+	items := m.listCmp.Items()
+	toolCallInx := NotFound
+	var toolCall messages.ToolCallCmp
+	for i := len(items) - 1; i >= 0; i-- {
+		if msg, ok := items[i].(messages.ToolCallCmp); ok {
+			if msg.GetToolCall().ID == event.Payload.SessionID {
+				toolCallInx = i
+				toolCall = msg
+			}
+		}
+	}
+	if toolCallInx == NotFound {
+		return nil
+	}
+	nestedToolCalls := toolCall.GetNestedToolCalls()
+	for _, tc := range event.Payload.ToolCalls() {
+		found := false
+		for existingInx, existingTC := range nestedToolCalls {
+			if existingTC.GetToolCall().ID == tc.ID {
+				nestedToolCalls[existingInx].SetToolCall(tc)
+				found = true
+				break
+			}
+		}
+		if !found {
+			nestedCall := messages.NewToolCallCmp(
+				event.Payload.ID,
+				tc,
+				messages.WithToolCallNested(true),
+			)
+			cmds = append(cmds, nestedCall.Init())
+			nestedToolCalls = append(
+				nestedToolCalls,
+				nestedCall,
+			)
+		}
+	}
+	toolCall.SetNestedToolCalls(nestedToolCalls)
+	m.listCmp.UpdateItem(
+		toolCallInx,
+		toolCall,
+	)
+	return tea.Batch(cmds...)
 }
 
 // handleMessageEvent processes different types of message events (created/updated).
@@ -116,16 +161,16 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
 	switch event.Type {
 	case pubsub.CreatedEvent:
 		if event.Payload.SessionID != m.session.ID {
-			m.handleChildSession(event)
-			return nil
+			return m.handleChildSession(event)
 		}
-
 		if m.messageExists(event.Payload.ID) {
 			return nil
 		}
-
 		return m.handleNewMessage(event.Payload)
 	case pubsub.UpdatedEvent:
+		if event.Payload.SessionID != m.session.ID {
+			return m.handleChildSession(event)
+		}
 		return m.handleUpdateAssistantMessage(event.Payload)
 	}
 	return nil
@@ -196,8 +241,6 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
 	// Find existing assistant message and tool calls for this message
 	assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
 
-	logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantIndex, "toolCalls", existingToolCalls)
-
 	// Handle assistant message content
 	if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
 		cmds = append(cmds, cmd)
@@ -389,6 +432,19 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 	for _, tc := range msg.ToolCalls() {
 		options := m.buildToolCallOptions(tc, msg, toolResultMap)
 		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
+		// If this tool call is the agent tool, fetch nested tool calls
+		if tc.Name == agent.AgentToolName {
+			nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
+			nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
+			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
+			for _, nestedMsg := range nestedUIMessages {
+				if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
+					toolCall.SetIsNested(true)
+					nestedToolCalls = append(nestedToolCalls, toolCall)
+				}
+			}
+			uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
+		}
 	}
 
 	return uiMessages

internal/tui/components/chat/messages/messages.go 🔗

@@ -7,6 +7,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/llm/models"
@@ -80,7 +81,7 @@ func (m *messageCmp) Init() tea.Cmd {
 // Manages animation updates for spinning messages and stops animation when appropriate.
 func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case anim.ColorCycleMsg, anim.StepCharsMsg:
+	case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
 		m.spinning = m.shouldSpin()
 		if m.spinning {
 			u, cmd := m.anim.Update(msg)

internal/tui/components/chat/messages/renderer.go 🔗

@@ -91,7 +91,14 @@ func (pb *paramBuilder) build() []string {
 
 // renderWithParams provides a common rendering pattern for tools with parameters
 func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
-	header := makeHeader(toolName, v.textWidth(), args...)
+	width := v.textWidth()
+	if v.isNested {
+		width -= 3 // Adjust for nested tool call indentation
+	}
+	header := makeHeader(toolName, width, args...)
+	if v.isNested {
+		return v.style().Render(header)
+	}
 	if res, done := earlyState(header, v); done {
 		return res
 	}
@@ -117,6 +124,7 @@ func init() {
 	registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
 	registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} })
 	registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
+	registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
 }
 
 // -----------------------------------------------------------------------------
@@ -467,6 +475,51 @@ func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
 	})
 }
 
+// -----------------------------------------------------------------------------
+//  Task renderer
+// -----------------------------------------------------------------------------
+
+// agentRenderer handles project-wide diagnostic information
+type agentRenderer struct {
+	baseRenderer
+}
+
+// Render displays agent task parameters and result content
+func (tr agentRenderer) Render(v *toolCallCmp) string {
+	var params agent.AgentParams
+	if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
+		return tr.renderError(v, "Invalid task parameters")
+	}
+	prompt := params.Prompt
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+	args := newParamBuilder().addMain(prompt).build()
+
+	header := makeHeader("Task", v.textWidth(), args...)
+	parts := []string{header}
+	for _, call := range v.nestedToolCalls {
+		parts = append(parts, call.View())
+	}
+
+	if v.result.ToolCallID == "" {
+		v.spinning = true
+		parts = append(parts, v.anim.View())
+	} else {
+		v.spinning = false
+	}
+
+	header = lipgloss.JoinVertical(
+		lipgloss.Left,
+		parts...,
+	)
+
+	if v.result.ToolCallID == "" {
+		return header
+	}
+
+	body := renderPlainContent(v, v.result.Content)
+	return joinHeaderBody(header, body)
+}
+
 // makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
 func makeHeader(tool string, width int, params ...string) string {
 	prefix := tool + ": "

internal/tui/components/chat/messages/tool.go 🔗

@@ -3,10 +3,10 @@ package messages
 import (
 	"fmt"
 
+	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
-	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/tui/components/anim"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
@@ -28,13 +28,17 @@ type ToolCallCmp interface {
 	SetCancelled()                     // Mark as cancelled
 	ParentMessageId() string           // Get parent message ID
 	Spinning() bool                    // Animation state for pending tools
+	GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
+	SetNestedToolCalls([]ToolCallCmp)  // Set nested tool calls
+	SetIsNested(bool)                  // Set whether this tool call is nested
 }
 
 // toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
 // It handles rendering of tool execution states including pending, completed, and error states.
 type toolCallCmp struct {
-	width   int  // Component width for text wrapping
-	focused bool // Focus state for border styling
+	width    int  // Component width for text wrapping
+	focused  bool // Focus state for border styling
+	isNested bool // Whether this tool call is nested within another
 
 	// Tool call data and state
 	parentMessageId string             // ID of the message that initiated this tool call
@@ -45,6 +49,8 @@ type toolCallCmp struct {
 	// Animation state for pending tool calls
 	spinning bool       // Whether to show loading animation
 	anim     util.Model // Animation component for pending states
+
+	nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
 }
 
 // ToolCallOption provides functional options for configuring tool call components
@@ -64,17 +70,32 @@ func WithToolCallResult(result message.ToolResult) ToolCallOption {
 	}
 }
 
+func WithToolCallNested(isNested bool) ToolCallOption {
+	return func(m *toolCallCmp) {
+		m.isNested = isNested
+	}
+}
+
+func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
+	return func(m *toolCallCmp) {
+		m.nestedToolCalls = calls
+	}
+}
+
 // NewToolCallCmp creates a new tool call component with the given parent message ID,
 // tool call, and optional configuration
 func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
 	m := &toolCallCmp{
 		call:            tc,
 		parentMessageId: parentMessageId,
-		anim:            anim.New(15, "Working"),
 	}
 	for _, opt := range opts {
 		opt(m)
 	}
+	m.anim = anim.New(15, "Working")
+	if m.isNested {
+		m.anim = anim.New(10, "")
+	}
 	return m
 }
 
@@ -82,7 +103,6 @@ func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCal
 // Returns a command to start the animation for pending tool calls.
 func (m *toolCallCmp) Init() tea.Cmd {
 	m.spinning = m.shouldSpin()
-	logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
 	if m.spinning {
 		return m.anim.Init()
 	}
@@ -92,14 +112,22 @@ func (m *toolCallCmp) Init() tea.Cmd {
 // Update handles incoming messages and updates the component state.
 // Manages animation updates for pending tool calls.
 func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	logging.Debug("Tool call update", "msg", msg)
 	switch msg := msg.(type) {
-	case anim.ColorCycleMsg, anim.StepCharsMsg:
+	case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
+		var cmds []tea.Cmd
+		for i, nested := range m.nestedToolCalls {
+			if nested.Spinning() {
+				u, cmd := nested.Update(msg)
+				m.nestedToolCalls[i] = u.(ToolCallCmp)
+				cmds = append(cmds, cmd)
+			}
+		}
 		if m.spinning {
 			u, cmd := m.anim.Update(msg)
 			m.anim = u.(util.Model)
-			return m, cmd
+			cmds = append(cmds, cmd)
 		}
+		return m, tea.Batch(cmds...)
 	}
 	return m, nil
 }
@@ -114,6 +142,15 @@ func (m *toolCallCmp) View() string {
 	}
 
 	r := registry.lookup(m.call.Name)
+
+	if m.isNested {
+		return box.Render(
+			lipgloss.JoinHorizontal(lipgloss.Left,
+				" └ ",
+				r.Render(m),
+			),
+		)
+	}
 	return box.PaddingLeft(1).Render(r.Render(m))
 }
 
@@ -153,10 +190,31 @@ func (m *toolCallCmp) GetToolResult() message.ToolResult {
 	return m.result
 }
 
+// GetNestedToolCalls returns the nested tool calls
+func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
+	return m.nestedToolCalls
+}
+
+// SetNestedToolCalls sets the nested tool calls
+func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
+	m.nestedToolCalls = calls
+	for _, nested := range m.nestedToolCalls {
+		nested.SetSize(m.width, 0)
+	}
+}
+
+// SetIsNested sets whether this tool call is nested within another
+func (m *toolCallCmp) SetIsNested(isNested bool) {
+	m.isNested = isNested
+}
+
 // Rendering methods
 
 // renderPending displays the tool name with a loading animation for pending tool calls
 func (m *toolCallCmp) renderPending() string {
+	if m.isNested {
+		return fmt.Sprintf("└ %s: %s", prettifyToolName(m.call.Name), m.anim.View())
+	}
 	return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
 }
 
@@ -164,6 +222,10 @@ func (m *toolCallCmp) renderPending() string {
 // Applies muted colors and focus-dependent border styles.
 func (m *toolCallCmp) style() lipgloss.Style {
 	t := theme.CurrentTheme()
+	if m.isNested {
+		return styles.BaseStyle().
+			Foreground(t.TextMuted())
+	}
 	borderStyle := lipgloss.NormalBorder()
 	if m.focused {
 		borderStyle = lipgloss.DoubleBorder()
@@ -218,6 +280,9 @@ func (m *toolCallCmp) GetSize() (int, int) {
 // SetSize updates the width of the tool call component for text wrapping
 func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
 	m.width = width
+	for _, nested := range m.nestedToolCalls {
+		nested.SetSize(width, height)
+	}
 	return nil
 }
 
@@ -234,5 +299,13 @@ func (m *toolCallCmp) shouldSpin() bool {
 
 // Spinning returns whether the tool call is currently showing a loading animation
 func (m *toolCallCmp) Spinning() bool {
+	if m.spinning {
+		return true
+	}
+	for _, nested := range m.nestedToolCalls {
+		if nested.Spinning() {
+			return true
+		}
+	}
 	return m.spinning
 }

internal/tui/components/core/list/list.go 🔗

@@ -6,6 +6,7 @@ import (
 
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/tui/components/anim"
@@ -182,7 +183,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		return m.handleKeyPress(msg)
-	case anim.ColorCycleMsg, anim.StepCharsMsg:
+	case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
 		return m.handleAnimationMsg(msg)
 	}