refactor(chat): hook up events for tools

Kujtim Hoxha created

Change summary

internal/ui/chat/assistant.go |   2 
internal/ui/chat/bash.go      |   7 ++
internal/ui/chat/messages.go  |  32 ++++++----
internal/ui/chat/tools.go     |  54 ++++++++++++++---
internal/ui/model/chat.go     |   8 ++
internal/ui/model/ui.go       | 111 +++++++++++++++++++++++++++++++-----
6 files changed, 174 insertions(+), 40 deletions(-)

Detailed changes

internal/ui/chat/assistant.go 🔗

@@ -167,7 +167,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
 
 	var footer string
 	// if thinking is done add the thought for footer
-	if !a.message.IsThinking() {
+	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
 		duration := a.message.ThinkingDuration()
 		if duration.String() != "0s" {
 			footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +

internal/ui/chat/bash.go 🔗

@@ -27,6 +27,8 @@ func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) strin
 		cmd = strings.ReplaceAll(cmd, "\t", "    ")
 	}
 
+	// TODO: if the tool is being run in the background use the background job renderer
+
 	toolParams := []string{
 		cmd,
 	}
@@ -36,6 +38,11 @@ func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) strin
 	}
 
 	header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
+
+	if opts.Nested {
+		return header
+	}
+
 	earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
 
 	// If this is OK that means that the tool is not done yet or it was canceled

internal/ui/chat/messages.go 🔗

@@ -170,29 +170,37 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
 			if tr, ok := toolResults[tc.ID]; ok {
 				result = &tr
 			}
-			renderFunc := DefaultToolRenderer
-			// we only do full width for diffs (as far as I know)
-			cappedWidth := true
-			switch tc.Name {
-			case tools.BashToolName:
-				renderFunc = BashToolRenderer
-			}
-
-			items = append(items, NewToolMessageItem(
+			items = append(items, GetToolMessageItem(
 				sty,
-				renderFunc,
 				tc,
 				result,
 				msg.FinishReason() == message.FinishReasonCanceled,
-				cappedWidth,
 			))
-
 		}
 		return items
 	}
 	return []MessageItem{}
 }
 
+func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem {
+	renderFunc := DefaultToolRenderer
+	// we only do full width for diffs (as far as I know)
+	cappedWidth := true
+	switch tc.Name {
+	case tools.BashToolName:
+		renderFunc = BashToolRenderer
+	}
+
+	return NewToolMessageItem(
+		sty,
+		renderFunc,
+		tc,
+		result,
+		canceled,
+		cappedWidth,
+	)
+}
+
 // shouldRenderAssistantMessage determines if an assistant message should be rendered
 //
 // In some cases the assistant message only has tools so we do not want to render an

internal/ui/chat/tools.go 🔗

