on main help sign in
handle agent tool
commit9a69a310cf0df2af9eea6ed0e6da78c46d2182ae authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 24 May 2025, 16:45 (1 year ago) parent3eff8fa
6 files changed+227−22
internal/tui/components/anim/anim.go+22−1 internal/tui/components/chat/list.go+66−10 internal/tui/components/chat/messages/messages.go+2−1 internal/tui/components/chat/messages/renderer.go+54−1 internal/tui/components/chat/messages/tool.go+81−8 internal/tui/components/core/list/list.go+2−1
internal/tui/components/anim/anim.go+22−1
@@ -75,6 +75,11 @@ func cycleColors(id string) tea.Cmd {
7575 })
7676}
7777
78+type Animation interface {
79+ util.Model
80+ ID() string
81+}
82+
7883// anim is the model that manages the animation that displays while the
7984// output is being generated.
8085type anim struct {
@@ -88,7 +93,15 @@ type anim struct {
8893 id string
8994}
9095
91−func New(cyclingCharsSize uint, label string) util.Model {
96+type animOption func(*anim)
97+
98+func WithId(id string) animOption {
99+ return func(a *anim) {
100+ a.id = id
101+ }
102+}
103+
104+func New(cyclingCharsSize uint, label string, opts ...animOption) Animation {
92105 // #nosec G115
93106 n := min(int(cyclingCharsSize), maxCyclingChars)
94107
@@ -105,6 +118,10 @@ func New(cyclingCharsSize uint, label string) util.Model {
105118 id: id.String(),
106119 }
107120
121+ for _, opt := range opts {
122+ opt(&c)
123+ }
124+
108125 // If we're in truecolor mode (and there are enough cycling characters)
109126 // color the cycling characters with a gradient ramp.
110127 const minRampSize = 3
@@ -204,6 +221,10 @@ func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
204221 }
205222}
206223
224+func (a anim) ID() string {
225+ return a.id
226+}
227+
207228func (a *anim) updateChars(chars *[]cyclingChar) {
208229 for i, c := range *chars {
209230 switch c.state(a.start) {
internal/tui/components/chat/list.go+66−10
@@ -8,7 +8,7 @@ import (
88 tea "github.com/charmbracelet/bubbletea/v2"
99 "github.com/charmbracelet/lipgloss/v2"
1010 "github.com/opencode-ai/opencode/internal/app"
11− "github.com/opencode-ai/opencode/internal/logging"
11+ "github.com/opencode-ai/opencode/internal/llm/agent"
1212 "github.com/opencode-ai/opencode/internal/message"
1313 "github.com/opencode-ai/opencode/internal/pubsub"
1414 "github.com/opencode-ai/opencode/internal/session"
@@ -106,9 +106,54 @@ func (m *messageListCmp) View() string {
106106}
107107
108108// handleChildSession handles messages from child sessions (agent tools).
109−// TODO: update the agent tool message with the changes
110−func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
111− // Implementation pending
109+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
110+ var cmds []tea.Cmd
111+ if len(event.Payload.ToolCalls()) == 0 {
112+ return nil
113+ }
114+ items := m.listCmp.Items()
115+ toolCallInx := NotFound
116+ var toolCall messages.ToolCallCmp
117+ for i := len(items) - 1; i >= 0; i-- {
118+ if msg, ok := items[i].(messages.ToolCallCmp); ok {
119+ if msg.GetToolCall().ID == event.Payload.SessionID {
120+ toolCallInx = i
121+ toolCall = msg
122+ }
123+ }
124+ }
125+ if toolCallInx == NotFound {
126+ return nil
127+ }
128+ nestedToolCalls := toolCall.GetNestedToolCalls()
129+ for _, tc := range event.Payload.ToolCalls() {
130+ found := false
131+ for existingInx, existingTC := range nestedToolCalls {
132+ if existingTC.GetToolCall().ID == tc.ID {
133+ nestedToolCalls[existingInx].SetToolCall(tc)
134+ found = true
135+ break
136+ }
137+ }
138+ if !found {
139+ nestedCall := messages.NewToolCallCmp(
140+ event.Payload.ID,
141+ tc,
142+ messages.WithToolCallNested(true),
143+ )
144+ cmds = append(cmds, nestedCall.Init())
145+ nestedToolCalls = append(
146+ nestedToolCalls,
147+ nestedCall,
148+ )
149+ }
150+ }
151+ toolCall.SetNestedToolCalls(nestedToolCalls)
152+ m.listCmp.UpdateItem(
153+ toolCallInx,
154+ toolCall,
155+ )
156+ return tea.Batch(cmds...)
112157}
113158
114159// handleMessageEvent processes different types of message events (created/updated).
@@ -116,16 +161,16 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
116161 switch event.Type {
117162 case pubsub.CreatedEvent:
118163 if event.Payload.SessionID != m.session.ID {
119− m.handleChildSession(event)
120− return nil
164+ return m.handleChildSession(event)
121165 }
122−
123166 if m.messageExists(event.Payload.ID) {
124167 return nil
125168 }
126−
127169 return m.handleNewMessage(event.Payload)
128170 case pubsub.UpdatedEvent:
171+ if event.Payload.SessionID != m.session.ID {
172+ return m.handleChildSession(event)
173+ }
129174 return m.handleUpdateAssistantMessage(event.Payload)
130175 }
131176 return nil
@@ -196,8 +241,6 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
196241 // Find existing assistant message and tool calls for this message
197242 assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
198243
199− logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantIndex, "toolCalls", existingToolCalls)
200−
201244 // Handle assistant message content
202245 if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
203246 cmds = append(cmds, cmd)
@@ -389,6 +432,19 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
389432 for _, tc := range msg.ToolCalls() {
390433 options := m.buildToolCallOptions(tc, msg, toolResultMap)
391434 uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
435+ // If this tool call is the agent tool, fetch nested tool calls
436+ if tc.Name == agent.AgentToolName {
437+ nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
438+ nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
439+ nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
440+ for _, nestedMsg := range nestedUIMessages {
441+ if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
442+ toolCall.SetIsNested(true)
443+ nestedToolCalls = append(nestedToolCalls, toolCall)
444+ }
445+ }
446+ uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
447+ }
392448 }
393449
394450 return uiMessages
internal/tui/components/chat/messages/messages.go+2−1
@@ -7,6 +7,7 @@ import (
77 "strings"
88 "time"
99
10+ "github.com/charmbracelet/bubbles/v2/spinner"
1011 tea "github.com/charmbracelet/bubbletea/v2"
1112 "github.com/charmbracelet/lipgloss/v2"
1213 "github.com/opencode-ai/opencode/internal/llm/models"
@@ -80,7 +81,7 @@ func (m *messageCmp) Init() tea.Cmd {
8081// Manages animation updates for spinning messages and stops animation when appropriate.
8182func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8283 switch msg := msg.(type) {
83− case anim.ColorCycleMsg, anim.StepCharsMsg:
84+ case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
8485 m.spinning = m.shouldSpin()
8586 if m.spinning {
8687 u, cmd := m.anim.Update(msg)
internal/tui/components/chat/messages/renderer.go+54−1
@@ -91,7 +91,14 @@ func (pb *paramBuilder) build() []string {
9191
9292// renderWithParams provides a common rendering pattern for tools with parameters
9393func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
94− header := makeHeader(toolName, v.textWidth(), args...)
94+ width := v.textWidth()
95+ if v.isNested {
96+ width -= 3 // Adjust for nested tool call indentation
97+ }
98+ header := makeHeader(toolName, width, args...)
99+ if v.isNested {
100+ return v.style().Render(header)
101+ }
95102 if res, done := earlyState(header, v); done {
96103 return res
97104 }
@@ -117,6 +124,7 @@ func init() {
117124 registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
118125 registry.register(tools.PatchToolName, func() renderer { return patchRenderer{} })
119126 registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
127+ registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
120128}
121129
122130// -----------------------------------------------------------------------------
@@ -467,6 +475,51 @@ func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
467475 })
468476}
469477
478+// -----------------------------------------------------------------------------
479+// Task renderer
480+// -----------------------------------------------------------------------------
481+
482+// agentRenderer handles project-wide diagnostic information
483+type agentRenderer struct {
484+ baseRenderer
485+}
486+
487+// Render displays agent task parameters and result content
488+func (tr agentRenderer) Render(v *toolCallCmp) string {
489+ var params agent.AgentParams
490+ if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
491+ return tr.renderError(v, "Invalid task parameters")
492+ }
493+ prompt := params.Prompt
494+ prompt = strings.ReplaceAll(prompt, "\n", " ")
495+ args := newParamBuilder().addMain(prompt).build()
496+
497+ header := makeHeader("Task", v.textWidth(), args...)
498+ parts := []string{header}
499+ for _, call := range v.nestedToolCalls {
500+ parts = append(parts, call.View())
501+ }
502+
503+ if v.result.ToolCallID == "" {
504+ v.spinning = true
505+ parts = append(parts, v.anim.View())
506+ } else {
507+ v.spinning = false
508+ }
509+
510+ header = lipgloss.JoinVertical(
511+ lipgloss.Left,
512+ parts...,
513+ )
514+
515+ if v.result.ToolCallID == "" {
516+ return header
517+ }
518+
519+ body := renderPlainContent(v, v.result.Content)
520+ return joinHeaderBody(header, body)
521+}
522+
470523// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
471524func makeHeader(tool string, width int, params ...string) string {
472525 prefix := tool + ": "
internal/tui/components/chat/messages/tool.go+81−8
@@ -3,10 +3,10 @@ package messages
33import (
44 "fmt"
55
6+ "github.com/charmbracelet/bubbles/v2/spinner"
67 tea "github.com/charmbracelet/bubbletea/v2"
78 "github.com/charmbracelet/lipgloss/v2"
89 "github.com/charmbracelet/x/ansi"
9− "github.com/opencode-ai/opencode/internal/logging"
1010 "github.com/opencode-ai/opencode/internal/message"
1111 "github.com/opencode-ai/opencode/internal/tui/components/anim"
1212 "github.com/opencode-ai/opencode/internal/tui/layout"
@@ -28,13 +28,17 @@ type ToolCallCmp interface {
2828 SetCancelled() // Mark as cancelled
2929 ParentMessageId() string // Get parent message ID
3030 Spinning() bool // Animation state for pending tools
31+ GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
32+ SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
33+ SetIsNested(bool) // Set whether this tool call is nested
3134}
3235
3336// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
3437// It handles rendering of tool execution states including pending, completed, and error states.
3538type toolCallCmp struct {
36− width int // Component width for text wrapping
37− focused bool // Focus state for border styling
39+ width int // Component width for text wrapping
40+ focused bool // Focus state for border styling
41+ isNested bool // Whether this tool call is nested within another
3842
3943 // Tool call data and state
4044 parentMessageId string // ID of the message that initiated this tool call
@@ -45,6 +49,8 @@ type toolCallCmp struct {
4549 // Animation state for pending tool calls
4650 spinning bool // Whether to show loading animation
4751 anim util.Model // Animation component for pending states
52+
53+ nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
4854}
4955
5056// ToolCallOption provides functional options for configuring tool call components
@@ -64,17 +70,32 @@ func WithToolCallResult(result message.ToolResult) ToolCallOption {
6470 }
6571}
6672
73+func WithToolCallNested(isNested bool) ToolCallOption {
74+ return func(m *toolCallCmp) {
75+ m.isNested = isNested
76+ }
77+}
78+
79+func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
80+ return func(m *toolCallCmp) {
81+ m.nestedToolCalls = calls
82+ }
83+}
84+
6785// NewToolCallCmp creates a new tool call component with the given parent message ID,
6886// tool call, and optional configuration
6987func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
7088 m := &toolCallCmp{
7189 call: tc,
7290 parentMessageId: parentMessageId,
73− anim: anim.New(15, "Working"),
7491 }
7592 for _, opt := range opts {
7693 opt(m)
7794 }
95+ m.anim = anim.New(15, "Working")
96+ if m.isNested {
97+ m.anim = anim.New(10, "")
98+ }
7899 return m
79100}
80101
@@ -82,7 +103,6 @@ func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCal
82103// Returns a command to start the animation for pending tool calls.
83104func (m *toolCallCmp) Init() tea.Cmd {
84105 m.spinning = m.shouldSpin()
85− logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
86106 if m.spinning {
87107 return m.anim.Init()
88108 }
@@ -92,14 +112,22 @@ func (m *toolCallCmp) Init() tea.Cmd {
92112// Update handles incoming messages and updates the component state.
93113// Manages animation updates for pending tool calls.
94114func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
95− logging.Debug("Tool call update", "msg", msg)
96115 switch msg := msg.(type) {
97− case anim.ColorCycleMsg, anim.StepCharsMsg:
116+ case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
117+ var cmds []tea.Cmd
118+ for i, nested := range m.nestedToolCalls {
119+ if nested.Spinning() {
120+ u, cmd := nested.Update(msg)
121+ m.nestedToolCalls[i] = u.(ToolCallCmp)
122+ cmds = append(cmds, cmd)
123+ }
124+ }
98125 if m.spinning {
99126 u, cmd := m.anim.Update(msg)
100127 m.anim = u.(util.Model)
101− return m, cmd
128+ cmds = append(cmds, cmd)
102129 }
130+ return m, tea.Batch(cmds...)
103131 }
104132 return m, nil
105133}
@@ -114,6 +142,15 @@ func (m *toolCallCmp) View() string {
114142 }
115143
116144 r := registry.lookup(m.call.Name)
145+
146+ if m.isNested {
147+ return box.Render(
148+ lipgloss.JoinHorizontal(lipgloss.Left,
149+ " └ ",
150+ r.Render(m),
151+ ),
152+ )
153+ }
117154 return box.PaddingLeft(1).Render(r.Render(m))
118155}
119156
@@ -153,10 +190,31 @@ func (m *toolCallCmp) GetToolResult() message.ToolResult {
153190 return m.result
154191}
155192
193+// GetNestedToolCalls returns the nested tool calls
194+func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
195+ return m.nestedToolCalls
196+}
197+
198+// SetNestedToolCalls sets the nested tool calls
199+func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
200+ m.nestedToolCalls = calls
201+ for _, nested := range m.nestedToolCalls {
202+ nested.SetSize(m.width, 0)
203+ }
204+}
205+
206+// SetIsNested sets whether this tool call is nested within another
207+func (m *toolCallCmp) SetIsNested(isNested bool) {
208+ m.isNested = isNested
209+}
210+
156211// Rendering methods
157212
158213// renderPending displays the tool name with a loading animation for pending tool calls
159214func (m *toolCallCmp) renderPending() string {
215+ if m.isNested {
216+ return fmt.Sprintf("└ %s: %s", prettifyToolName(m.call.Name), m.anim.View())
217+ }
160218 return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
161219}
162220
@@ -164,6 +222,10 @@ func (m *toolCallCmp) renderPending() string {
164222// Applies muted colors and focus-dependent border styles.
165223func (m *toolCallCmp) style() lipgloss.Style {
166224 t := theme.CurrentTheme()
225+ if m.isNested {
226+ return styles.BaseStyle().
227+ Foreground(t.TextMuted())
228+ }
167229 borderStyle := lipgloss.NormalBorder()
168230 if m.focused {
169231 borderStyle = lipgloss.DoubleBorder()
@@ -218,6 +280,9 @@ func (m *toolCallCmp) GetSize() (int, int) {
218280// SetSize updates the width of the tool call component for text wrapping
219281func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
220282 m.width = width
283+ for _, nested := range m.nestedToolCalls {
284+ nested.SetSize(width, height)
285+ }
221286 return nil
222287}
223288
@@ -234,5 +299,13 @@ func (m *toolCallCmp) shouldSpin() bool {
234299
235300// Spinning returns whether the tool call is currently showing a loading animation
236301func (m *toolCallCmp) Spinning() bool {
302+ if m.spinning {
303+ return true
304+ }
305+ for _, nested := range m.nestedToolCalls {
306+ if nested.Spinning() {
307+ return true
308+ }
309+ }
237310 return m.spinning
238311}
internal/tui/components/core/list/list.go+2−1
@@ -6,6 +6,7 @@ import (
66
77 "github.com/charmbracelet/bubbles/v2/help"
88 "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/spinner"
910 tea "github.com/charmbracelet/bubbletea/v2"
1011 "github.com/charmbracelet/lipgloss/v2"
1112 "github.com/opencode-ai/opencode/internal/tui/components/anim"
@@ -182,7 +183,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
182183 switch msg := msg.(type) {
183184 case tea.KeyPressMsg:
184185 return m.handleKeyPress(msg)
185− case anim.ColorCycleMsg, anim.StepCharsMsg:
186+ case anim.ColorCycleMsg, anim.StepCharsMsg, spinner.TickMsg:
186187 return m.handleAnimationMsg(msg)
187188 }
188189