From 0896789b516e50c4a13c2ddc01a02fe4466c4bd8 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 12:16:38 +0100 Subject: [PATCH 1/8] refactor(chat): initial setup for tool calls --- internal/ui/chat/bash.go | 72 ++++++++ internal/ui/chat/messages.go | 24 +++ internal/ui/chat/tools.go | 329 +++++++++++++++++++++++++++++++++++ internal/ui/styles/styles.go | 10 +- 4 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 internal/ui/chat/bash.go create mode 100644 internal/ui/chat/tools.go diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go new file mode 100644 index 0000000000000000000000000000000000000000..e933f319ff1f0b832d1af02524e8914927c66c0d --- /dev/null +++ b/internal/ui/chat/bash.go @@ -0,0 +1,72 @@ +package chat + +import ( + "encoding/json" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// BashToolRenderer renders a bash tool call. +func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + const toolName = "Bash" + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, toolName, opts.Anim) + } + + var params tools.BashParams + var cmd string + err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + if err != nil { + cmd = "failed to parse command" + } else { + cmd = strings.ReplaceAll(params.Command, "\n", " ") + cmd = strings.ReplaceAll(cmd, "\t", " ") + } + + toolParams := []string{ + cmd, + } + + if params.RunInBackground { + toolParams = append(toolParams, "background", "true") + } + + header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...) + earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth) + + // If this is OK that means that the tool is not done yet or it was canceled + if ok { + return strings.Join([]string{header, "", earlyStateContent}, "\n") + } + + if opts.Result == nil { + // We should not get here! + return header + } + + var meta tools.BashResponseMetadata + err = json.Unmarshal([]byte(opts.Result.Metadata), &meta) + + var output string + if err != nil { + output = "failed to parse output" + } + output = meta.Output + if output == "" && opts.Result.Content != tools.BashNoOutput { + output = opts.Result.Content + } + + if output == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + + output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded)) + + return strings.Join([]string{header, "", output}, "\n") +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index fe1e0c8f891c14f90c7e58cf8a99deb16d165765..9c925e0719436b4289cc2984f740df64c49c68ec 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -8,6 +8,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/list" @@ -164,6 +165,29 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s if shouldRenderAssistantMessage(msg) { items = append(items, NewAssistantMessageItem(sty, msg)) } + for _, tc := range msg.ToolCalls() { + var result *message.ToolResult + 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( + sty, + renderFunc, + tc, + result, + msg.FinishReason() == message.FinishReasonCanceled, + cappedWidth, + )) + + } return items } return []MessageItem{} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..e35934400f374feb22941020e0cf7f389eab8254 --- /dev/null +++ b/internal/ui/chat/tools.go @@ -0,0 +1,329 @@ +package chat + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// responseContextHeight limits the number of lines displayed in tool output. +const responseContextHeight = 10 + +// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body +const toolBodyLeftPaddingTotal = 2 + +// ToolStatus represents the current state of a tool call. +type ToolStatus int + +const ( + ToolStatusAwaitingPermission ToolStatus = iota + ToolStatusRunning + ToolStatusSuccess + ToolStatusError + ToolStatusCanceled +) + +// ToolRenderOpts contains the data needed to render a tool call. +type ToolRenderOpts struct { + ToolCall message.ToolCall + Result *message.ToolResult + Canceled bool + Anim *anim.Anim + Expanded bool + IsSpinning bool + PermissionRequested bool + PermissionGranted bool +} + +// Status returns the current status of the tool call. +func (opts *ToolRenderOpts) Status() ToolStatus { + if opts.Canceled { + return ToolStatusCanceled + } + if opts.Result != nil { + if opts.Result.IsError { + return ToolStatusError + } + return ToolStatusSuccess + } + if opts.PermissionRequested && !opts.PermissionGranted { + return ToolStatusAwaitingPermission + } + return ToolStatusRunning +} + +// ToolRenderFunc is a function that renders a tool call to a string. +type ToolRenderFunc func(sty *styles.Styles, width int, t *ToolRenderOpts) string + +// DefaultToolRenderer is a placeholder renderer for tools without a custom renderer. +func DefaultToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name +} + +// ToolMessageItem represents a tool call message that can be displayed in the UI. +type ToolMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + renderFunc ToolRenderFunc + toolCall message.ToolCall + result *message.ToolResult + canceled bool + // we use this so we can efficiently cache + // tools that have a capped width (e.x bash.. and others) + hasCappedWidth bool + + sty *styles.Styles + anim *anim.Anim + expanded bool +} + +// NewToolMessageItem creates a new tool message item with the given renderFunc. +func NewToolMessageItem( + sty *styles.Styles, + renderFunc ToolRenderFunc, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, + hasCappedWidth bool, +) *ToolMessageItem { + t := &ToolMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + sty: sty, + renderFunc: renderFunc, + toolCall: toolCall, + result: result, + canceled: canceled, + hasCappedWidth: hasCappedWidth, + } + t.anim = anim.New(anim.Settings{ + ID: toolCall.ID, + Size: 15, + GradColorA: sty.Primary, + GradColorB: sty.Secondary, + LabelColor: sty.FgBase, + CycleColors: true, + }) + return t +} + +// ID returns the unique identifier for this tool message item. +func (t *ToolMessageItem) ID() string { + return t.toolCall.ID +} + +// StartAnimation starts the assistant message animation if it should be spinning. +func (t *ToolMessageItem) StartAnimation() tea.Cmd { + if !t.isSpinning() { + return nil + } + return t.anim.Start() +} + +// Animate progresses the assistant message animation if it should be spinning. +func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if !t.isSpinning() { + return nil + } + return t.anim.Animate(msg) +} + +// Render renders the tool message item at the given width. +func (t *ToolMessageItem) Render(width int) string { + toolItemWidth := width - messageLeftPaddingTotal + if t.hasCappedWidth { + toolItemWidth = cappedMessageWidth(width) + } + style := t.sty.Chat.Message.ToolCallBlurred + if t.focused { + style = t.sty.Chat.Message.ToolCallFocused + } + + content, height, ok := t.getCachedRender(toolItemWidth) + // 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(), + }) + height = lipgloss.Height(content) + // cache the rendered content + t.setCachedRender(content, toolItemWidth, height) + } + + highlightedContent := t.renderHighlighted(content, toolItemWidth, height) + return style.Render(highlightedContent) +} + +// isSpinning returns true if the tool should show animation. +func (t *ToolMessageItem) isSpinning() bool { + return !t.toolCall.Finished && !t.canceled +} + +// ToggleExpanded toggles the expanded state of the thinking box. +func (t *ToolMessageItem) ToggleExpanded() { + t.expanded = !t.expanded + t.clearCache() +} + +// HandleMouseClick implements MouseClickable. +func (t *ToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { + if btn != ansi.MouseLeft { + return false + } + t.ToggleExpanded() + return true +} + +// pendingTool renders a tool that is still in progress with an animation. +func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string { + icon := sty.Tool.IconPending.Render() + toolName := sty.Tool.NameNormal.Render(name) + + var animView string + if anim != nil { + animView = anim.Render() + } + + return fmt.Sprintf("%s %s %s", icon, toolName, animView) +} + +// toolEarlyStateContent handles error/cancelled/pending states before content rendering. +// Returns the rendered output and true if early state was handled. +func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) { + var msg string + switch opts.Status() { + case ToolStatusError: + msg = toolErrorContent(sty, opts.Result, width) + case ToolStatusCanceled: + msg = sty.Tool.StateCancelled.Render("Canceled.") + case ToolStatusAwaitingPermission: + msg = sty.Tool.StateWaiting.Render("Requesting permission...") + case ToolStatusRunning: + msg = sty.Tool.StateWaiting.Render("Waiting for tool response...") + default: + return "", false + } + return msg, true +} + +// toolErrorContent formats an error message with ERROR tag. +func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string { + if result == nil { + return "" + } + errContent := strings.ReplaceAll(result.Content, "\n", " ") + errTag := sty.Tool.ErrorTag.Render("ERROR") + tagWidth := lipgloss.Width(errTag) + errContent = ansi.Truncate(errContent, width-tagWidth-3, "…") + return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent)) +} + +// toolIcon returns the status icon for a tool call. +// toolIcon returns the status icon for a tool call based on its status. +func toolIcon(sty *styles.Styles, status ToolStatus) string { + switch status { + case ToolStatusSuccess: + return sty.Tool.IconSuccess.String() + case ToolStatusError: + return sty.Tool.IconError.String() + case ToolStatusCanceled: + return sty.Tool.IconCancelled.String() + default: + return sty.Tool.IconPending.String() + } +} + +// toolParamList formats parameters as "main (key=value, ...)" with truncation. +// toolParamList formats tool parameters as "main (key=value, ...)" with truncation. +func toolParamList(sty *styles.Styles, params []string, width int) string { + // minSpaceForMainParam is the min space required for the main param + // if this is less that the value set we will only show the main param nothing else + const minSpaceForMainParam = 30 + if len(params) == 0 { + return "" + } + + mainParam := params[0] + + // Build key=value pairs from remaining params (consecutive key, value pairs). + var kvPairs []string + for i := 1; i+1 < len(params); i += 2 { + if params[i+1] != "" { + kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1])) + } + } + + // Try to include key=value pairs if there's enough space. + output := mainParam + if len(kvPairs) > 0 { + partsStr := strings.Join(kvPairs, ", ") + if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam { + output = fmt.Sprintf("%s (%s)", mainParam, partsStr) + } + } + + if width >= 0 { + output = ansi.Truncate(output, width, "…") + } + return sty.Tool.ParamMain.Render(output) +} + +// toolHeader builds the tool header line: "● ToolName params..." +func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string { + icon := toolIcon(sty, status) + toolName := sty.Tool.NameNested.Render(name) + prefix := fmt.Sprintf("%s %s ", icon, toolName) + prefixWidth := lipgloss.Width(prefix) + remainingWidth := width - prefixWidth + paramsStr := toolParamList(sty, params, remainingWidth) + return prefix + paramsStr +} + +// toolOutputPlainContent renders plain text with optional expansion support. +func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + content = strings.TrimSpace(content) + lines := strings.Split(content, "\n") + + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) // Show all + } + + var out []string + for i, ln := range lines { + if i >= maxLines { + break + } + ln = " " + ln + if lipgloss.Width(ln) > width { + ln = ansi.Truncate(ln, width, "…") + } + out = append(out, sty.Tool.ContentLine.Width(width).Render(ln)) + } + + wasTruncated := len(lines) > responseContextHeight + + if !expanded && wasTruncated { + out = append(out, sty.Tool.ContentTruncation. + Width(width). + Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight))) + } + + return strings.Join(out, "\n") +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index d6f96474c7daf3397ebebb9b819b2388790d7a95..21ace6a1e588f97a4be586cc01c5ecf3f0a88984 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -26,6 +26,8 @@ const ( DocumentIcon string = "🖼" ModelIcon string = "◇" + ArrowRightIcon string = "→" + ToolPending string = "●" ToolSuccess string = "✓" ToolError string = "×" @@ -34,6 +36,10 @@ const ( BorderThick string = "▌" SectionSeparator string = "─" + + TodoCompletedIcon string = "✓" + TodoPendingIcon string = "•" + TodoInProgressIcon string = "→" ) const ( @@ -227,7 +233,7 @@ type Styles struct { ContentTruncation lipgloss.Style // Truncation message "… (N lines)" ContentCodeLine lipgloss.Style // Code line with background and width ContentCodeBg color.Color // Background color for syntax highlighting - BodyPadding lipgloss.Style // Body content padding (PaddingLeft(2)) + Body lipgloss.Style // Body content padding (PaddingLeft(2)) // Deprecated - kept for backward compatibility ContentBg lipgloss.Style // Content background @@ -956,7 +962,7 @@ func DefaultStyles() Styles { s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter) s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter) s.Tool.ContentCodeBg = bgBase - s.Tool.BodyPadding = base.PaddingLeft(2) + s.Tool.Body = base.PaddingLeft(2) // Deprecated - kept for backward compatibility s.Tool.ContentBg = s.Muted.Background(bgBaseLighter) From 462cdf644189170903c3a51c4ee0363263d1d026 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 13:00:11 +0100 Subject: [PATCH 2/8] 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 { From 14ff2243a2f79c3925bc56247d7234aab1d332cd Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 13:08:50 +0100 Subject: [PATCH 3/8] chore(chat): small improvements --- internal/ui/chat/messages.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index af89bc5e7cbb9be861fc22dbcc6a1141c671fb93..9bf977d2e2f6fbd960e57a7e5d77fedb28b5b54b 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -182,18 +182,25 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s return []MessageItem{} } -func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem { +// GetToolRenderer returns the appropriate ToolRenderFunc for a given tool call. +// this should be used for nested tools as well. +func GetToolRenderer(tc message.ToolCall) ToolRenderFunc { 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 renderFunc +} + +// GetToolMessageItem creates a MessageItem for a tool call and its result. +func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem { + // we only do full width for diffs (as far as I know) + cappedWidth := tc.Name != tools.EditToolName && tc.Name != tools.MultiEditToolName return NewToolMessageItem( sty, - renderFunc, + GetToolRenderer(tc), tc, result, canceled, From 03beedc2002cb9f909568ec3c32775fca0faf023 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 14:33:28 +0100 Subject: [PATCH 4/8] fix(chat): do not mark tools with results as canceled --- internal/ui/chat/tools.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index b643ef2805708ade2fbc209bb6b1b9a29ade97e4..5d6987c604aac42090659dd35d3da77d9bfe9a2e 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -44,7 +44,7 @@ type ToolRenderOpts struct { // Status returns the current status of the tool call. func (opts *ToolRenderOpts) Status() ToolStatus { - if opts.Canceled { + if opts.Canceled && opts.Result == nil { return ToolStatusCanceled } if opts.Result != nil { From 2cccd515a848a5df0d9dffea3c3f5f037752c07b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 13:24:59 -0500 Subject: [PATCH 5/8] refactor(chat): rename GetMessageItems to ExtractMessageItems and GetToolRenderer to ToolRenderer --- internal/ui/chat/messages.go | 33 +++++++++------------------------ internal/ui/chat/tools.go | 7 ++++--- internal/ui/model/chat.go | 4 ++-- internal/ui/model/ui.go | 12 ++++++------ 4 files changed, 21 insertions(+), 35 deletions(-) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 9bf977d2e2f6fbd960e57a7e5d77fedb28b5b54b..000847a192a106f45aa6836281e50bf89426f20f 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -151,12 +151,12 @@ func cappedMessageWidth(availableWidth int) int { return min(availableWidth-messageLeftPaddingTotal, maxTextWidth) } -// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns -// all parts of the message as [MessageItem]s. +// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It +// returns all parts of the message as [MessageItem]s. // // For assistant messages with tool calls, pass a toolResults map to link results. // Use BuildToolResultMap to create this map from all messages in a session. -func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { +func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { switch msg.Role { case message.User: return []MessageItem{NewUserMessageItem(sty, msg)} @@ -170,7 +170,7 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s if tr, ok := toolResults[tc.ID]; ok { result = &tr } - items = append(items, GetToolMessageItem( + items = append(items, NewToolMessageItem( sty, tc, result, @@ -182,30 +182,15 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s return []MessageItem{} } -// GetToolRenderer returns the appropriate ToolRenderFunc for a given tool call. +// ToolRenderer returns the appropriate [ToolRenderFunc] for a given tool call. // this should be used for nested tools as well. -func GetToolRenderer(tc message.ToolCall) ToolRenderFunc { - renderFunc := DefaultToolRenderer +func ToolRenderer(tc message.ToolCall) ToolRenderFunc { switch tc.Name { case tools.BashToolName: - renderFunc = BashToolRenderer + return BashToolRenderer + default: + return DefaultToolRenderer } - return renderFunc -} - -// GetToolMessageItem creates a MessageItem for a tool call and its result. -func GetToolMessageItem(sty *styles.Styles, tc message.ToolCall, result *message.ToolResult, canceled bool) MessageItem { - // we only do full width for diffs (as far as I know) - cappedWidth := tc.Name != tools.EditToolName && tc.Name != tools.MultiEditToolName - - return NewToolMessageItem( - sty, - GetToolRenderer(tc), - tc, - result, - canceled, - cappedWidth, - ) } // shouldRenderAssistantMessage determines if an assistant message should be rendered diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 5d6987c604aac42090659dd35d3da77d9bfe9a2e..1847ac72287af31b7a0026c2ba60f85366a19e0a 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/styles" @@ -91,18 +92,18 @@ type ToolMessageItem struct { // NewToolMessageItem creates a new tool message item with the given renderFunc. func NewToolMessageItem( sty *styles.Styles, - renderFunc ToolRenderFunc, toolCall message.ToolCall, result *message.ToolResult, canceled bool, - hasCappedWidth bool, ) *ToolMessageItem { + // we only do full width for diffs (as far as I know) + hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName t := &ToolMessageItem{ highlightableMessageItem: defaultHighlighter(sty), cachedMessageItem: &cachedMessageItem{}, focusableMessageItem: &focusableMessageItem{}, sty: sty, - renderFunc: renderFunc, + renderFunc: ToolRenderer(toolCall), toolCall: toolCall, result: result, canceled: canceled, diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index f506d469b6b90292af6373d20f2a296fa7d0ac6a..94a999cd75b5e3853cb173c2f287b0dfba3513f7 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -245,8 +245,8 @@ func (m *Chat) ClearMessages() { m.ClearMouse() } -// GetMessageItem returns the message item at the given id. -func (m *Chat) GetMessageItem(id string) chat.MessageItem { +// MessageItem returns the message item with the given ID, or nil if not found. +func (m *Chat) MessageItem(id string) chat.MessageItem { idx, ok := m.idInxMap[id] if !ok { return nil diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5fe8eeb3689a6a185c0e2cd6af7a1758120a5178..2dab87ba0de0eac8ed365938c7abc8395785c9b2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -380,7 +380,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { // Add messages to chat with linked tool results items := make([]chat.MessageItem, 0, len(msgs)*2) for _, msg := range msgPtrs { - items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...) + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) } // If the user switches between sessions while the agent is working we want @@ -407,7 +407,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd switch msg.Role { case message.User, message.Assistant: - items := chat.GetMessageItems(m.com.Styles, &msg, nil) + items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) for _, item := range items { if animatable, ok := item.(chat.Animatable); ok { if cmd := animatable.StartAnimation(); cmd != nil { @@ -421,7 +421,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } case message.Tool: for _, tr := range msg.ToolResults() { - toolItem := m.chat.GetMessageItem(tr.ToolCallID) + toolItem := m.chat.MessageItem(tr.ToolCallID) if toolItem == nil { // we should have an item! continue @@ -439,7 +439,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { // 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) + existingItem := m.chat.MessageItem(msg.ID) if existingItem == nil || msg.Role != message.Assistant { return nil } @@ -450,7 +450,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { var items []chat.MessageItem for _, tc := range msg.ToolCalls() { - existingToolItem := m.chat.GetMessageItem(tc.ID) + existingToolItem := m.chat.MessageItem(tc.ID) if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok { existingToolCall := toolItem.ToolCall() // only update if finished state changed or input changed @@ -460,7 +460,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { } } if existingToolItem == nil { - items = append(items, chat.GetToolMessageItem(m.com.Styles, tc, nil, false)) + items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false)) } } From 41b819f5204422eaf842d04454c4a1609ef07ae2 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 13:54:24 -0500 Subject: [PATCH 6/8] refactor(ui): chat: abstract tool message rendering --- internal/ui/chat/bash.go | 33 ++++++++++- internal/ui/chat/messages.go | 12 ---- internal/ui/chat/tools.go | 106 ++++++++++++++++++++++++++--------- internal/ui/model/ui.go | 4 +- 4 files changed, 113 insertions(+), 42 deletions(-) diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 539ed23497ecf8a0095beb0562f4040bacb39349..c57d8e8d8a3ba0f567373a81707b6fdc54166fa6 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -5,11 +5,40 @@ import ( "strings" "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/styles" ) -// BashToolRenderer renders a bash tool call. -func BashToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string { +// BashToolMessageItem is a message item that represents a bash tool call. +type BashToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*BashToolMessageItem)(nil) + +// NewBashToolMessageItem creates a new [BashToolMessageItem]. +func NewBashToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem( + sty, + toolCall, + result, + &BashToolRenderContext{}, + canceled, + ) +} + +// BashToolRenderContext holds context for rendering bash tool messages. +// +// It implements the [ToolRenderer] interface. +type BashToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) const toolName = "Bash" if !opts.ToolCall.Finished && !opts.Canceled { diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 000847a192a106f45aa6836281e50bf89426f20f..6bcb3a1c1fef2353f77329dd8814e277367fa948 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -8,7 +8,6 @@ import ( "strings" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/list" @@ -182,17 +181,6 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m return []MessageItem{} } -// ToolRenderer returns the appropriate [ToolRenderFunc] for a given tool call. -// this should be used for nested tools as well. -func ToolRenderer(tc message.ToolCall) ToolRenderFunc { - switch tc.Name { - case tools.BashToolName: - return BashToolRenderer - default: - return DefaultToolRenderer - } -} - // 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 1847ac72287af31b7a0026c2ba60f85366a19e0a..705462e2c10c049bccb7c2237e98b20a8f03477e 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -30,6 +30,23 @@ const ( ToolStatusCanceled ) +// ToolMessageItem represents a tool call message in the chat UI. +type ToolMessageItem interface { + MessageItem + + ToolCall() message.ToolCall + SetToolCall(tc message.ToolCall) + SetResult(res *message.ToolResult) +} + +// DefaultToolRenderContext implements the default [ToolRenderer] interface. +type DefaultToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name +} + // ToolRenderOpts contains the data needed to render a tool call. type ToolRenderOpts struct { ToolCall message.ToolCall @@ -60,21 +77,26 @@ func (opts *ToolRenderOpts) Status() ToolStatus { return ToolStatusRunning } -// ToolRenderFunc is a function that renders a tool call to a string. -type ToolRenderFunc func(sty *styles.Styles, width int, t *ToolRenderOpts) string +// ToolRenderer represents an interface for rendering tool calls. +type ToolRenderer interface { + RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string +} -// DefaultToolRenderer is a placeholder renderer for tools without a custom renderer. -func DefaultToolRenderer(sty *styles.Styles, width int, opts *ToolRenderOpts) string { - return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name +// ToolRendererFunc is a function type that implements the [ToolRenderer] interface. +type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string + +// RenderTool implements the ToolRenderer interface. +func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return f(sty, width, opts) } -// ToolMessageItem represents a tool call message that can be displayed in the UI. -type ToolMessageItem struct { +// baseToolMessageItem represents a tool call message that can be displayed in the UI. +type baseToolMessageItem struct { *highlightableMessageItem *cachedMessageItem *focusableMessageItem - renderFunc ToolRenderFunc + toolRenderer ToolRenderer toolCall message.ToolCall result *message.ToolResult canceled bool @@ -89,21 +111,23 @@ type ToolMessageItem struct { expanded bool } -// NewToolMessageItem creates a new tool message item with the given renderFunc. -func NewToolMessageItem( +// newBaseToolMessageItem is the internal constructor for base tool message items. +func newBaseToolMessageItem( sty *styles.Styles, toolCall message.ToolCall, result *message.ToolResult, + toolRenderer ToolRenderer, canceled bool, -) *ToolMessageItem { +) *baseToolMessageItem { // we only do full width for diffs (as far as I know) hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName - t := &ToolMessageItem{ + + t := &baseToolMessageItem{ highlightableMessageItem: defaultHighlighter(sty), cachedMessageItem: &cachedMessageItem{}, focusableMessageItem: &focusableMessageItem{}, sty: sty, - renderFunc: ToolRenderer(toolCall), + toolRenderer: toolRenderer, toolCall: toolCall, result: result, canceled: canceled, @@ -117,16 +141,42 @@ func NewToolMessageItem( LabelColor: sty.FgBase, CycleColors: true, }) + return t } +// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name. +// +// It returns a specific tool message item type if implemented, otherwise it +// returns a generic tool message item. +func NewToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + switch toolCall.Name { + case tools.BashToolName: + return NewBashToolMessageItem(sty, toolCall, result, canceled) + default: + // TODO: Implement other tool items + return newBaseToolMessageItem( + sty, + toolCall, + result, + &DefaultToolRenderContext{}, + canceled, + ) + } +} + // ID returns the unique identifier for this tool message item. -func (t *ToolMessageItem) ID() string { +func (t *baseToolMessageItem) ID() string { return t.toolCall.ID } // StartAnimation starts the assistant message animation if it should be spinning. -func (t *ToolMessageItem) StartAnimation() tea.Cmd { +func (t *baseToolMessageItem) StartAnimation() tea.Cmd { if !t.isSpinning() { return nil } @@ -134,7 +184,7 @@ func (t *ToolMessageItem) StartAnimation() tea.Cmd { } // Animate progresses the assistant message animation if it should be spinning. -func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { +func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { if !t.isSpinning() { return nil } @@ -142,7 +192,7 @@ func (t *ToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { } // Render renders the tool message item at the given width. -func (t *ToolMessageItem) Render(width int) string { +func (t *baseToolMessageItem) Render(width int) string { toolItemWidth := width - messageLeftPaddingTotal if t.hasCappedWidth { toolItemWidth = cappedMessageWidth(width) @@ -155,7 +205,7 @@ func (t *ToolMessageItem) Render(width int) string { content, height, ok := t.getCachedRender(toolItemWidth) // if we are spinning or there is no cache rerender if !ok || t.isSpinning() { - content = t.renderFunc(t.sty, toolItemWidth, &ToolRenderOpts{ + content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{ ToolCall: t.toolCall, Result: t.result, Canceled: t.canceled, @@ -175,47 +225,51 @@ func (t *ToolMessageItem) Render(width int) string { } // ToolCall returns the tool call associated with this message item. -func (t *ToolMessageItem) ToolCall() message.ToolCall { +func (t *baseToolMessageItem) ToolCall() message.ToolCall { return t.toolCall } // SetToolCall sets the tool call associated with this message item. -func (t *ToolMessageItem) SetToolCall(tc message.ToolCall) { +func (t *baseToolMessageItem) 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) { +func (t *baseToolMessageItem) 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) { +// TODO: Consider merging with SetPermissionGranted and add an interface for +// permission management. +func (t *baseToolMessageItem) 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) { +// TODO: Consider merging with SetPermissionRequested and add an interface for +// permission management. +func (t *baseToolMessageItem) SetPermissionGranted(granted bool) { t.permissionGranted = granted t.clearCache() } // isSpinning returns true if the tool should show animation. -func (t *ToolMessageItem) isSpinning() bool { +func (t *baseToolMessageItem) isSpinning() bool { return !t.toolCall.Finished && !t.canceled } // ToggleExpanded toggles the expanded state of the thinking box. -func (t *ToolMessageItem) ToggleExpanded() { +func (t *baseToolMessageItem) ToggleExpanded() { t.expanded = !t.expanded t.clearCache() } // HandleMouseClick implements MouseClickable. -func (t *ToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { +func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { if btn != ansi.MouseLeft { return false } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2dab87ba0de0eac8ed365938c7abc8395785c9b2..99f75f98fbf666dda9d3b05ef985977f071c4e46 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -426,7 +426,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { // we should have an item! continue } - if toolMsgItem, ok := toolItem.(*chat.ToolMessageItem); ok { + if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok { toolMsgItem.SetResult(&tr) } } @@ -451,7 +451,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { var items []chat.MessageItem for _, tc := range msg.ToolCalls() { existingToolItem := m.chat.MessageItem(tc.ID) - if toolItem, ok := existingToolItem.(*chat.ToolMessageItem); ok { + if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok { existingToolCall := toolItem.ToolCall() // only update if finished state changed or input changed // to avoid clearing the cache From e86304d5a6f6d9c0a806ae9e402ee971fcf8ee62 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 15:20:17 -0500 Subject: [PATCH 7/8] fix(ui): remove redundant check in thinking rendering --- internal/ui/chat/assistant.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index de1e6dceddfd4f2939190d6d4bde46ce3ff8fb55..026889f25ad22d76704b26485b5299080934009a 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -152,9 +152,6 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string isTruncated := totalLines > maxCollapsedThinkingHeight if !a.thinkingExpanded && isTruncated { lines = lines[totalLines-maxCollapsedThinkingHeight:] - } - - if !a.thinkingExpanded && isTruncated { hint := a.sty.Chat.Message.ThinkingTruncationHint.Render( fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight), ) From 8e244e651a6d8e662f88e6b640ef3541a319ae62 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 18 Dec 2025 15:21:49 -0500 Subject: [PATCH 8/8] fix(ui): improve thinking message truncation display --- internal/ui/chat/assistant.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 026889f25ad22d76704b26485b5299080934009a..5efa9c8b6a72aa9644f34618ad50b89a2aae3913 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -155,7 +155,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string hint := a.sty.Chat.Message.ThinkingTruncationHint.Render( fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight), ) - lines = append([]string{hint}, lines...) + lines = append(lines, "", hint) } thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)