on main help sign in
refactor(chat): hook up events for tools
commit462cdf644189170903c3a51c4ee0363263d1d026 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 18 Dec 2025, 13:00 (1 year ago) parent0896789
6 files changed+174−40
internal/ui/chat/assistant.go+1−1 internal/ui/chat/bash.go+7 internal/ui/chat/messages.go+20−12 internal/ui/chat/tools.go+44−10 internal/ui/model/chat.go+8 internal/ui/model/ui.go+94−17
internal/ui/chat/assistant.go+1−1
@@ -167,7 +167,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
167167
168168 var footer string
169169 // if thinking is done add the thought for footer
170− if !a.message.IsThinking() {
170+ if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
171171 duration := a.message.ThinkingDuration()
172172 if duration.String() != "0s" {
173173 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
2727 cmd = strings.ReplaceAll(cmd, "\t", " ")
2828 }
2929
30+ // TODO: if the tool is being run in the background use the background job renderer
31+
3032 toolParams := []string{
3133 cmd,
3234 }
@@ -36,6 +38,11 @@ func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) strin
3638 }
3739
3840 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
41+
42+ if opts.Nested {
43+ return header
44+ }
45+
3946 earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
4047
4148 // 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
170170 if tr, ok := toolResults[tc.ID]; ok {
171171 result = &tr
172172 }
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(
182174 sty,
183− renderFunc,
184175 tc,
185176 result,
186177 msg.FinishReason() == message.FinishReasonCanceled,
187− cappedWidth,
188178 ))
189−
190179 }
191180 return items
192181 }
193182 return []MessageItem{}
194183}
195184
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+
196204// shouldRenderAssistantMessage determines if an assistant message should be rendered
197205//
198206// 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 {
3636 Canceled bool
3737 Anim *anim.Anim
3838 Expanded bool
39+ Nested bool
3940 IsSpinning bool
4041 PermissionRequested bool
4142 PermissionGranted bool
@@ -72,10 +73,12 @@ type ToolMessageItem struct {
7273 *cachedMessageItem
7374 *focusableMessageItem
7475
75− renderFunc ToolRenderFunc
76− toolCall message.ToolCall
77− result *message.ToolResult
78− canceled bool
76+ renderFunc ToolRenderFunc
77+ toolCall message.ToolCall
78+ result *message.ToolResult
79+ canceled bool
80+ permissionRequested bool
81+ permissionGranted bool
7982 // we use this so we can efficiently cache
8083 // tools that have a capped width (e.x bash.. and others)
8184 hasCappedWidth bool
@@ -152,12 +155,14 @@ func (t *ToolMessageItem) Render(width int) string {
152155 // if we are spinning or there is no cache rerender
153156 if !ok || t.isSpinning() {
154157 content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{
155− ToolCall: t.toolCall,
156− Result: t.result,
157− Canceled: t.canceled,
158− Anim: t.anim,
159− Expanded: t.expanded,
160− IsSpinning: t.isSpinning(),
158+ ToolCall: t.toolCall,
159+ Result: t.result,
160+ Canceled: t.canceled,
161+ Anim: t.anim,
162+ Expanded: t.expanded,
163+ PermissionRequested: t.permissionRequested,
164+ PermissionGranted: t.permissionGranted,
165+ IsSpinning: t.isSpinning(),
161166 })
162167 height = lipgloss.Height(content)
163168 // cache the rendered content
@@ -168,6 +173,35 @@ func (t *ToolMessageItem) Render(width int) string {
168173 return style.Render(highlightedContent)
169174}
170175
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+
171205// isSpinning returns true if the tool should show animation.
172206func (t *ToolMessageItem) isSpinning() bool {
173207 return !t.toolCall.Finished && !t.canceled
internal/ui/model/chat.go+8
@@ -237,6 +237,14 @@ func (m *Chat) SelectLastInView() {
237237 m.list.SelectLastInView()
238238}
239239
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+
240248// GetMessageItem returns the message item at the given id.
241249func (m *Chat) GetMessageItem(id string) chat.MessageItem {
242250 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) {
208208 }
209209
210210 case pubsub.Event[message.Message]:
211+ // TODO: handle nested messages for agentic tools
211212 if m.session == nil || msg.Payload.SessionID != m.session.ID {
212213 break
213214 }
@@ -217,8 +218,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
217218 case pubsub.UpdatedEvent:
218219 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
219220 }
220− // TODO: Finish implementing me
221− // cmds = append(cmds, m.setMessageEvents(msg.Payload))
222221 case pubsub.Event[history.File]:
223222 cmds = append(cmds, m.handleFileEvent(msg.Payload))
224223 case pubsub.Event[app.LSPEvent]:
@@ -403,35 +402,81 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
403402}
404403
405404// 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
406406func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
407− items := chat.GetMessageItems(m.com.Styles, &msg, nil)
408407 var cmds []tea.Cmd
409− for _, item := range items {
410− if animatable, ok := item.(chat.Animatable); ok {
411− if cmd := animatable.StartAnimation(); cmd != nil {
412− cmds = append(cmds, cmd)
408+ switch msg.Role {
409+ case message.User, message.Assistant:
410+ items := chat.GetMessageItems(m.com.Styles, &msg, nil)
411+ for _, item := range items {
412+ if animatable, ok := item.(chat.Animatable); ok {
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 {
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)
413431 }
414432 }
415− }
416− m.chat.AppendMessages(items...)
417− if cmd := m.chat.ScrollToBottom(); cmd != nil {
418− cmds = append(cmds, cmd)
419433 }
420434 return tea.Batch(cmds...)
421435}
422436
423437// 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
425440func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
441+ var cmds []tea.Cmd
426442 existingItem := m.chat.GetMessageItem(msg.ID)
427− switch msg.Role {
428− case message.Assistant:
429− if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
430− assistantItem.SetMessage(&msg)
443+ if existingItem == nil || msg.Role != message.Assistant {
444+ return nil
445+ }
446+
447+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
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))
431464 }
432465 }
433466
434− return nil
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+
479+ return tea.Batch(cmds...)
435480}
436481
437482func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -498,6 +543,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
498543 case dialog.SwitchSessionsMsg:
499544 cmds = append(cmds, m.listSessions)
500545 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)
501553 case dialog.CompactMsg:
502554 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
503555 if err != nil {
@@ -548,6 +600,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
548600 m.randomizePlaceholders()
549601
550602 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()
551612 case key.Matches(msg, m.keyMap.Tab):
552613 m.focus = uiFocusMain
553614 m.textarea.Blur()
@@ -1362,6 +1423,22 @@ func (m *UI) listSessions() tea.Msg {
13621423 return listSessionsMsg{sessions: allSessions}
13631424}
13641425
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+
13651442// handlePasteMsg handles a paste message.
13661443func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
13671444 if m.focus != uiFocusEditor {