From 9a69a310cf0df2af9eea6ed0e6da78c46d2182ae Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 24 May 2025 16:45:46 +0200 Subject: [PATCH] handle agent tool --- internal/tui/components/anim/anim.go | 23 ++++- internal/tui/components/chat/list.go | 76 +++++++++++++--- .../tui/components/chat/messages/messages.go | 3 +- .../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(-) diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 91ae8317eafaa6c49fce54194b8f1013d88042f4..8c8276de23600147bbae7ab3cef1483cd05b2904 100644 --- a/internal/tui/components/anim/anim.go +++ b/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) { diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index 57205c4c3480b889a994078d3fd8b0d7c79eaeb2..762fdd247af9bac5c55e562e56bd11eb307357db 100644 --- a/internal/tui/components/chat/list.go +++ b/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 diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 714f79b3a46b2147bc51c115e0ac6b9ee4482f4d..b047af6bf36dc800e2755149d906f3ad2ee32a4c 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index b8a7b834543f33be69336eb3bef00493ed95d84c..836d9fb90cebc130f78dfe54a82da2c8be4e439f 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/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, ¶ms); 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 ": param (key=value)" and truncates as needed. func makeHeader(tool string, width int, params ...string) string { prefix := tool + ": " diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index bee94d67795fb288fe6e062f68b57155ed4ccbb5..15c1d6d143c8c7d0622413872c0f72b9949d6210 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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 } diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 4bcb167a5062942d83d938073e4356f2d834c02e..341b94b1ca5998f974f60f2d0a922ec1b16bd6f7 100644 --- a/internal/tui/components/core/list/list.go +++ b/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) }