@@ -36,6 +36,7 @@ type ToolRenderOpts struct {
 	Canceled            bool
 	Anim                *anim.Anim
 	Expanded            bool
+	Nested              bool
 	IsSpinning          bool
 	PermissionRequested bool
 	PermissionGranted   bool
@@ -72,10 +73,12 @@ type ToolMessageItem struct {
 	*cachedMessageItem
 	*focusableMessageItem
 
-	renderFunc ToolRenderFunc
-	toolCall   message.ToolCall
-	result     *message.ToolResult
-	canceled   bool
+	renderFunc          ToolRenderFunc
+	toolCall            message.ToolCall
+	result              *message.ToolResult
+	canceled            bool
+	permissionRequested bool
+	permissionGranted   bool
 	// we use this so we can efficiently cache
 	// tools that have a capped width (e.x bash.. and others)
 	hasCappedWidth bool
@@ -152,12 +155,14 @@ func (t *ToolMessageItem) Render(width int) string {
 	// if we are spinning or there is no cache rerender
 	if !ok || t.isSpinning() {
 		content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{
-			ToolCall:   t.toolCall,
-			Result:     t.result,
-			Canceled:   t.canceled,
-			Anim:       t.anim,
-			Expanded:   t.expanded,
-			IsSpinning: t.isSpinning(),
+			ToolCall:            t.toolCall,
+			Result:              t.result,
+			Canceled:            t.canceled,
+			Anim:                t.anim,
+			Expanded:            t.expanded,
+			PermissionRequested: t.permissionRequested,
+			PermissionGranted:   t.permissionGranted,
+			IsSpinning:          t.isSpinning(),
 		})
 		height = lipgloss.Height(content)
 		// cache the rendered content
@@ -168,6 +173,35 @@ func (t *ToolMessageItem) Render(width int) string {
 	return style.Render(highlightedContent)
 }
 
+// ToolCall returns the tool call associated with this message item.
+func (t *ToolMessageItem) ToolCall() message.ToolCall {
+	return t.toolCall
+}
+
+// SetToolCall sets the tool call associated with this message item.
+func (t *ToolMessageItem) SetToolCall(tc message.ToolCall) {
+	t.toolCall = tc
+	t.clearCache()
+}
+
+// SetResult sets the tool result associated with this message item.
+func (t *ToolMessageItem) SetResult(res *message.ToolResult) {
+	t.result = res
+	t.clearCache()
+}
+
+// SetPermissionRequested sets whether permission has been requested for this tool call.
+func (t *ToolMessageItem) SetPermissionRequested(requested bool) {
+	t.permissionRequested = requested
+	t.clearCache()
+}
+
+// SetPermissionGranted sets whether permission has been granted for this tool call.
+func (t *ToolMessageItem) SetPermissionGranted(granted bool) {
+	t.permissionGranted = granted
+	t.clearCache()
+}
+
 // isSpinning returns true if the tool should show animation.
 func (t *ToolMessageItem) isSpinning() bool {
 	return !t.toolCall.Finished && !t.canceled

internal/ui/model/chat.go 🔗

@@ -237,6 +237,14 @@ func (m *Chat) SelectLastInView() {
 	m.list.SelectLastInView()
 }
 
+// ClearMessages removes all messages from the chat list.
+func (m *Chat) ClearMessages() {
+	m.idInxMap = make(map[string]int)
+	m.pausedAnimations = make(map[string]struct{})
+	m.list.SetItems()
+	m.ClearMouse()
+}
+
 // GetMessageItem returns the message item at the given id.
 func (m *Chat) GetMessageItem(id string) chat.MessageItem {
 	idx, ok := m.idInxMap[id]

internal/ui/model/ui.go 🔗

@@ -208,6 +208,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case pubsub.Event[message.Message]:
+		// TODO: handle nested messages for agentic tools
 		if m.session == nil || msg.Payload.SessionID != m.session.ID {
 			break
 		}
@@ -217,8 +218,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case pubsub.UpdatedEvent:
 			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 		}
-		// TODO: Finish implementing me
-		// cmds = append(cmds, m.setMessageEvents(msg.Payload))
 	case pubsub.Event[history.File]:
 		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 	case pubsub.Event[app.LSPEvent]:
@@ -403,35 +402,81 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 }
 
 // appendSessionMessage appends a new message to the current session in the chat
+// if the message is a tool result it will update the corresponding tool call message
 func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
-	items := chat.GetMessageItems(m.com.Styles, &msg, nil)
 	var cmds []tea.Cmd
-	for _, item := range items {
-		if animatable, ok := item.(chat.Animatable); ok {
-			if cmd := animatable.StartAnimation(); cmd != nil {
-				cmds = append(cmds, cmd)
+	switch msg.Role {
+	case message.User, message.Assistant:
+		items := chat.GetMessageItems(m.com.Styles, &msg, nil)
+		for _, item := range items {
+			if animatable, ok := item.(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+		}
+		m.chat.AppendMessages(items...)
+		if cmd := m.chat.ScrollToBottom(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case message.Tool:
+		for _, tr := range msg.ToolResults() {
+			toolItem := m.chat.GetMessageItem(tr.ToolCallID)
+			if toolItem == nil {
+				// we should have an item!
+				continue
+			}
+			if toolMsgItem, ok := toolItem.(*chat.ToolMessageItem); ok {
+				toolMsgItem.SetResult(&tr)
 			}
 		}
-	}
-	m.chat.AppendMessages(items...)
-	if cmd := m.chat.ScrollToBottom(); cmd != nil {
-		cmds = append(cmds, cmd)
 	}
 	return tea.Batch(cmds...)
 }
 
 // updateSessionMessage updates an existing message in the current session in the chat
-// INFO: currently only updates the assistant when I add tools this will get a bit more complex
+// when an assistant message is updated it may include updated tool calls as well
+// that is why we need to handle creating/updating each tool call message too
 func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
 	existingItem := m.chat.GetMessageItem(msg.ID)
-	switch msg.Role {
-	case message.Assistant:
-		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
-			assistantItem.SetMessage(&msg)
+	if existingItem == nil || msg.Role != message.Assistant {
+		return nil
+	}
+
+	if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
+		assistantItem.SetMessage(&msg)
+	}
+
+	var items []chat.MessageItem
+	for _, tc := range msg.ToolCalls() {
+		existingToolItem := m.chat.GetMessageItem(tc.ID)
+		if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok {
+			existingToolCall := toolItem.ToolCall()
+			// only update if finished state changed or input changed
+			// to avoid clearing the cache
+			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
+				toolItem.SetToolCall(tc)
+			}
+		}
+		if existingToolItem == nil {
+			items = append(items, chat.GetToolMessageItem(m.com.Styles, tc, nil, false))
 		}
 	}
 
-	return nil
+	for _, item := range items {
+		if animatable, ok := item.(chat.Animatable); ok {
+			if cmd := animatable.StartAnimation(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+	m.chat.AppendMessages(items...)
+	if cmd := m.chat.ScrollToBottom(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	return tea.Batch(cmds...)
 }
 
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -498,6 +543,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		case dialog.SwitchSessionsMsg:
 			cmds = append(cmds, m.listSessions)
 			m.dialog.CloseDialog(dialog.CommandsID)
+		case dialog.NewSessionsMsg:
+			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+				break
+			}
+			m.newSession()
+			m.dialog.CloseDialog(dialog.CommandsID)
 		case dialog.CompactMsg:
 			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
 			if err != nil {
@@ -548,6 +600,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				m.randomizePlaceholders()
 
 				return m.sendMessage(value, attachments)
+			case key.Matches(msg, m.keyMap.Chat.NewSession):
+				if m.session == nil || m.session.ID == "" {
+					break
+				}
+				if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+					break
+				}
+				m.newSession()
 			case key.Matches(msg, m.keyMap.Tab):
 				m.focus = uiFocusMain
 				m.textarea.Blur()
@@ -1362,6 +1423,22 @@ func (m *UI) listSessions() tea.Msg {
 	return listSessionsMsg{sessions: allSessions}
 }
 
+// newSession clears the current session state and prepares for a new session.
+// The actual session creation happens when the user sends their first message.
+func (m *UI) newSession() {
+	if m.session == nil || m.session.ID == "" {
+		return
+	}
+
+	m.session = nil
+	m.sessionFiles = nil
+	m.state = uiLanding
+	m.focus = uiFocusEditor
+	m.textarea.Focus()
+	m.chat.Blur()
+	m.chat.ClearMessages()
+}
+
 // handlePasteMsg handles a paste message.
 func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 	if m.focus != uiFocusEditor {