Detailed changes
@@ -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) {
@@ -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
@@ -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)
@@ -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 "<Tool>: param (key=value)" and truncates as needed.
func makeHeader(tool string, width int, params ...string) string {
prefix := tool + ": "
@@ -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
}
@@ -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)
}