on main help sign in
implement tool calls in the ui
2883256Kujtim Hoxha
.editorconfig+1−1
@@ -11,7 +11,7 @@ indent_size = 2
11
11
12[*.go]
12[*.go]
13indent_style = tab
13indent_style = tab
14−indent_size = 8
14+indent_size = 4
15
15
16[*.golden]
16[*.golden]
17insert_final_newline = false
17insert_final_newline = false
internal/llm/agent/agent.go+5−9
@@ -443,18 +443,14 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
443 assistantMsg.AppendContent(event.Content)
443 assistantMsg.AppendContent(event.Content)
444 return a.messages.Update(ctx, *assistantMsg)
444 return a.messages.Update(ctx, *assistantMsg)
445 case provider.EventToolUseStart:
445 case provider.EventToolUseStart:
446+ logging.Info("Tool call started", "toolCall", event.ToolCall)
446 assistantMsg.AddToolCall(*event.ToolCall)
447 assistantMsg.AddToolCall(*event.ToolCall)
447 return a.messages.Update(ctx, *assistantMsg)
448 return a.messages.Update(ctx, *assistantMsg)
448− // TODO: see how to handle this
449− // case provider.EventToolUseDelta:
449+ case provider.EventToolUseDelta:
450− // tm := time.Unix(assistantMsg.UpdatedAt, 0)
451− // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
450+ assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
452− // if time.Since(tm) > 1000*time.Millisecond {
453− // err := a.messages.Update(ctx, *assistantMsg)
451+ return a.messages.Update(ctx, *assistantMsg)
454− // assistantMsg.UpdatedAt = time.Now().Unix()
455− // return err
456− // }
457 case provider.EventToolUseStop:
452 case provider.EventToolUseStop:
453+ logging.Info("Finished tool call", "toolCall", event.ToolCall)
458 assistantMsg.FinishToolCall(event.ToolCall.ID)
454 assistantMsg.FinishToolCall(event.ToolCall.ID)
459 return a.messages.Update(ctx, *assistantMsg)
455 return a.messages.Update(ctx, *assistantMsg)
460 case provider.EventError:
456 case provider.EventError:
internal/llm/prompt/coder.go+2−2
@@ -153,7 +153,7 @@ When making changes to files, first understand the file's code conventions. Mimi
153
153
154# Doing tasks
154# Doing tasks
155The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
155The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
156−1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
156+1. Use the available search tools to understand the codebase and the user's query.
1572. Implement the solution using all tools available to you
1572. Implement the solution using all tools available to you
1583. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
1583. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
1594. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time.
1594. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time.
@@ -162,7 +162,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
162
162
163# Tool usage policy
163# Tool usage policy
164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
165−- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
165+- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
166- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
166- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
167
167
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
internal/llm/provider/anthropic.go+1−1
@@ -305,7 +305,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
305 ToolCall: &message.ToolCall{
305 ToolCall: &message.ToolCall{
306 ID: currentToolCallID,
306 ID: currentToolCallID,
307 Finished: false,
307 Finished: false,
308− Input: event.Delta.JSON.PartialJSON.Raw(),
308+ Input: event.Delta.PartialJSON,
309 },
309 },
310 }
310 }
311 }
311 }
internal/tui/components/anim/anim.go+26−12
@@ -9,6 +9,7 @@ import (
9 "github.com/charmbracelet/bubbles/v2/spinner"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/google/uuid"
12 "github.com/lucasb-eyer/go-colorful"
13 "github.com/lucasb-eyer/go-colorful"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
14 "github.com/opencode-ai/opencode/internal/tui/styles"
14 "github.com/opencode-ai/opencode/internal/tui/theme"
15 "github.com/opencode-ai/opencode/internal/tui/theme"
@@ -54,19 +55,23 @@ func (c cyclingChar) state(start time.Time) charState {
54 return charCyclingState
55 return charCyclingState
55}
56}
56
57
57−type StepCharsMsg struct{}
58+type StepCharsMsg struct {
59+ id string
60+}
58
61
59−func stepChars() tea.Cmd {
62+func stepChars(id string) tea.Cmd {
60 return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
63 return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
61− return StepCharsMsg{}
64+ return StepCharsMsg{id}
62 })
65 })
63}
66}
64
67
65−type ColorCycleMsg struct{}
68+type ColorCycleMsg struct {
69+ id string
70+}
66
71
67−func cycleColors() tea.Cmd {
72+func cycleColors(id string) tea.Cmd {
68 return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
73 return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
69− return ColorCycleMsg{}
74+ return ColorCycleMsg{id}
70 })
75 })
71}
76}
72
77
@@ -80,6 +85,7 @@ type anim struct {
80 label []rune
85 label []rune
81 ellipsis spinner.Model
86 ellipsis spinner.Model
82 ellipsisStarted bool
87 ellipsisStarted bool
88+ id string
83}
89}
84
90
85func New(cyclingCharsSize uint, label string) util.Model {
91func New(cyclingCharsSize uint, label string) util.Model {
@@ -91,10 +97,12 @@ func New(cyclingCharsSize uint, label string) util.Model {
91 gap = ""
97 gap = ""
92 }
98 }
93
99
100+ id := uuid.New()
94 c := anim{
101 c := anim{
95 start: time.Now(),
102 start: time.Now(),
96 label: []rune(gap + label),
103 label: []rune(gap + label),
97 ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
104 ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
105+ id: id.String(),
98 }
106 }
99
107
100 // If we're in truecolor mode (and there are enough cycling characters)
108 // If we're in truecolor mode (and there are enough cycling characters)
@@ -144,15 +152,18 @@ func New(cyclingCharsSize uint, label string) util.Model {
144}
152}
145
153
146// Init initializes the animation.
154// Init initializes the animation.
147−func (anim) Init() tea.Cmd {
155+func (a anim) Init() tea.Cmd {
148− return tea.Batch(stepChars(), cycleColors())
156+ return tea.Batch(stepChars(a.id), cycleColors(a.id))
149}
157}
150
158
151// Update handles messages.
159// Update handles messages.
152func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153 var cmd tea.Cmd
161 var cmd tea.Cmd
154− switch msg.(type) {
162+ switch msg := msg.(type) {
155 case StepCharsMsg:
163 case StepCharsMsg:
164+ if msg.id != a.id {
165+ return a, nil
166+ }
156 a.updateChars(&a.cyclingChars)
167 a.updateChars(&a.cyclingChars)
157 a.updateChars(&a.labelChars)
168 a.updateChars(&a.labelChars)
158
169
@@ -173,14 +184,17 @@ func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
173 }
184 }
174 }
185 }
175
186
176− return a, tea.Batch(stepChars(), cmd)
187+ return a, tea.Batch(stepChars(a.id), cmd)
177 case ColorCycleMsg:
188 case ColorCycleMsg:
189+ if msg.id != a.id {
190+ return a, nil
191+ }
178 const minColorCycleSize = 2
192 const minColorCycleSize = 2
179 if len(a.ramp) < minColorCycleSize {
193 if len(a.ramp) < minColorCycleSize {
180 return a, nil
194 return a, nil
181 }
195 }
182 a.ramp = append(a.ramp[1:], a.ramp[0])
196 a.ramp = append(a.ramp[1:], a.ramp[0])
183− return a, cycleColors()
197+ return a, cycleColors(a.id)
184 case spinner.TickMsg:
198 case spinner.TickMsg:
185 var cmd tea.Cmd
199 var cmd tea.Cmd
186 a.ellipsis, cmd = a.ellipsis.Update(msg)
200 a.ellipsis, cmd = a.ellipsis.Update(msg)
@@ -216,7 +230,7 @@ func (a anim) View() string {
216 b.WriteRune(c.currentValue)
230 b.WriteRune(c.currentValue)
217 }
231 }
218
232
219− if len(a.label) > 1 {
233+ if len(a.labelChars) > 1 {
220 textStyle := styles.BaseStyle().
234 textStyle := styles.BaseStyle().
221 Foreground(t.Text())
235 Foreground(t.Text())
222 for _, c := range a.labelChars {
236 for _, c := range a.labelChars {
internal/tui/components/chat/list_v2.go+67−17
@@ -7,6 +7,7 @@ import (
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/opencode-ai/opencode/internal/app"
9 "github.com/opencode-ai/opencode/internal/app"
10+ "github.com/opencode-ai/opencode/internal/logging"
10 "github.com/opencode-ai/opencode/internal/message"
11 "github.com/opencode-ai/opencode/internal/message"
11 "github.com/opencode-ai/opencode/internal/pubsub"
12 "github.com/opencode-ai/opencode/internal/pubsub"
12 "github.com/opencode-ai/opencode/internal/session"
13 "github.com/opencode-ai/opencode/internal/session"
@@ -61,7 +62,8 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
61 return m, m.listCmp.SetItems([]util.Model{})
62 return m, m.listCmp.SetItems([]util.Model{})
62
63
63 case pubsub.Event[message.Message]:
64 case pubsub.Event[message.Message]:
64− return m, m.handleMessageEvent(msg)
65+ cmd := m.handleMessageEvent(msg)
66+ return m, cmd
65 default:
67 default:
66 var cmds []tea.Cmd
68 var cmds []tea.Cmd
67 u, cmd := m.listCmp.Update(msg)
69 u, cmd := m.listCmp.Update(msg)
@@ -92,8 +94,8 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
92 // more likely to be at the end of the list
94 // more likely to be at the end of the list
93 items := m.listCmp.Items()
95 items := m.listCmp.Items()
94 for i := len(items) - 1; i >= 0; i-- {
96 for i := len(items) - 1; i >= 0; i-- {
95− msg := items[i].(messages.MessageCmp)
97+ msg, ok := items[i].(messages.MessageCmp)
96− if msg.GetMessage().ID == event.Payload.ID {
98+ if ok && msg.GetMessage().ID == event.Payload.ID {
97 messageExists = true
99 messageExists = true
98 break
100 break
99 }
101 }
@@ -109,7 +111,6 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
109 case message.Tool:
111 case message.Tool:
110 return m.handleToolMessage(event.Payload)
112 return m.handleToolMessage(event.Payload)
111 }
113 }
112− // TODO: handle tools
113 case pubsub.UpdatedEvent:
114 case pubsub.UpdatedEvent:
114 return m.handleUpdateAssistantMessage(event.Payload)
115 return m.handleUpdateAssistantMessage(event.Payload)
115 }
116 }
@@ -122,30 +123,79 @@ func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
122}
123}
123
124
124func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
125func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
126+ items := m.listCmp.Items()
127+ for _, tr := range msg.ToolResults() {
128+ for i := len(items) - 1; i >= 0; i-- {
129+ message := items[i]
130+ if toolCall, ok := message.(messages.ToolCallCmp); ok {
131+ if toolCall.GetToolCall().ID == tr.ToolCallID {
132+ toolCall.SetToolResult(tr)
133+ m.listCmp.UpdateItem(
134+ i,
135+ toolCall,
136+ )
137+ break
138+ }
139+ }
140+ }
141+ }
125 return nil
142 return nil
126}
143}
127
144
128func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
145func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
146+ var cmds []tea.Cmd
129 // Simple update the content
147 // Simple update the content
130 items := m.listCmp.Items()
148 items := m.listCmp.Items()
149+ assistantMessageInx := -1
150+ toolCalls := map[int]messages.ToolCallCmp{}
151+
152+ // we go backwards because the messages are most likely at the end of the list
153+ for i := len(items) - 1; i >= 0; i-- {
154+ message := items[i]
131− lastItem := items[len(items)-1].(messages.MessageCmp)
155+ if asMsg, ok := message.(messages.MessageCmp); ok {
132− // TODO:handle tool calls
133− if lastItem.GetMessage().ID != msg.ID {
156+ if asMsg.GetMessage().ID == msg.ID {
134− return nil
157+ assistantMessageInx = i
158+ }
159+ } else if tc, ok := message.(messages.ToolCallCmp); ok {
160+ if tc.ParentMessageId() == msg.ID {
161+ toolCalls[i] = tc
162+ }
163+ }
135 }
164 }
136− // for now just updet the last message
165+
166+ logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantMessageInx, "toolCalls", toolCalls)
167+
137− if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
168+ if assistantMessageInx > -1 && (len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()) {
138 m.listCmp.UpdateItem(
169 m.listCmp.UpdateItem(
139− len(items)-1,
170+ assistantMessageInx,
140 messages.NewMessageCmp(
171 messages.NewMessageCmp(
141 msg,
172 msg,
142 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
173 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
143 ),
174 ),
144 )
175 )
145− } else if len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
176+ } else if assistantMessageInx > -1 && len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
146− m.listCmp.DeleteItem(len(items) - 1)
177+ m.listCmp.DeleteItem(assistantMessageInx)
147 }
178 }
179+ for _, tc := range msg.ToolCalls() {
180+ found := false
181+ for inx, tcc := range toolCalls {
182+ if tc.ID == tcc.GetToolCall().ID {
183+ tcc.SetToolCall(tc)
184+ m.listCmp.UpdateItem(
185+ inx,
186+ tcc,
187+ )
188+ found = true
189+ break
190+ }
191+ }
192+ if !found {
193+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
194+ cmds = append(cmds, cmd)
195+ }
196+ }
197+
148− return nil
198+ return tea.Batch(cmds...)
149}
199}
150
200
151func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
201func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
@@ -161,7 +211,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
161 cmds = append(cmds, cmd)
211 cmds = append(cmds, cmd)
162 }
212 }
163 for _, tc := range msg.ToolCalls() {
213 for _, tc := range msg.ToolCalls() {
164− cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(tc))
214+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
165 cmds = append(cmds, cmd)
215 cmds = append(cmds, cmd)
166 }
216 }
167 return tea.Batch(cmds...)
217 return tea.Batch(cmds...)
@@ -206,10 +256,10 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
206 if tr, ok := toolResultMap[tc.ID]; ok {
256 if tr, ok := toolResultMap[tc.ID]; ok {
207 options = append(options, messages.WithToolCallResult(tr))
257 options = append(options, messages.WithToolCallResult(tr))
208 }
258 }
209− if msg.FinishPart().Reason == message.FinishReasonCanceled {
259+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
210 options = append(options, messages.WithToolCallCancelled())
260 options = append(options, messages.WithToolCallCancelled())
211 }
261 }
212− uiMessages = append(uiMessages, messages.NewToolCallCmp(tc, options...))
262+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
213 }
263 }
214 }
264 }
215 }
265 }
internal/tui/components/chat/messages/messages.go+10−3
@@ -65,9 +65,16 @@ func (m *messageCmp) Init() tea.Cmd {
65}
65}
66
66
67func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
67func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68+ switch msg := msg.(type) {
69+ case anim.ColorCycleMsg, anim.StepCharsMsg:
70+ m.spinning = m.shouldSpin()
71+ if m.spinning {
68− u, cmd := m.anim.Update(msg)
72+ u, cmd := m.anim.Update(msg)
69− m.anim = u.(util.Model)
73+ m.anim = u.(util.Model)
74+ return m, cmd
75+ }
76+ }
70− return m, cmd
77+ return m, nil
71}
78}
72
79
73func (m *messageCmp) View() string {
80func (m *messageCmp) View() string {
internal/tui/components/chat/messages/renderer.go−29
@@ -524,32 +524,3 @@ func prettifyToolName(name string) string {
524 return name
524 return name
525 }
525 }
526}
526}
527−
528−func toolAction(name string) string {
529− switch name {
530− case agent.AgentToolName:
531− return "Preparing prompt..."
532− case tools.BashToolName:
533− return "Building command..."
534− case tools.EditToolName:
535− return "Preparing edit..."
536− case tools.FetchToolName:
537− return "Writing fetch..."
538− case tools.GlobToolName:
539− return "Finding files..."
540− case tools.GrepToolName:
541− return "Searching content..."
542− case tools.LSToolName:
543− return "Listing directory..."
544− case tools.SourcegraphToolName:
545− return "Searching code..."
546− case tools.ViewToolName:
547− return "Reading file..."
548− case tools.WriteToolName:
549− return "Preparing write..."
550− case tools.PatchToolName:
551− return "Preparing patch..."
552− default:
553− return "Working..."
554− }
555−}
internal/tui/components/chat/messages/tool.go+70−37
@@ -6,9 +6,9 @@ import (
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/x/ansi"
8 "github.com/charmbracelet/x/ansi"
9− "github.com/opencode-ai/opencode/internal/llm/agent"
9+ "github.com/opencode-ai/opencode/internal/logging"
10− "github.com/opencode-ai/opencode/internal/llm/tools"
11 "github.com/opencode-ai/opencode/internal/message"
10 "github.com/opencode-ai/opencode/internal/message"
11+ "github.com/opencode-ai/opencode/internal/tui/components/anim"
12 "github.com/opencode-ai/opencode/internal/tui/layout"
12 "github.com/opencode-ai/opencode/internal/tui/layout"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
13 "github.com/opencode-ai/opencode/internal/tui/styles"
14 "github.com/opencode-ai/opencode/internal/tui/theme"
14 "github.com/opencode-ai/opencode/internal/tui/theme"
@@ -21,15 +21,24 @@ type ToolCallCmp interface {
21 layout.Focusable
21 layout.Focusable
22 GetToolCall() message.ToolCall
22 GetToolCall() message.ToolCall
23 GetToolResult() message.ToolResult
23 GetToolResult() message.ToolResult
24+ SetToolResult(message.ToolResult)
25+ SetToolCall(message.ToolCall)
26+ SetCancelled()
27+ ParentMessageId() string
28+ Spinning() bool
24}
29}
25
30
26type toolCallCmp struct {
31type toolCallCmp struct {
27 width int
32 width int
28 focused bool
33 focused bool
29
34
35+ parentMessageId string
30− call message.ToolCall
36+ call message.ToolCall
31− result message.ToolResult
37+ result message.ToolResult
32− cancelled bool
38+ cancelled bool
39+
40+ spinning bool
41+ anim util.Model
33}
42}
34
43
35type ToolCallOption func(*toolCallCmp)
44type ToolCallOption func(*toolCallCmp)
@@ -46,9 +55,11 @@ func WithToolCallResult(result message.ToolResult) ToolCallOption {
46 }
55 }
47}
56}
48
57
49−func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
58+func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
50 m := &toolCallCmp{
59 m := &toolCallCmp{
51− call: tc,
60+ call: tc,
61+ parentMessageId: parentMessageId,
62+ anim: anim.New(15, "Working"),
52 }
63 }
53 for _, opt := range opts {
64 for _, opt := range opts {
54 opt(m)
65 opt(m)
@@ -57,10 +68,24 @@ func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
57}
68}
58
69
59func (m *toolCallCmp) Init() tea.Cmd {
70func (m *toolCallCmp) Init() tea.Cmd {
71+ m.spinning = m.shouldSpin()
72+ logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
73+ if m.spinning {
74+ return m.anim.Init()
75+ }
60 return nil
76 return nil
61}
77}
62
78
63func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
79func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80+ logging.Debug("Tool call update", "msg", msg)
81+ switch msg := msg.(type) {
82+ case anim.ColorCycleMsg, anim.StepCharsMsg:
83+ if m.spinning {
84+ u, cmd := m.anim.Update(msg)
85+ m.anim = u.(util.Model)
86+ return m, cmd
87+ }
88+ }
64 return m, nil
89 return m, nil
65}
90}
66
91
@@ -75,6 +100,30 @@ func (m *toolCallCmp) View() string {
75 return box.PaddingLeft(1).Render(r.Render(m))
100 return box.PaddingLeft(1).Render(r.Render(m))
76}
101}
77
102
103+// SetCancelled implements ToolCallCmp.
104+func (m *toolCallCmp) SetCancelled() {
105+ m.cancelled = true
106+}
107+
108+// SetToolCall implements ToolCallCmp.
109+func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
110+ m.call = call
111+ if m.call.Finished {
112+ m.spinning = false
113+ }
114+}
115+
116+// ParentMessageId implements ToolCallCmp.
117+func (m *toolCallCmp) ParentMessageId() string {
118+ return m.parentMessageId
119+}
120+
121+// SetToolResult implements ToolCallCmp.
122+func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
123+ m.result = result
124+ m.spinning = false
125+}
126+
78// GetToolCall implements ToolCallCmp.
127// GetToolCall implements ToolCallCmp.
79func (m *toolCallCmp) GetToolCall() message.ToolCall {
128func (m *toolCallCmp) GetToolCall() message.ToolCall {
80 return m.call
129 return m.call
@@ -86,7 +135,7 @@ func (m *toolCallCmp) GetToolResult() message.ToolResult {
86}
135}
87
136
88func (m *toolCallCmp) renderPending() string {
137func (m *toolCallCmp) renderPending() string {
89− return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), toolAction(m.call.Name))
138+ return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
90}
139}
91
140
92func (m *toolCallCmp) style() lipgloss.Style {
141func (m *toolCallCmp) style() lipgloss.Style {
@@ -113,35 +162,6 @@ func (m *toolCallCmp) fit(content string, width int) string {
113 return ansi.Truncate(content, width, dots)
162 return ansi.Truncate(content, width, dots)
114}
163}
115
164
116−func (m *toolCallCmp) toolName() string {
117− switch m.call.Name {
118− case agent.AgentToolName:
119− return "Task"
120− case tools.BashToolName:
121− return "Bash"
122− case tools.EditToolName:
123− return "Edit"
124− case tools.FetchToolName:
125− return "Fetch"
126− case tools.GlobToolName:
127− return "Glob"
128− case tools.GrepToolName:
129− return "Grep"
130− case tools.LSToolName:
131− return "List"
132− case tools.SourcegraphToolName:
133− return "Sourcegraph"
134− case tools.ViewToolName:
135− return "View"
136− case tools.WriteToolName:
137− return "Write"
138− case tools.PatchToolName:
139− return "Patch"
140− default:
141− return m.call.Name
142− }
143−}
144−
145func (m *toolCallCmp) Blur() tea.Cmd {
165func (m *toolCallCmp) Blur() tea.Cmd {
146 m.focused = false
166 m.focused = false
147 return nil
167 return nil
@@ -165,3 +185,16 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
165 m.width = width
185 m.width = width
166 return nil
186 return nil
167}
187}
188+
189+func (m *toolCallCmp) shouldSpin() bool {
190+ if !m.call.Finished {
191+ return true
192+ } else if m.result.ToolCallID != m.call.ID {
193+ return true
194+ }
195+ return false
196+}
197+
198+func (m *toolCallCmp) Spinning() bool {
199+ return m.spinning
200+}
internal/tui/components/core/list/list.go+87−51
@@ -9,7 +9,6 @@ import (
9 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
12− "github.com/opencode-ai/opencode/internal/logging"
13 "github.com/opencode-ai/opencode/internal/tui/components/anim"
12 "github.com/opencode-ai/opencode/internal/tui/components/anim"
14 "github.com/opencode-ai/opencode/internal/tui/layout"
13 "github.com/opencode-ai/opencode/internal/tui/layout"
15 "github.com/opencode-ai/opencode/internal/tui/util"
14 "github.com/opencode-ai/opencode/internal/tui/util"
@@ -39,7 +38,7 @@ type renderedItem struct {
39}
38}
40type model struct {
39type model struct {
41 width, height, offset int
40 width, height, offset int
42− finalHight int // this gets set when the last item is rendered to mark the max offset
41+ finalHeight int // this gets set when the last item is rendered to mark the max offset
43 reverse bool
42 reverse bool
44 help help.Model
43 help help.Model
45 keymap KeyMap
44 keymap KeyMap
@@ -95,7 +94,7 @@ func New(opts ...listOptions) ListModel {
95 gapSize: 0,
94 gapSize: 0,
96 padding: []int{},
95 padding: []int{},
97 selectedItemInx: -1,
96 selectedItemInx: -1,
98− finalHight: -1,
97+ finalHeight: -1,
99 lastRenderedInx: -1,
98 lastRenderedInx: -1,
100 renderedItems: new(sync.Map),
99 renderedItems: new(sync.Map),
101 }
100 }
@@ -160,20 +159,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160 m.goToBottom()
159 m.goToBottom()
161 return m, nil
160 return m, nil
162 }
161 }
163− case anim.ColorCycleMsg:
162+ case anim.ColorCycleMsg, anim.StepCharsMsg:
164− logging.Info("ColorCycleMsg", "msg", msg)
165− for inx, item := range m.items {
166− if i, ok := item.(HasAnim); ok {
167− if i.Spinning() {
168− updated, cmd := i.Update(msg)
169− cmds = append(cmds, cmd)
170− m.UpdateItem(inx, updated.(util.Model))
171− }
172− }
173− }
174− return m, tea.Batch(cmds...)
175− case anim.StepCharsMsg:
176− logging.Info("ColorCycleMsg", "msg", msg)
177 for inx, item := range m.items {
163 for inx, item := range m.items {
178 if i, ok := item.(HasAnim); ok {
164 if i, ok := item.(HasAnim); ok {
179 if i.Spinning() {
165 if i.Spinning() {
@@ -189,7 +175,6 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
189 u, cmd := m.items[m.selectedItemInx].Update(msg)
175 u, cmd := m.items[m.selectedItemInx].Update(msg)
190 cmds = append(cmds, cmd)
176 cmds = append(cmds, cmd)
191 m.UpdateItem(m.selectedItemInx, u.(util.Model))
177 m.UpdateItem(m.selectedItemInx, u.(util.Model))
192− cmds = append(cmds, cmd)
193 return m, tea.Batch(cmds...)
178 return m, tea.Batch(cmds...)
194 }
179 }
195
180
@@ -232,7 +217,7 @@ func (m *model) renderVisibleReverse() {
232 itemLines = cachedContent.(renderedItem).lines
217 itemLines = cachedContent.(renderedItem).lines
233 } else {
218 } else {
234 itemLines = strings.Split(items[i].View(), "\n")
219 itemLines = strings.Split(items[i].View(), "\n")
235− if m.gapSize > 0 && realIndex != len(m.items)-1 {
220+ if m.gapSize > 0 {
236 for range m.gapSize {
221 for range m.gapSize {
237 itemLines = append(itemLines, "")
222 itemLines = append(itemLines, "")
238 }
223 }
@@ -245,7 +230,7 @@ func (m *model) renderVisibleReverse() {
245 }
230 }
246
231
247 if realIndex == 0 {
232 if realIndex == 0 {
248− m.finalHight = max(0, start+len(itemLines)-m.listHeight())
233+ m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
249 }
234 }
250 m.renderedLines = append(itemLines, m.renderedLines...)
235 m.renderedLines = append(itemLines, m.renderedLines...)
251 m.lastRenderedInx = realIndex
236 m.lastRenderedInx = realIndex
@@ -256,9 +241,9 @@ func (m *model) renderVisibleReverse() {
256 start += len(itemLines)
241 start += len(itemLines)
257 }
242 }
258 m.needsRerender = false
243 m.needsRerender = false
259− if m.finalHight > -1 {
244+ if m.finalHeight > -1 {
260 // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
245 // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
261− m.offset = min(m.offset, m.finalHight)
246+ m.offset = min(m.offset, m.finalHeight)
262 }
247 }
263 maxHeight := min(m.listHeight(), len(m.renderedLines))
248 maxHeight := min(m.listHeight(), len(m.renderedLines))
264 if m.offset < len(m.renderedLines) {
249 if m.offset < len(m.renderedLines) {
@@ -293,7 +278,7 @@ func (m *model) renderVisible() {
293 itemLines = cachedContent.(renderedItem).lines
278 itemLines = cachedContent.(renderedItem).lines
294 } else {
279 } else {
295 itemLines = strings.Split(item.View(), "\n")
280 itemLines = strings.Split(item.View(), "\n")
296− if m.gapSize > 0 && realIndex != len(m.items)-1 {
281+ if m.gapSize > 0 {
297 for range m.gapSize {
282 for range m.gapSize {
298 itemLines = append(itemLines, "")
283 itemLines = append(itemLines, "")
299 }
284 }
@@ -310,7 +295,7 @@ func (m *model) renderVisible() {
310 }
295 }
311
296
312 if realIndex == len(m.items)-1 {
297 if realIndex == len(m.items)-1 {
313− m.finalHight = max(0, start+len(itemLines)-m.listHeight())
298+ m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
314 }
299 }
315
300
316 m.renderedLines = append(m.renderedLines, itemLines...)
301 m.renderedLines = append(m.renderedLines, itemLines...)
@@ -319,9 +304,9 @@ func (m *model) renderVisible() {
319 }
304 }
320 m.needsRerender = false
305 m.needsRerender = false
321 maxHeight := min(m.listHeight(), len(m.renderedLines))
306 maxHeight := min(m.listHeight(), len(m.renderedLines))
322− if m.finalHight > -1 {
307+ if m.finalHeight > -1 {
323 // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
308 // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
324− m.offset = min(m.offset, m.finalHight)
309+ m.offset = min(m.offset, m.finalHeight)
325 }
310 }
326 if m.offset < len(m.renderedLines) {
311 if m.offset < len(m.renderedLines) {
327 m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
312 m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
@@ -412,6 +397,9 @@ func (m *model) downOneItem() tea.Cmd {
412}
397}
413
398
414func (m *model) goToBottom() tea.Cmd {
399func (m *model) goToBottom() tea.Cmd {
400+ if len(m.items) == 0 {
401+ return nil
402+ }
415 var cmds []tea.Cmd
403 var cmds []tea.Cmd
416 m.reverse = true
404 m.reverse = true
417 cmd := m.blurSelected()
405 cmd := m.blurSelected()
@@ -428,11 +416,14 @@ func (m *model) ResetView() {
428 m.renderedLines = []string{}
416 m.renderedLines = []string{}
429 m.offset = 0
417 m.offset = 0
430 m.lastRenderedInx = -1
418 m.lastRenderedInx = -1
431− m.finalHight = -1
419+ m.finalHeight = -1
432 m.needsRerender = true
420 m.needsRerender = true
433}
421}
434
422
435func (m *model) goToTop() tea.Cmd {
423func (m *model) goToTop() tea.Cmd {
424+ if len(m.items) == 0 {
425+ return nil
426+ }
436 var cmds []tea.Cmd
427 var cmds []tea.Cmd
437 m.reverse = false
428 m.reverse = false
438 cmd := m.blurSelected()
429 cmd := m.blurSelected()
@@ -480,7 +471,7 @@ func (m *model) rerenderItem(inx int) {
480 }
471 }
481 rerenderedItem := m.items[inx].View()
472 rerenderedItem := m.items[inx].View()
482 rerenderedLines := strings.Split(rerenderedItem, "\n")
473 rerenderedLines := strings.Split(rerenderedItem, "\n")
483− if m.gapSize > 0 && inx != len(m.items)-1 {
474+ if m.gapSize > 0 {
484 for range m.gapSize {
475 for range m.gapSize {
485 rerenderedLines = append(rerenderedLines, "")
476 rerenderedLines = append(rerenderedLines, "")
486 }
477 }
@@ -492,7 +483,6 @@ func (m *model) rerenderItem(inx int) {
492 }
483 }
493 // check if the item is in the content
484 // check if the item is in the content
494 start := cachedItem.start
485 start := cachedItem.start
495− logging.Info("rerenderItem", "inx", inx, "start", start, "cachedItem.start", cachedItem.start, "cachedItem.height", cachedItem.height)
496 end := start + cachedItem.height
486 end := start + cachedItem.height
497 totalLines := len(m.renderedLines)
487 totalLines := len(m.renderedLines)
498 if m.reverse {
488 if m.reverse {
@@ -504,9 +494,35 @@ func (m *model) rerenderItem(inx int) {
504 m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
494 m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
505 }
495 }
506 // TODO: if hight changed do something
496 // TODO: if hight changed do something
507− if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
497+ if cachedItem.height != len(rerenderedLines) {
508 if inx == len(m.items)-1 {
498 if inx == len(m.items)-1 {
509− m.finalHight = max(0, start+len(rerenderedLines)-m.listHeight())
499+ m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
500+ }
501+
502+ // update the start of the other cached items
503+ currentStart := cachedItem.start + len(rerenderedLines)
504+ if m.reverse {
505+ for i := inx - 1; i < len(m.items); i-- {
506+ if existing, ok := m.renderedItems.Load(i); ok {
507+ cached := existing.(renderedItem)
508+ cached.start = currentStart
509+ currentStart += cached.height
510+ m.renderedItems.Store(i, cached)
511+ } else {
512+ break
513+ }
514+ }
515+ } else {
516+ for i := inx + 1; i < len(m.items); i++ {
517+ if existing, ok := m.renderedItems.Load(i); ok {
518+ cached := existing.(renderedItem)
519+ cached.start = currentStart
520+ currentStart += cached.height
521+ m.renderedItems.Store(i, cached)
522+ } else {
523+ break
524+ }
525+ }
510 }
526 }
511 }
527 }
512 m.renderedItems.Store(inx, renderedItem{
528 m.renderedItems.Store(inx, renderedItem{
@@ -518,11 +534,11 @@ func (m *model) rerenderItem(inx int) {
518}
534}
519
535
520func (m *model) increaseOffset(n int) {
536func (m *model) increaseOffset(n int) {
521− if m.finalHight > -1 {
537+ if m.finalHeight > -1 {
522− if m.offset < m.finalHight {
538+ if m.offset < m.finalHeight {
523 m.offset += n
539 m.offset += n
524− if m.offset > m.finalHight {
540+ if m.offset > m.finalHeight {
525− m.offset = m.finalHight
541+ m.offset = m.finalHeight
526 }
542 }
527 m.needsRerender = true
543 m.needsRerender = true
528 }
544 }
@@ -550,7 +566,8 @@ func (m *model) UpdateItem(inx int, item util.Model) {
550 i.Focus()
566 i.Focus()
551 }
567 }
552 }
568 }
553− m.ResetView()
569+ m.setItemSize(inx)
570+ m.rerenderItem(inx)
554 m.needsRerender = true
571 m.needsRerender = true
555}
572}
556
573
@@ -565,16 +582,15 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
565 return nil
582 return nil
566 }
583 }
567 if m.height != height {
584 if m.height != height {
568− m.finalHight = -1
585+ m.finalHeight = -1
569 m.height = height
586 m.height = height
570 }
587 }
571 m.width = width
588 m.width = width
572 m.ResetView()
589 m.ResetView()
573− return m.setItemsSize()
590+ return m.setAllItemsSize()
574}
591}
575
592
576−func (m *model) setItemsSize() tea.Cmd {
593+func (m *model) getItemSize() int {
577− var cmds []tea.Cmd
578 width := m.width
594 width := m.width
579 if m.padding != nil {
595 if m.padding != nil {
580 if len(m.padding) == 1 {
596 if len(m.padding) == 1 {
@@ -585,11 +601,22 @@ func (m *model) setItemsSize() tea.Cmd {
585 width -= m.padding[1] + m.padding[3]
601 width -= m.padding[1] + m.padding[3]
586 }
602 }
587 }
603 }
588− for _, item := range m.items {
604+ return width
605+}
606+
607+func (m *model) setItemSize(inx int) tea.Cmd {
589− if i, ok := item.(layout.Sizeable); ok {
608+ if i, ok := m.items[inx].(layout.Sizeable); ok {
590− cmd := i.SetSize(width, 0) // height is not limited
609+ cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
610+ return cmd
611+ }
612+ return nil
613+}
614+
615+func (m *model) setAllItemsSize() tea.Cmd {
616+ var cmds []tea.Cmd
617+ for i := range m.items {
618+ cmd := m.setItemSize(i)
591− cmds = append(cmds, cmd)
619+ cmds = append(cmds, cmd)
592− }
593 }
620 }
594 return tea.Batch(cmds...)
621 return tea.Batch(cmds...)
595}
622}
@@ -612,11 +639,16 @@ func (m *model) listHeight() int {
612
639
613// AppendItem implements List.
640// AppendItem implements List.
614func (m *model) AppendItem(item util.Model) tea.Cmd {
641func (m *model) AppendItem(item util.Model) tea.Cmd {
642+ var cmds []tea.Cmd
615 cmd := item.Init()
643 cmd := item.Init()
644+ cmds = append(cmds, cmd)
616 m.items = append(m.items, item)
645 m.items = append(m.items, item)
646+ cmd = m.setItemSize(len(m.items) - 1)
647+ cmds = append(cmds, cmd)
617− m.goToBottom()
648+ cmd = m.goToBottom()
649+ cmds = append(cmds, cmd)
618 m.needsRerender = true
650 m.needsRerender = true
619− return cmd
651+ return tea.Batch(cmds...)
620}
652}
621
653
622// DeleteItem implements List.
654// DeleteItem implements List.
@@ -632,7 +664,9 @@ func (m *model) DeleteItem(i int) {
632
664
633// PrependItem implements List.
665// PrependItem implements List.
634func (m *model) PrependItem(item util.Model) tea.Cmd {
666func (m *model) PrependItem(item util.Model) tea.Cmd {
667+ var cmds []tea.Cmd
635 cmd := item.Init()
668 cmd := item.Init()
669+ cmds = append(cmds, cmd)
636 m.items = append([]util.Model{item}, m.items...)
670 m.items = append([]util.Model{item}, m.items...)
637 // update the indices of the rendered items
671 // update the indices of the rendered items
638 newRenderedItems := make(map[int]renderedItem)
672 newRenderedItems := make(map[int]renderedItem)
@@ -647,9 +681,12 @@ func (m *model) PrependItem(item util.Model) tea.Cmd {
647 for k, v := range newRenderedItems {
681 for k, v := range newRenderedItems {
648 m.renderedItems.Store(k, v)
682 m.renderedItems.Store(k, v)
649 }
683 }
650− m.goToTop()
684+ cmd = m.goToTop()
685+ cmds = append(cmds, cmd)
686+ cmd = m.setItemSize(0)
687+ cmds = append(cmds, cmd)
651 m.needsRerender = true
688 m.needsRerender = true
652− return cmd
689+ return tea.Batch(cmds...)
653}
690}
654
691
655func (m *model) setReverse(reverse bool) {
692func (m *model) setReverse(reverse bool) {
@@ -664,7 +701,7 @@ func (m *model) setReverse(reverse bool) {
664func (m *model) SetItems(items []util.Model) tea.Cmd {
701func (m *model) SetItems(items []util.Model) tea.Cmd {
665 m.items = items
702 m.items = items
666 var cmds []tea.Cmd
703 var cmds []tea.Cmd
667− cmd := m.setItemsSize()
704+ cmd := m.setAllItemsSize()
668 cmds = append(cmds, cmd)
705 cmds = append(cmds, cmd)
669 for _, item := range m.items {
706 for _, item := range m.items {
670 cmds = append(cmds, item.Init())
707 cmds = append(cmds, item.Init())
@@ -678,7 +715,6 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
678 cmd := m.focusSelected()
715 cmd := m.focusSelected()
679 cmds = append(cmds, cmd)
716 cmds = append(cmds, cmd)
680 }
717 }
681− m.needsRerender = true
682 m.ResetView()
718 m.ResetView()
683 return tea.Batch(cmds...)
719 return tea.Batch(cmds...)
684}
720}
internal/tui/tui.go−1
@@ -186,7 +186,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186 var cmd tea.Cmd
186 var cmd tea.Cmd
187 switch msg := msg.(type) {
187 switch msg := msg.(type) {
188 case tea.WindowSizeMsg:
188 case tea.WindowSizeMsg:
189− logging.Info("Window size changed main: ", "Width", msg.Width, "Height", msg.Height)
190 msg.Height -= 1 // Make space for the status bar
189 msg.Height -= 1 // Make space for the status bar
191 a.width, a.height = msg.Width, msg.Height
190 a.width, a.height = msg.Width, msg.Height
192
191