From 462cdf644189170903c3a51c4ee0363263d1d026 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 13:00:11 +0100 Subject: [PATCH] refactor(chat): hook up events for tools --- 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(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 331167bdc13d90233f881343ce607c07e0ef700c..de1e6dceddfd4f2939190d6d4bde46ce3ff8fb55 100644 --- a/internal/ui/chat/assistant.go +++ b/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 ") + diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index e933f319ff1f0b832d1af02524e8914927c66c0d..539ed23497ecf8a0095beb0562f4040bacb39349 100644 --- a/internal/ui/chat/bash.go +++ b/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 diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 9c925e0719436b4289cc2984f740df64c49c68ec..af89bc5e7cbb9be861fc22dbcc6a1141c671fb93 100644 --- a/internal/ui/chat/messages.go +++ b/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 diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index e35934400f374feb22941020e0cf7f389eab8254..b643ef2805708ade2fbc209bb6b1b9a29ade97e4 100644 --- a/internal/ui/chat/tools.go +++ b/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 diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 2844cb81619e99956ba5895686d82e65eef92ce7..7c19f4d6c49e7f33c57979a0f9f4a2230a780670 100644 --- a/internal/ui/model/chat.go +++ b/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] diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 57a7113ae5b432436f23ec79e0d6d5cacdbb94f0..e1cfcf0e66eb4c407678e9b65b080827989aea56 100644 --- a/internal/ui/model/ui.go +++ b/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 {