Detailed changes
@@ -152,13 +152,10 @@ 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),
)
- lines = append([]string{hint}, lines...)
+ lines = append(lines, "", hint)
}
thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
@@ -167,7 +164,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 ") +
@@ -0,0 +1,108 @@
+package chat
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// 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 {
+ 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", " ")
+ }
+
+ // TODO: if the tool is being run in the background use the background job renderer
+
+ toolParams := []string{
+ cmd,
+ }
+
+ if params.RunInBackground {
+ toolParams = append(toolParams, "background", "true")
+ }
+
+ 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
+ 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")
+}
@@ -150,12 +150,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)}
@@ -164,6 +164,18 @@ 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
+ }
+ items = append(items, NewToolMessageItem(
+ sty,
+ tc,
+ result,
+ msg.FinishReason() == message.FinishReasonCanceled,
+ ))
+ }
return items
}
return []MessageItem{}
@@ -0,0 +1,418 @@
+package chat
+
+import (
+ "fmt"
+ "strings"
+
+ 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"
+ "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
+)
+
+// 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
+ Result *message.ToolResult
+ Canceled bool
+ Anim *anim.Anim
+ Expanded bool
+ Nested bool
+ IsSpinning bool
+ PermissionRequested bool
+ PermissionGranted bool
+}
+
+// Status returns the current status of the tool call.
+func (opts *ToolRenderOpts) Status() ToolStatus {
+ if opts.Canceled && opts.Result == nil {
+ return ToolStatusCanceled
+ }
+ if opts.Result != nil {
+ if opts.Result.IsError {
+ return ToolStatusError
+ }
+ return ToolStatusSuccess
+ }
+ if opts.PermissionRequested && !opts.PermissionGranted {
+ return ToolStatusAwaitingPermission
+ }
+ return ToolStatusRunning
+}
+
+// ToolRenderer represents an interface for rendering tool calls.
+type ToolRenderer interface {
+ RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
+}
+
+// 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)
+}
+
+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
+type baseToolMessageItem struct {
+ *highlightableMessageItem
+ *cachedMessageItem
+ *focusableMessageItem
+
+ toolRenderer ToolRenderer
+ 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
+
+ sty *styles.Styles
+ anim *anim.Anim
+ expanded bool
+}
+
+// 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,
+) *baseToolMessageItem {
+ // we only do full width for diffs (as far as I know)
+ hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
+
+ t := &baseToolMessageItem{
+ highlightableMessageItem: defaultHighlighter(sty),
+ cachedMessageItem: &cachedMessageItem{},
+ focusableMessageItem: &focusableMessageItem{},
+ sty: sty,
+ toolRenderer: toolRenderer,
+ 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
+}
+
+// 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 *baseToolMessageItem) ID() string {
+ return t.toolCall.ID
+}
+
+// StartAnimation starts the assistant message animation if it should be spinning.
+func (t *baseToolMessageItem) 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 *baseToolMessageItem) 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 *baseToolMessageItem) 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.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
+ 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
+ t.setCachedRender(content, toolItemWidth, height)
+ }
+
+ highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
+ return style.Render(highlightedContent)
+}
+
+// ToolCall returns the tool call associated with this message item.
+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
+ return t.toolCall
+}
+
+// SetToolCall sets the tool call associated with this message item.
+func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
+ t.toolCall = tc
+ t.clearCache()
+}
+
+// SetResult sets the tool result associated with this message item.
+func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
+ t.result = res
+ t.clearCache()
+}
+
+// SetPermissionRequested sets whether permission has been requested for this tool call.
+// 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.
+// 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 *baseToolMessageItem) isSpinning() bool {
+ return !t.toolCall.Finished && !t.canceled
+}
+
+// ToggleExpanded toggles the expanded state of the thinking box.
+func (t *baseToolMessageItem) ToggleExpanded() {
+ t.expanded = !t.expanded
+ t.clearCache()
+}
+
+// HandleMouseClick implements MouseClickable.
+func (t *baseToolMessageItem) 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")
+}
@@ -237,8 +237,16 @@ func (m *Chat) SelectLastInView() {
m.list.SelectLastInView()
}
-// GetMessageItem returns the message item at the given id.
-func (m *Chat) GetMessageItem(id string) chat.MessageItem {
+// 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()
+}
+
+// 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
@@ -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]:
@@ -381,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
@@ -403,9 +402,68 @@ 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
+ switch msg.Role {
+ case message.User, message.Assistant:
+ 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 {
+ cmds = append(cmds, cmd)
+ }
+ }
+ }
+ m.chat.AppendMessages(items...)
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case message.Tool:
+ for _, tr := range msg.ToolResults() {
+ toolItem := m.chat.MessageItem(tr.ToolCallID)
+ if toolItem == nil {
+ // we should have an item!
+ continue
+ }
+ if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
+ toolMsgItem.SetResult(&tr)
+ }
+ }
+ }
+ return tea.Batch(cmds...)
+}
+
+// updateSessionMessage updates an existing message in the current session in the chat
+// 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.MessageItem(msg.ID)
+ 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.MessageItem(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.NewToolMessageItem(m.com.Styles, tc, nil, false))
+ }
+ }
+
for _, item := range items {
if animatable, ok := item.(chat.Animatable); ok {
if cmd := animatable.StartAnimation(); cmd != nil {
@@ -417,21 +475,8 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
if cmd := m.chat.ScrollToBottomAndAnimate(); 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
-func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
- existingItem := m.chat.GetMessageItem(msg.ID)
- switch msg.Role {
- case message.Assistant:
- if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
- assistantItem.SetMessage(&msg)
- }
- }
-
- return nil
+ 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 {
@@ -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)