Detailed changes
@@ -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 ") +
@@ -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
@@ -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
@@ -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
@@ -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]
@@ -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 {