on main help sign in
refactor(chat): hook up events for tools
462cdf6Kujtim Hoxha
internal/ui/chat/assistant.go+1−1
@@ -167,7 +167,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
167
167
168 var footer string
168 var footer string
169 // if thinking is done add the thought for footer
169 // if thinking is done add the thought for footer
170− if !a.message.IsThinking() {
170+ if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
171 duration := a.message.ThinkingDuration()
171 duration := a.message.ThinkingDuration()
172 if duration.String() != "0s" {
172 if duration.String() != "0s" {
173 footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
173 footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
internal/ui/chat/bash.go+7
@@ -27,6 +27,8 @@ func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) strin
27 cmd = strings.ReplaceAll(cmd, "\t", " ")
27 cmd = strings.ReplaceAll(cmd, "\t", " ")
28 }
28 }
29
29
30+ // TODO: if the tool is being run in the background use the background job renderer
31+
30 toolParams := []string{
32 toolParams := []string{
31 cmd,
33 cmd,
32 }
34 }
@@ -36,6 +38,11 @@ func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) strin
36 }
38 }
37
39
38 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
40 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
41+
42+ if opts.Nested {
43+ return header
44+ }
45+
39 earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
46 earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
40
47
41 // If this is OK that means that the tool is not done yet or it was canceled
48 // If this is OK that means that the tool is not done yet or it was canceled
internal/ui/chat/messages.go+20−12
@@ -170,29 +170,37 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
170 if tr, ok := toolResults[tc.ID]; ok {
170 if tr, ok := toolResults[tc.ID]; ok {
171 result = &tr
171 result = &tr
172 }
172 }
173− renderFunc := DefaultToolRenderer
174− // we only do full width for diffs (as far as I know)
175− cappedWidth := true
176− switch tc.Name {
177− case tools.BashToolName:
178− renderFunc = BashToolRenderer
179− }
180−
181− items = append(items, NewToolMessageItem(
173+ items = append(items, GetToolMessageItem(
182 sty,
174 sty,
183− renderFunc,
184 tc,
175 tc,
185 result,
176 result,
186 msg.FinishReason() == message.FinishReasonCanceled,
177 msg.FinishReason() == message.FinishReasonCanceled,
187− cappedWidth,
188 ))
178 ))
189−
190 }
179 }
191 return items
180 return items
192 }
181 }
193 return []MessageItem{}
182 return []MessageItem{}
194}
183}
195
184
185+func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem {
186+ renderFunc := DefaultToolRenderer
187+ // we only do full width for diffs (as far as I know)
188+ cappedWidth := true
189+ switch tc.Name {
190+ case tools.BashToolName:
191+ renderFunc = BashToolRenderer
192+ }
193+
194+ return NewToolMessageItem(
195+ sty,
196+ renderFunc,
197+ tc,
198+ result,
199+ canceled,
200+ cappedWidth,
201+ )
202+}
203+
196// shouldRenderAssistantMessage determines if an assistant message should be rendered
204// shouldRenderAssistantMessage determines if an assistant message should be rendered
197//
205//
198// In some cases the assistant message only has tools so we do not want to render an
206// In some cases the assistant message only has tools so we do not want to render an
internal/ui/chat/tools.go+44−10
@@ -36,6 +36,7 @@ type ToolRenderOpts struct {
36 Canceled bool
36 Canceled bool
37 Anim *anim.Anim
37 Anim *anim.Anim
38 Expanded bool
38 Expanded bool
39+ Nested bool
39 IsSpinning bool
40 IsSpinning bool
40 PermissionRequested bool
41 PermissionRequested bool
41 PermissionGranted bool
42 PermissionGranted bool
@@ -72,10 +73,12 @@ type ToolMessageItem struct {
72 *cachedMessageItem
73 *cachedMessageItem
73 *focusableMessageItem
74 *focusableMessageItem
74
75
75− renderFunc ToolRenderFunc
76+ renderFunc ToolRenderFunc
76− toolCall message.ToolCall
77+ toolCall message.ToolCall
77− result *message.ToolResult
78+ result *message.ToolResult
78− canceled bool
79+ canceled bool
80+ permissionRequested bool
81+ permissionGranted bool
79 // we use this so we can efficiently cache
82 // we use this so we can efficiently cache
80 // tools that have a capped width (e.x bash.. and others)
83 // tools that have a capped width (e.x bash.. and others)
81 hasCappedWidth bool
84 hasCappedWidth bool
@@ -152,12 +155,14 @@ func (t *ToolMessageItem) Render(width int) string {
152 // if we are spinning or there is no cache rerender
155 // if we are spinning or there is no cache rerender
153 if !ok || t.isSpinning() {
156 if !ok || t.isSpinning() {
154 content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{
157 content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{
155− ToolCall: t.toolCall,
158+ ToolCall: t.toolCall,
156− Result: t.result,
159+ Result: t.result,
157− Canceled: t.canceled,
160+ Canceled: t.canceled,
158− Anim: t.anim,
161+ Anim: t.anim,
159− Expanded: t.expanded,
162+ Expanded: t.expanded,
163+ PermissionRequested: t.permissionRequested,
164+ PermissionGranted: t.permissionGranted,
160− IsSpinning: t.isSpinning(),
165+ IsSpinning: t.isSpinning(),
161 })
166 })
162 height = lipgloss.Height(content)
167 height = lipgloss.Height(content)
163 // cache the rendered content
168 // cache the rendered content
@@ -168,6 +173,35 @@ func (t *ToolMessageItem) Render(width int) string {
168 return style.Render(highlightedContent)
173 return style.Render(highlightedContent)
169}
174}
170
175
176+// ToolCall returns the tool call associated with this message item.
177+func (t *ToolMessageItem) ToolCall() message.ToolCall {
178+ return t.toolCall
179+}
180+
181+// SetToolCall sets the tool call associated with this message item.
182+func (t *ToolMessageItem) SetToolCall(tc message.ToolCall) {
183+ t.toolCall = tc
184+ t.clearCache()
185+}
186+
187+// SetResult sets the tool result associated with this message item.
188+func (t *ToolMessageItem) SetResult(res *message.ToolResult) {
189+ t.result = res
190+ t.clearCache()
191+}
192+
193+// SetPermissionRequested sets whether permission has been requested for this tool call.
194+func (t *ToolMessageItem) SetPermissionRequested(requested bool) {
195+ t.permissionRequested = requested
196+ t.clearCache()
197+}
198+
199+// SetPermissionGranted sets whether permission has been granted for this tool call.
200+func (t *ToolMessageItem) SetPermissionGranted(granted bool) {
201+ t.permissionGranted = granted
202+ t.clearCache()
203+}
204+
171// isSpinning returns true if the tool should show animation.
205// isSpinning returns true if the tool should show animation.
172func (t *ToolMessageItem) isSpinning() bool {
206func (t *ToolMessageItem) isSpinning() bool {
173 return !t.toolCall.Finished && !t.canceled
207 return !t.toolCall.Finished && !t.canceled
internal/ui/model/chat.go+8
@@ -237,6 +237,14 @@ func (m *Chat) SelectLastInView() {
237 m.list.SelectLastInView()
237 m.list.SelectLastInView()
238}
238}
239
239
240+// ClearMessages removes all messages from the chat list.
241+func (m *Chat) ClearMessages() {
242+ m.idInxMap = make(map[string]int)
243+ m.pausedAnimations = make(map[string]struct{})
244+ m.list.SetItems()
245+ m.ClearMouse()
246+}
247+
240// GetMessageItem returns the message item at the given id.
248// GetMessageItem returns the message item at the given id.
241func (m *Chat) GetMessageItem(id string) chat.MessageItem {
249func (m *Chat) GetMessageItem(id string) chat.MessageItem {
242 idx, ok := m.idInxMap[id]
250 idx, ok := m.idInxMap[id]
internal/ui/model/ui.go+94−17
@@ -208,6 +208,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
208 }
208 }
209
209
210 case pubsub.Event[message.Message]:
210 case pubsub.Event[message.Message]:
211+ // TODO: handle nested messages for agentic tools
211 if m.session == nil || msg.Payload.SessionID != m.session.ID {
212 if m.session == nil || msg.Payload.SessionID != m.session.ID {
212 break
213 break
213 }
214 }
@@ -217,8 +218,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
217 case pubsub.UpdatedEvent:
218 case pubsub.UpdatedEvent:
218 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
219 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
219 }
220 }
220− // TODO: Finish implementing me
221− // cmds = append(cmds, m.setMessageEvents(msg.Payload))
222 case pubsub.Event[history.File]:
221 case pubsub.Event[history.File]:
223 cmds = append(cmds, m.handleFileEvent(msg.Payload))
222 cmds = append(cmds, m.handleFileEvent(msg.Payload))
224 case pubsub.Event[app.LSPEvent]:
223 case pubsub.Event[app.LSPEvent]:
@@ -403,35 +402,81 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
403}
402}
404
403
405// appendSessionMessage appends a new message to the current session in the chat
404// appendSessionMessage appends a new message to the current session in the chat
405+// if the message is a tool result it will update the corresponding tool call message
406func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
406func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
407− items := chat.GetMessageItems(m.com.Styles, &msg, nil)
408 var cmds []tea.Cmd
407 var cmds []tea.Cmd
408+ switch msg.Role {
409+ case message.User, message.Assistant:
410+ items := chat.GetMessageItems(m.com.Styles, &msg, nil)
409− for _, item := range items {
411+ for _, item := range items {
410− if animatable, ok := item.(chat.Animatable); ok {
412+ if animatable, ok := item.(chat.Animatable); ok {
411− if cmd := animatable.StartAnimation(); cmd != nil {
413+ if cmd := animatable.StartAnimation(); cmd != nil {
414+ cmds = append(cmds, cmd)
415+ }
416+ }
417+ }
418+ m.chat.AppendMessages(items...)
419+ if cmd := m.chat.ScrollToBottom(); cmd != nil {
412− cmds = append(cmds, cmd)
420+ cmds = append(cmds, cmd)
421+ }
422+ case message.Tool:
423+ for _, tr := range msg.ToolResults() {
424+ toolItem := m.chat.GetMessageItem(tr.ToolCallID)
425+ if toolItem == nil {
426+ // we should have an item!
427+ continue
428+ }
429+ if toolMsgItem, ok := toolItem.(*chat.ToolMessageItem); ok {
430+ toolMsgItem.SetResult(&tr)
413 }
431 }
414 }
432 }
415− }
416− m.chat.AppendMessages(items...)
417− if cmd := m.chat.ScrollToBottom(); cmd != nil {
418− cmds = append(cmds, cmd)
419 }
433 }
420 return tea.Batch(cmds...)
434 return tea.Batch(cmds...)
421}
435}
422
436
423// updateSessionMessage updates an existing message in the current session in the chat
437// updateSessionMessage updates an existing message in the current session in the chat
424−// INFO: currently only updates the assistant when I add tools this will get a bit more complex
438+// when an assistant message is updated it may include updated tool calls as well
439+// that is why we need to handle creating/updating each tool call message too
425func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
440func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
441+ var cmds []tea.Cmd
426 existingItem := m.chat.GetMessageItem(msg.ID)
442 existingItem := m.chat.GetMessageItem(msg.ID)
427− switch msg.Role {
428− case message.Assistant:
443+ if existingItem == nil || msg.Role != message.Assistant {
444+ return nil
445+ }
446+
429− if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
447+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
430− assistantItem.SetMessage(&msg)
448+ assistantItem.SetMessage(&msg)
449+ }
450+
451+ var items []chat.MessageItem
452+ for _, tc := range msg.ToolCalls() {
453+ existingToolItem := m.chat.GetMessageItem(tc.ID)
454+ if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok {
455+ existingToolCall := toolItem.ToolCall()
456+ // only update if finished state changed or input changed
457+ // to avoid clearing the cache
458+ if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
459+ toolItem.SetToolCall(tc)
460+ }
461+ }
462+ if existingToolItem == nil {
463+ items = append(items, chat.GetToolMessageItem(m.com.Styles, tc, nil, false))
431 }
464 }
432 }
465 }
433
466
467+ for _, item := range items {
468+ if animatable, ok := item.(chat.Animatable); ok {
469+ if cmd := animatable.StartAnimation(); cmd != nil {
470+ cmds = append(cmds, cmd)
471+ }
472+ }
473+ }
474+ m.chat.AppendMessages(items...)
475+ if cmd := m.chat.ScrollToBottom(); cmd != nil {
476+ cmds = append(cmds, cmd)
477+ }
478+
434− return nil
479+ return tea.Batch(cmds...)
435}
480}
436
481
437func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
482func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -498,6 +543,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
498 case dialog.SwitchSessionsMsg:
543 case dialog.SwitchSessionsMsg:
499 cmds = append(cmds, m.listSessions)
544 cmds = append(cmds, m.listSessions)
500 m.dialog.CloseDialog(dialog.CommandsID)
545 m.dialog.CloseDialog(dialog.CommandsID)
546+ case dialog.NewSessionsMsg:
547+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
548+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
549+ break
550+ }
551+ m.newSession()
552+ m.dialog.CloseDialog(dialog.CommandsID)
501 case dialog.CompactMsg:
553 case dialog.CompactMsg:
502 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
554 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
503 if err != nil {
555 if err != nil {
@@ -548,6 +600,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
548 m.randomizePlaceholders()
600 m.randomizePlaceholders()
549
601
550 return m.sendMessage(value, attachments)
602 return m.sendMessage(value, attachments)
603+ case key.Matches(msg, m.keyMap.Chat.NewSession):
604+ if m.session == nil || m.session.ID == "" {
605+ break
606+ }
607+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
608+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
609+ break
610+ }
611+ m.newSession()
551 case key.Matches(msg, m.keyMap.Tab):
612 case key.Matches(msg, m.keyMap.Tab):
552 m.focus = uiFocusMain
613 m.focus = uiFocusMain
553 m.textarea.Blur()
614 m.textarea.Blur()
@@ -1362,6 +1423,22 @@ func (m *UI) listSessions() tea.Msg {
1362 return listSessionsMsg{sessions: allSessions}
1423 return listSessionsMsg{sessions: allSessions}
1363}
1424}
1364
1425
1426+// newSession clears the current session state and prepares for a new session.
1427+// The actual session creation happens when the user sends their first message.
1428+func (m *UI) newSession() {
1429+ if m.session == nil || m.session.ID == "" {
1430+ return
1431+ }
1432+
1433+ m.session = nil
1434+ m.sessionFiles = nil
1435+ m.state = uiLanding
1436+ m.focus = uiFocusEditor
1437+ m.textarea.Focus()
1438+ m.chat.Blur()
1439+ m.chat.ClearMessages()
1440+}
1441+
1365// handlePasteMsg handles a paste message.
1442// handlePasteMsg handles a paste message.
1366func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1443func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1367 if m.focus != uiFocusEditor {
1444 if m.focus != uiFocusEditor {