chore: add tool items

Kujtim Hoxha created

Change summary

internal/ui/chat/assistant.go   | 142 +++++++++-
internal/ui/chat/chat.go        |  91 +++++++
internal/ui/chat/tool_base.go   | 111 +++++++
internal/ui/chat/tool_items.go  | 328 +++++++++++++++++++++---
internal/ui/common/anim/anim.go | 446 +++++++++++++++++++++++++++++++++++
internal/ui/list/item.go        |   8 
internal/ui/list/list.go        |  83 ++++++
internal/ui/model/ui.go         | 272 +++++++++++++++++++++
internal/ui/styles/styles.go    |  16 +
9 files changed, 1,410 insertions(+), 87 deletions(-)

Detailed changes

internal/ui/chat/assistant.go 🔗

@@ -3,12 +3,15 @@ package chat
 import (
 	"fmt"
 	"strings"
+	"time"
 
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/common/anim"
+	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/x/ansi"
 )
@@ -26,18 +29,55 @@ type AssistantMessageItem struct {
 	sty               *styles.Styles
 	thinkingExpanded  bool
 	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
+
+	spinning         bool
+	anim             *anim.Anim
+	hasToolCalls     bool
+	isSummaryMessage bool
+
+	thinkingStartedAt  int64
+	thinkingFinishedAt int64
 }
 
 // NewAssistantMessage creates a new assistant message item.
-func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, sty *styles.Styles) *AssistantMessageItem {
-	return &AssistantMessageItem{
-		id:       id,
-		content:  content,
-		thinking: thinking,
-		finished: finished,
-		finish:   finish,
-		sty:      sty,
+func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, hasToolCalls, isSummaryMessage bool, thinkingStartedAt, thinkingFinishedAt int64, sty *styles.Styles) *AssistantMessageItem {
+	m := &AssistantMessageItem{
+		id:                 id,
+		content:            content,
+		thinking:           thinking,
+		finished:           finished,
+		finish:             finish,
+		hasToolCalls:       hasToolCalls,
+		isSummaryMessage:   isSummaryMessage,
+		thinkingStartedAt:  thinkingStartedAt,
+		thinkingFinishedAt: thinkingFinishedAt,
+		sty:                sty,
 	}
+
+	m.anim = anim.New(anim.Settings{
+		Size:        15,
+		GradColorA:  sty.Primary,
+		GradColorB:  sty.Secondary,
+		LabelColor:  sty.FgBase,
+		CycleColors: true,
+	})
+	m.spinning = m.shouldSpin()
+
+	return m
+}
+
+// shouldSpin returns true if the message should show loading animation.
+func (m *AssistantMessageItem) shouldSpin() bool {
+	if m.finished {
+		return false
+	}
+	if strings.TrimSpace(m.content) != "" {
+		return false
+	}
+	if m.hasToolCalls {
+		return false
+	}
+	return true
 }
 
 // ID implements Identifiable.
@@ -62,11 +102,17 @@ func (m *AssistantMessageItem) HighlightStyle() lipgloss.Style {
 
 // Render implements list.Item.
 func (m *AssistantMessageItem) Render(width int) string {
+	if m.spinning && m.thinking == "" {
+		if m.isSummaryMessage {
+			m.anim.SetLabel("Summarizing")
+		}
+		return m.anim.View()
+	}
+
 	cappedWidth := min(width, maxTextWidth)
 	content := strings.TrimSpace(m.content)
 	thinking := strings.TrimSpace(m.thinking)
 
-	// Handle empty finished messages.
 	if m.finished && content == "" {
 		switch m.finish.Reason {
 		case message.FinishReasonEndTurn:
@@ -79,13 +125,10 @@ func (m *AssistantMessageItem) Render(width int) string {
 	}
 
 	var parts []string
-
-	// Render thinking content if present.
 	if thinking != "" {
 		parts = append(parts, m.renderThinking(thinking, cappedWidth))
 	}
 
-	// Render main content.
 	if content != "" {
 		if len(parts) > 0 {
 			parts = append(parts, "")
@@ -96,6 +139,45 @@ func (m *AssistantMessageItem) Render(width int) string {
 	return lipgloss.JoinVertical(lipgloss.Left, parts...)
 }
 
+// Update implements list.Updatable for handling animation updates.
+func (m *AssistantMessageItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	switch msg.(type) {
+	case anim.StepMsg:
+		m.spinning = m.shouldSpin()
+		if !m.spinning {
+			return m, nil
+		}
+		updatedAnim, cmd := m.anim.Update(msg)
+		m.anim = updatedAnim
+		if cmd != nil {
+			return m, cmd
+		}
+	}
+
+	return m, nil
+}
+
+// InitAnimation initializes and starts the animation.
+func (m *AssistantMessageItem) InitAnimation() tea.Cmd {
+	m.spinning = m.shouldSpin()
+	return m.anim.Init()
+}
+
+// SetContent updates the assistant message with new content.
+func (m *AssistantMessageItem) SetContent(content, thinking string, finished bool, finish *message.Finish, hasToolCalls, isSummaryMessage bool, reasoning message.ReasoningContent) {
+	m.content = content
+	m.thinking = thinking
+	m.finished = finished
+	if finish != nil {
+		m.finish = *finish
+	}
+	m.hasToolCalls = hasToolCalls
+	m.isSummaryMessage = isSummaryMessage
+	m.thinkingStartedAt = reasoning.StartedAt
+	m.thinkingFinishedAt = reasoning.FinishedAt
+	m.spinning = m.shouldSpin()
+}
+
 // renderMarkdown renders content as markdown.
 func (m *AssistantMessageItem) renderMarkdown(content string, width int) string {
 	renderer := common.MarkdownRenderer(m.sty, width)
@@ -106,7 +188,7 @@ func (m *AssistantMessageItem) renderMarkdown(content string, width int) string
 	return strings.TrimSuffix(result, "\n")
 }
 
-// renderThinking renders the thinking/reasoning content.
+// renderThinking renders the thinking/reasoning content with footer.
 func (m *AssistantMessageItem) renderThinking(thinking string, width int) string {
 	renderer := common.PlainMarkdownRenderer(m.sty, width-2)
 	rendered, err := renderer.Render(thinking)
@@ -118,35 +200,51 @@ func (m *AssistantMessageItem) renderThinking(thinking string, width int) string
 	lines := strings.Split(rendered, "\n")
 	totalLines := len(lines)
 
-	// Collapse if not expanded and exceeds max height.
 	isTruncated := totalLines > maxCollapsedThinkingHeight
 	if !m.thinkingExpanded && isTruncated {
 		lines = lines[totalLines-maxCollapsedThinkingHeight:]
 	}
 
-	// Add hint if truncated and not expanded.
 	if !m.thinkingExpanded && isTruncated {
-		hint := m.sty.Muted.Render(fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight))
+		hint := m.sty.Chat.Message.ThinkingTruncationHint.Render(
+			fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
+		)
 		lines = append([]string{hint}, lines...)
 	}
 
-	thinkingStyle := m.sty.Subtle.Background(m.sty.BgBaseLighter).Width(width)
+	thinkingStyle := m.sty.Chat.Message.ThinkingBox.Width(width)
 	result := thinkingStyle.Render(strings.Join(lines, "\n"))
-
-	// Track the rendered height for click detection.
 	m.thinkingBoxHeight = lipgloss.Height(result)
 
+	var footer string
+	if m.thinkingStartedAt > 0 {
+		if m.thinkingFinishedAt > 0 {
+			duration := time.Duration(m.thinkingFinishedAt-m.thinkingStartedAt) * time.Second
+			if duration.String() != "0s" {
+				footer = m.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
+					m.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
+			}
+		} else if m.finish.Reason == message.FinishReasonCanceled {
+			footer = m.sty.Chat.Message.ThinkingFooterCancelled.Render("Canceled")
+		} else {
+			m.anim.SetLabel("Thinking")
+			footer = m.anim.View()
+		}
+	}
+
+	if footer != "" {
+		result += "\n\n" + footer
+	}
+
 	return result
 }
 
 // HandleMouseClick implements list.MouseClickable.
 func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
-	// Only handle left clicks.
 	if btn != ansi.MouseLeft {
 		return false
 	}
 
-	// Check if click is within the thinking box area.
 	if m.thinking != "" && y < m.thinkingBoxHeight {
 		m.thinkingExpanded = !m.thinkingExpanded
 		return true
@@ -157,13 +255,11 @@ func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int)
 
 // HandleKeyPress implements list.KeyPressable.
 func (m *AssistantMessageItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
-	// Only handle space key on thinking content.
 	if m.thinking == "" {
 		return false
 	}
 
 	if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
-		// Toggle thinking expansion.
 		m.thinkingExpanded = !m.thinkingExpanded
 		return true
 	}

internal/ui/chat/chat.go 🔗

@@ -4,6 +4,7 @@ package chat
 
 import (
 	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	uv "github.com/charmbracelet/ultraviolet"
@@ -24,6 +25,20 @@ type MessageItem interface {
 	Identifiable
 }
 
+// Animatable is implemented by items that support animation initialization.
+type Animatable interface {
+	InitAnimation() tea.Cmd
+}
+
+// ToolItem is implemented by tool items that support mutable updates.
+type ToolItem interface {
+	SetResult(result message.ToolResult)
+	SetCancelled()
+	UpdateCall(call message.ToolCall)
+	SetNestedCalls(calls []ToolCallContext)
+	Context() *ToolCallContext
+}
+
 // Chat represents the chat UI model that handles chat interactions and
 // messages.
 type Chat struct {
@@ -93,6 +108,31 @@ func (m *Chat) AppendItems(items ...list.Item) {
 	m.list.ScrollToIndex(m.list.Len() - 1)
 }
 
+// UpdateMessage updates an existing message by ID. Returns true if the message
+// was found and updated.
+func (m *Chat) UpdateMessage(id string, msg MessageItem) bool {
+	for i := 0; i < m.list.Len(); i++ {
+		item := m.list.GetItemAt(i)
+		if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id {
+			return m.list.UpdateItemAt(i, msg)
+		}
+	}
+	return false
+}
+
+// GetMessage returns the message with the given ID. Returns nil if not found.
+func (m *Chat) GetMessage(id string) MessageItem {
+	for i := 0; i < m.list.Len(); i++ {
+		item := m.list.GetItemAt(i)
+		if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id {
+			if msg, ok := item.(MessageItem); ok {
+				return msg
+			}
+		}
+	}
+	return nil
+}
+
 // Focus sets the focus state of the chat component.
 func (m *Chat) Focus() {
 	m.list.Focus()
@@ -182,3 +222,54 @@ func (m *Chat) HandleMouseDrag(x, y int) {
 func (m *Chat) HandleKeyPress(msg tea.KeyPressMsg) bool {
 	return m.list.HandleKeyPress(msg)
 }
+
+// UpdateItems propagates a message to all items that support updates (e.g.,
+// for animations). Returns commands from updated items.
+func (m *Chat) UpdateItems(msg tea.Msg) tea.Cmd {
+	return m.list.UpdateItems(msg)
+}
+
+// ToolItemUpdater is implemented by tool items that support mutable updates.
+type ToolItemUpdater interface {
+	SetResult(result message.ToolResult)
+	SetCancelled()
+	UpdateCall(call message.ToolCall)
+	SetNestedCalls(calls []ToolCallContext)
+	Context() *ToolCallContext
+}
+
+// GetToolItem returns the tool item with the given ID, or nil if not found.
+func (m *Chat) GetToolItem(id string) ToolItem {
+	for i := 0; i < m.list.Len(); i++ {
+		item := m.list.GetItemAt(i)
+		if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id {
+			if toolItem, ok := item.(ToolItem); ok {
+				return toolItem
+			}
+		}
+	}
+	return nil
+}
+
+// InvalidateItem invalidates the render cache for the item with the given ID.
+// Use after mutating an item via ToolItem methods.
+func (m *Chat) InvalidateItem(id string) {
+	for i := 0; i < m.list.Len(); i++ {
+		item := m.list.GetItemAt(i)
+		if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id {
+			m.list.InvalidateItemAt(i)
+			return
+		}
+	}
+}
+
+// DeleteMessage removes a message by ID. Returns true if found and deleted.
+func (m *Chat) DeleteMessage(id string) bool {
+	for i := m.list.Len() - 1; i >= 0; i-- {
+		item := m.list.GetItemAt(i)
+		if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id {
+			return m.list.DeleteItemAt(i)
+		}
+	}
+	return false
+}

internal/ui/chat/tool_base.go 🔗

@@ -13,6 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/common/anim"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/x/ansi"
 )
@@ -41,7 +42,6 @@ type ToolCallContext struct {
 	IsNested            bool
 	Styles              *styles.Styles
 
-	// NestedCalls holds child tool calls for agent/agentic_fetch.
 	NestedCalls []ToolCallContext
 }
 
@@ -56,7 +56,6 @@ func (ctx *ToolCallContext) Status() ToolStatus {
 		}
 		return ToolStatusSuccess
 	}
-	// No result yet - check permission state.
 	if ctx.PermissionRequested && !ctx.PermissionGranted {
 		return ToolStatusAwaitingPermission
 	}
@@ -69,7 +68,6 @@ func (ctx *ToolCallContext) HasResult() bool {
 }
 
 // toolStyles provides common FocusStylable and HighlightStylable implementations.
-// Embed this in tool items to avoid repeating style methods.
 type toolStyles struct {
 	sty *styles.Styles
 }
@@ -90,16 +88,41 @@ func (s toolStyles) HighlightStyle() lipgloss.Style {
 type toolItem struct {
 	toolStyles
 	id           string
-	expanded     bool // Whether truncated content is expanded.
-	wasTruncated bool // Whether the last render was truncated.
+	ctx          ToolCallContext
+	expanded     bool
+	wasTruncated bool
+	spinning     bool
+	anim         *anim.Anim
 }
 
 // newToolItem creates a new toolItem with the given context.
 func newToolItem(ctx ToolCallContext) toolItem {
-	return toolItem{
+	animSize := 15
+	if ctx.IsNested {
+		animSize = 10
+	}
+
+	t := toolItem{
 		toolStyles: toolStyles{sty: ctx.Styles},
 		id:         ctx.Call.ID,
+		ctx:        ctx,
+		spinning:   shouldSpin(ctx),
+		anim: anim.New(anim.Settings{
+			Size:        animSize,
+			Label:       "Working",
+			GradColorA:  ctx.Styles.Primary,
+			GradColorB:  ctx.Styles.Secondary,
+			LabelColor:  ctx.Styles.FgBase,
+			CycleColors: true,
+		}),
 	}
+
+	return t
+}
+
+// shouldSpin returns true if the tool should show animation.
+func shouldSpin(ctx ToolCallContext) bool {
+	return !ctx.Call.Finished && !ctx.Cancelled
 }
 
 // ID implements Identifiable.
@@ -109,25 +132,21 @@ func (t *toolItem) ID() string {
 
 // HandleMouseClick implements list.MouseClickable.
 func (t *toolItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
-	// Only handle left clicks on truncated content.
 	if btn != ansi.MouseLeft || !t.wasTruncated {
 		return false
 	}
 
-	// Toggle expansion.
 	t.expanded = !t.expanded
 	return true
 }
 
 // HandleKeyPress implements list.KeyPressable.
 func (t *toolItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
-	// Only handle space key on truncated content.
 	if !t.wasTruncated {
 		return false
 	}
 
 	if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
-		// Toggle expansion.
 		t.expanded = !t.expanded
 		return true
 	}
@@ -135,6 +154,78 @@ func (t *toolItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
 	return false
 }
 
+// updateAnimation handles animation updates and returns true if changed.
+func (t *toolItem) updateAnimation(msg tea.Msg) (tea.Cmd, bool) {
+	if !t.spinning || t.anim == nil {
+		return nil, false
+	}
+
+	switch msg.(type) {
+	case anim.StepMsg:
+		updatedAnim, cmd := t.anim.Update(msg)
+		t.anim = updatedAnim
+		return cmd, cmd != nil
+	}
+
+	return nil, false
+}
+
+// InitAnimation initializes and starts the animation.
+func (t *toolItem) InitAnimation() tea.Cmd {
+	t.spinning = shouldSpin(t.ctx)
+	return t.anim.Init()
+}
+
+// SetResult updates the tool call with a result.
+func (t *toolItem) SetResult(result message.ToolResult) {
+	t.ctx.Result = &result
+	t.ctx.Call.Finished = true
+	t.spinning = false
+}
+
+// SetCancelled marks the tool call as cancelled.
+func (t *toolItem) SetCancelled() {
+	t.ctx.Cancelled = true
+	t.spinning = false
+}
+
+// UpdateCall updates the tool call data.
+func (t *toolItem) UpdateCall(call message.ToolCall) {
+	t.ctx.Call = call
+	if call.Finished {
+		t.spinning = false
+	}
+}
+
+// SetNestedCalls sets the nested tool calls for agent tools.
+func (t *toolItem) SetNestedCalls(calls []ToolCallContext) {
+	t.ctx.NestedCalls = calls
+}
+
+// Context returns the current tool call context.
+func (t *toolItem) Context() *ToolCallContext {
+	return &t.ctx
+}
+
+// renderPending returns the pending state view with animation.
+func (t *toolItem) renderPending() string {
+	icon := t.sty.Tool.IconPending.Render()
+
+	var toolName string
+	if t.ctx.IsNested {
+		toolName = t.sty.Tool.NameNested.Render(prettifyToolName(t.ctx.Call.Name))
+	} else {
+		toolName = t.sty.Tool.NameNormal.Render(prettifyToolName(t.ctx.Call.Name))
+	}
+
+	var animView string
+	if t.anim != nil {
+		animView = t.anim.View()
+	}
+
+	return fmt.Sprintf("%s %s %s", icon, toolName, animView)
+}
+
 // unmarshalParams unmarshals JSON input into the target struct.
 func unmarshalParams(input string, target any) error {
 	return json.Unmarshal([]byte(input), target)

internal/ui/chat/tool_items.go 🔗

@@ -6,11 +6,13 @@ import (
 	"strings"
 	"time"
 
+	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"charm.land/lipgloss/v2/tree"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/ui/list"
 )
 
 // NewToolItem creates the appropriate tool item for the given context.
@@ -80,24 +82,34 @@ func NewToolItem(ctx ToolCallContext) MessageItem {
 // BashToolItem renders bash command execution.
 type BashToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewBashToolItem(ctx ToolCallContext) *BashToolItem {
 	return &BashToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
+// Update implements list.Updatable.
+func (m *BashToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
 func (m *BashToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.BashParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
 	cmd := strings.ReplaceAll(params.Command, "\n", " ")
 	cmd = strings.ReplaceAll(cmd, "\t", "    ")
 
-	// Check if this is a background job that finished
 	if m.ctx.Call.Finished && m.ctx.HasResult() {
 		var meta tools.BashResponseMetadata
 		unmarshalParams(m.ctx.Result.Metadata, &meta)
@@ -153,17 +165,19 @@ func (m *BashToolItem) renderBackgroundJob(params tools.BashParams, meta tools.B
 // JobOutputToolItem renders job output retrieval.
 type JobOutputToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewJobOutputToolItem(ctx ToolCallContext) *JobOutputToolItem {
 	return &JobOutputToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *JobOutputToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.JobOutputParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -191,17 +205,19 @@ func (m *JobOutputToolItem) Render(width int) string {
 // JobKillToolItem renders job termination.
 type JobKillToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewJobKillToolItem(ctx ToolCallContext) *JobKillToolItem {
 	return &JobKillToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *JobKillToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.JobKillParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -263,17 +279,19 @@ func renderJobHeader(ctx *ToolCallContext, action, pid, description string, widt
 // ViewToolItem renders file viewing with syntax highlighting.
 type ViewToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewViewToolItem(ctx ToolCallContext) *ViewToolItem {
 	return &ViewToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *ViewToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.ViewParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -290,7 +308,6 @@ func (m *ViewToolItem) Render(width int) string {
 		return result
 	}
 
-	// Handle image content
 	if m.ctx.Result.Data != "" && strings.HasPrefix(m.ctx.Result.MIMEType, "image/") {
 		body := renderImageContent(m.ctx.Result.Data, m.ctx.Result.MIMEType, "", m.ctx.Styles)
 		return joinHeaderBody(header, body, m.ctx.Styles)
@@ -306,17 +323,19 @@ func (m *ViewToolItem) Render(width int) string {
 // EditToolItem renders file editing with diff visualization.
 type EditToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewEditToolItem(ctx ToolCallContext) *EditToolItem {
 	return &EditToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *EditToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.EditParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -342,17 +361,19 @@ func (m *EditToolItem) Render(width int) string {
 // MultiEditToolItem renders multiple file edits with diff visualization.
 type MultiEditToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewMultiEditToolItem(ctx ToolCallContext) *MultiEditToolItem {
 	return &MultiEditToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *MultiEditToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.MultiEditParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -376,7 +397,6 @@ func (m *MultiEditToolItem) Render(width int) string {
 
 	body := renderDiffContent(file, meta.OldContent, meta.NewContent, width-2, m.ctx.Styles, &m.toolItem)
 
-	// Add failed edits warning if any exist
 	if len(meta.EditsFailed) > 0 {
 		sty := m.ctx.Styles
 		noteTag := sty.Tool.NoteTag.Render("Note")
@@ -391,17 +411,19 @@ func (m *MultiEditToolItem) Render(width int) string {
 // WriteToolItem renders file writing with syntax-highlighted content preview.
 type WriteToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewWriteToolItem(ctx ToolCallContext) *WriteToolItem {
 	return &WriteToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *WriteToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.WriteParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -425,17 +447,19 @@ func (m *WriteToolItem) Render(width int) string {
 // GlobToolItem renders glob file pattern matching results.
 type GlobToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewGlobToolItem(ctx ToolCallContext) *GlobToolItem {
 	return &GlobToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *GlobToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.GlobParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -457,17 +481,19 @@ func (m *GlobToolItem) Render(width int) string {
 // GrepToolItem renders grep content search results.
 type GrepToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewGrepToolItem(ctx ToolCallContext) *GrepToolItem {
 	return &GrepToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *GrepToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.GrepParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -491,17 +517,19 @@ func (m *GrepToolItem) Render(width int) string {
 // LSToolItem renders directory listing results.
 type LSToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewLSToolItem(ctx ToolCallContext) *LSToolItem {
 	return &LSToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *LSToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.LSParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -522,17 +550,19 @@ func (m *LSToolItem) Render(width int) string {
 // SourcegraphToolItem renders code search results.
 type SourcegraphToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewSourcegraphToolItem(ctx ToolCallContext) *SourcegraphToolItem {
 	return &SourcegraphToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *SourcegraphToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.SourcegraphParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -559,17 +589,19 @@ func (m *SourcegraphToolItem) Render(width int) string {
 // FetchToolItem renders URL fetching with format-specific content display.
 type FetchToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewFetchToolItem(ctx ToolCallContext) *FetchToolItem {
 	return &FetchToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *FetchToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.FetchParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -585,7 +617,6 @@ func (m *FetchToolItem) Render(width int) string {
 		return result
 	}
 
-	// Use appropriate extension for syntax highlighting
 	file := "fetch.md"
 	switch params.Format {
 	case "text":
@@ -601,17 +632,19 @@ func (m *FetchToolItem) Render(width int) string {
 // AgenticFetchToolItem renders agentic URL fetching with nested tool calls.
 type AgenticFetchToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewAgenticFetchToolItem(ctx ToolCallContext) *AgenticFetchToolItem {
 	return &AgenticFetchToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *AgenticFetchToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.AgenticFetchParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -630,17 +663,19 @@ func (m *AgenticFetchToolItem) Render(width int) string {
 // WebFetchToolItem renders web page fetching.
 type WebFetchToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewWebFetchToolItem(ctx ToolCallContext) *WebFetchToolItem {
 	return &WebFetchToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *WebFetchToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.WebFetchParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -658,17 +693,19 @@ func (m *WebFetchToolItem) Render(width int) string {
 // WebSearchToolItem renders web search results.
 type WebSearchToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewWebSearchToolItem(ctx ToolCallContext) *WebSearchToolItem {
 	return &WebSearchToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *WebSearchToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.WebSearchParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -686,17 +723,19 @@ func (m *WebSearchToolItem) Render(width int) string {
 // DownloadToolItem renders file downloading.
 type DownloadToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewDownloadToolItem(ctx ToolCallContext) *DownloadToolItem {
 	return &DownloadToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *DownloadToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.DownloadParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -723,17 +762,19 @@ func (m *DownloadToolItem) Render(width int) string {
 // DiagnosticsToolItem renders project-wide diagnostic information.
 type DiagnosticsToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewDiagnosticsToolItem(ctx ToolCallContext) *DiagnosticsToolItem {
 	return &DiagnosticsToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *DiagnosticsToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	args := NewParamBuilder().Main("project").Build()
 	header := renderToolHeader(&m.ctx, "Diagnostics", width, args...)
 
@@ -748,17 +789,19 @@ func (m *DiagnosticsToolItem) Render(width int) string {
 // ReferencesToolItem renders LSP references search results.
 type ReferencesToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewReferencesToolItem(ctx ToolCallContext) *ReferencesToolItem {
 	return &ReferencesToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *ReferencesToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params tools.ReferencesParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -784,17 +827,19 @@ func (m *ReferencesToolItem) Render(width int) string {
 // TodosToolItem renders todo list management.
 type TodosToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewTodosToolItem(ctx ToolCallContext) *TodosToolItem {
 	return &TodosToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *TodosToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	sty := m.ctx.Styles
 	var params tools.TodosParams
 	var meta tools.TodosResponseMetadata
@@ -887,17 +932,19 @@ func (m *TodosToolItem) formatTodosFromMeta(meta tools.TodosResponseMetadata, wi
 // AgentToolItem renders agent task execution with nested tool calls.
 type AgentToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewAgentToolItem(ctx ToolCallContext) *AgentToolItem {
 	return &AgentToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *AgentToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	var params agent.AgentParams
 	unmarshalParams(m.ctx.Call.Input, &params)
 
@@ -942,7 +989,6 @@ func renderAgentBody(ctx *ToolCallContext, prompt, tagLabel, header string, widt
 		childTools.Enumerator(roundedEnumerator(2, tagWidth-5)).String(),
 	}
 
-	// Add pending indicator if not complete
 	if !ctx.HasResult() {
 		parts = append(parts, "", sty.Tool.StateWaiting.Render("Working..."))
 	}
@@ -978,34 +1024,36 @@ func roundedEnumerator(lPadding, lineWidth int) tree.Enumerator {
 // GenericToolItem renders unknown tool types with basic parameter display.
 type GenericToolItem struct {
 	toolItem
-	ctx ToolCallContext
 }
 
 func NewGenericToolItem(ctx ToolCallContext) *GenericToolItem {
 	return &GenericToolItem{
 		toolItem: newToolItem(ctx),
-		ctx:      ctx,
 	}
 }
 
 func (m *GenericToolItem) Render(width int) string {
+	if !m.ctx.Call.Finished && !m.ctx.Cancelled {
+		return m.renderPending()
+	}
+
 	name := prettifyToolName(m.ctx.Call.Name)
 
 	// Handle media content
 	if m.ctx.Result != nil && m.ctx.Result.Data != "" {
 		if strings.HasPrefix(m.ctx.Result.MIMEType, "image/") {
-			args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+			args := NewParamBuilder().Main(m.toolItem.ctx.Call.Input).Build()
 			header := renderToolHeader(&m.ctx, name, width, args...)
 			body := renderImageContent(m.ctx.Result.Data, m.ctx.Result.MIMEType, m.ctx.Result.Content, m.ctx.Styles)
 			return joinHeaderBody(header, body, m.ctx.Styles)
 		}
-		args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+		args := NewParamBuilder().Main(m.toolItem.ctx.Call.Input).Build()
 		header := renderToolHeader(&m.ctx, name, width, args...)
 		body := renderMediaContent(m.ctx.Result.MIMEType, m.ctx.Result.Content, m.ctx.Styles)
 		return joinHeaderBody(header, body, m.ctx.Styles)
 	}
 
-	args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+	args := NewParamBuilder().Main(m.toolItem.ctx.Call.Input).Build()
 	header := renderToolHeader(&m.ctx, name, width, args...)
 
 	if result, done := renderEarlyState(&m.ctx, header, width); done {
@@ -1098,3 +1146,183 @@ func truncateText(s string, width int) string {
 	}
 	return "…"
 }
+
+// Update implements list.Updatable.
+func (m *JobOutputToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *JobKillToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *ViewToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *EditToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *MultiEditToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *WriteToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *GlobToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *GrepToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *LSToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *SourcegraphToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *FetchToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *AgenticFetchToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *WebFetchToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *WebSearchToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *DownloadToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *DiagnosticsToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *ReferencesToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *TodosToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *AgentToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}
+
+// Update implements list.Updatable.
+func (m *GenericToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) {
+	cmd, changed := m.updateAnimation(msg)
+	if changed {
+		return m, cmd
+	}
+	return m, nil
+}

internal/ui/common/anim/anim.go 🔗

@@ -0,0 +1,446 @@
+// Package anim provides an animated spinner.
+package anim
+
+import (
+	"fmt"
+	"image/color"
+	"math/rand/v2"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"github.com/zeebo/xxh3"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/lucasb-eyer/go-colorful"
+
+	"github.com/charmbracelet/crush/internal/csync"
+)
+
+const (
+	fps           = 20
+	initialChar   = '.'
+	labelGap      = " "
+	labelGapWidth = 1
+
+	// Periods of ellipsis animation speed in steps.
+	//
+	// If the FPS is 20 (50 milliseconds) this means that the ellipsis will
+	// change every 8 frames (400 milliseconds).
+	ellipsisAnimSpeed = 8
+
+	// The maximum amount of time that can pass before a character appears.
+	// This is used to create a staggered entrance effect.
+	maxBirthOffset = time.Second
+
+	// Number of frames to prerender for the animation. After this number
+	// of frames, the animation will loop. This only applies when color
+	// cycling is disabled.
+	prerenderedFrames = 10
+
+	// Default number of cycling chars.
+	defaultNumCyclingChars = 10
+)
+
+// Default colors for gradient.
+var (
+	defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
+	defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
+	defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
+)
+
+var (
+	availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
+	ellipsisFrames = []string{".", "..", "...", ""}
+)
+
+// Internal ID management. Used during animating to ensure that frame messages
+// are received only by spinner components that sent them.
+var lastID int64
+
+func nextID() int {
+	return int(atomic.AddInt64(&lastID, 1))
+}
+
+// Cache for expensive animation calculations
+type animCache struct {
+	initialFrames  [][]string
+	cyclingFrames  [][]string
+	width          int
+	labelWidth     int
+	label          []string
+	ellipsisFrames []string
+}
+
+var animCacheMap = csync.NewMap[string, *animCache]()
+
+// settingsHash creates a hash key for the settings to use for caching
+func settingsHash(opts Settings) string {
+	h := xxh3.New()
+	fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
+		opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
+	return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+// StepMsg is a message type used to trigger the next step in the animation.
+type StepMsg struct{ id int }
+
+// Settings defines settings for the animation.
+type Settings struct {
+	Size        int
+	Label       string
+	LabelColor  color.Color
+	GradColorA  color.Color
+	GradColorB  color.Color
+	CycleColors bool
+}
+
+// Default settings.
+const ()
+
+// Anim is a Bubble for an animated spinner.
+type Anim struct {
+	width            int
+	cyclingCharWidth int
+	label            *csync.Slice[string]
+	labelWidth       int
+	labelColor       color.Color
+	startTime        time.Time
+	birthOffsets     []time.Duration
+	initialFrames    [][]string // frames for the initial characters
+	initialized      atomic.Bool
+	cyclingFrames    [][]string           // frames for the cycling characters
+	step             atomic.Int64         // current main frame step
+	ellipsisStep     atomic.Int64         // current ellipsis frame step
+	ellipsisFrames   *csync.Slice[string] // ellipsis animation frames
+	id               int
+}
+
+// New creates a new Anim instance with the specified width and label.
+func New(opts Settings) *Anim {
+	a := &Anim{}
+	// Validate settings.
+	if opts.Size < 1 {
+		opts.Size = defaultNumCyclingChars
+	}
+	if colorIsUnset(opts.GradColorA) {
+		opts.GradColorA = defaultGradColorA
+	}
+	if colorIsUnset(opts.GradColorB) {
+		opts.GradColorB = defaultGradColorB
+	}
+	if colorIsUnset(opts.LabelColor) {
+		opts.LabelColor = defaultLabelColor
+	}
+
+	a.id = nextID()
+	a.startTime = time.Now()
+	a.cyclingCharWidth = opts.Size
+	a.labelColor = opts.LabelColor
+
+	// Check cache first
+	cacheKey := settingsHash(opts)
+	cached, exists := animCacheMap.Get(cacheKey)
+
+	if exists {
+		// Use cached values
+		a.width = cached.width
+		a.labelWidth = cached.labelWidth
+		a.label = csync.NewSliceFrom(cached.label)
+		a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
+		a.initialFrames = cached.initialFrames
+		a.cyclingFrames = cached.cyclingFrames
+	} else {
+		// Generate new values and cache them
+		a.labelWidth = lipgloss.Width(opts.Label)
+
+		// Total width of anim, in cells.
+		a.width = opts.Size
+		if opts.Label != "" {
+			a.width += labelGapWidth + lipgloss.Width(opts.Label)
+		}
+
+		// Render the label
+		a.renderLabel(opts.Label)
+
+		// Pre-generate gradient.
+		var ramp []color.Color
+		numFrames := prerenderedFrames
+		if opts.CycleColors {
+			ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
+			numFrames = a.width * 2
+		} else {
+			ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
+		}
+
+		// Pre-render initial characters.
+		a.initialFrames = make([][]string, numFrames)
+		offset := 0
+		for i := range a.initialFrames {
+			a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
+			for j := range a.initialFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				var c color.Color
+				if j <= a.cyclingCharWidth {
+					c = ramp[j+offset]
+				} else {
+					c = opts.LabelColor
+				}
+
+				// Also prerender the initial character with Lip Gloss to avoid
+				// processing in the render loop.
+				a.initialFrames[i][j] = lipgloss.NewStyle().
+					Foreground(c).
+					Render(string(initialChar))
+			}
+			if opts.CycleColors {
+				offset++
+			}
+		}
+
+		// Prerender scrambled rune frames for the animation.
+		a.cyclingFrames = make([][]string, numFrames)
+		offset = 0
+		for i := range a.cyclingFrames {
+			a.cyclingFrames[i] = make([]string, a.width)
+			for j := range a.cyclingFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				// Also prerender the color with Lip Gloss here to avoid processing
+				// in the render loop.
+				r := availableRunes[rand.IntN(len(availableRunes))]
+				a.cyclingFrames[i][j] = lipgloss.NewStyle().
+					Foreground(ramp[j+offset]).
+					Render(string(r))
+			}
+			if opts.CycleColors {
+				offset++
+			}
+		}
+
+		// Cache the results
+		labelSlice := make([]string, a.label.Len())
+		for i, v := range a.label.Seq2() {
+			labelSlice[i] = v
+		}
+		ellipsisSlice := make([]string, a.ellipsisFrames.Len())
+		for i, v := range a.ellipsisFrames.Seq2() {
+			ellipsisSlice[i] = v
+		}
+		cached = &animCache{
+			initialFrames:  a.initialFrames,
+			cyclingFrames:  a.cyclingFrames,
+			width:          a.width,
+			labelWidth:     a.labelWidth,
+			label:          labelSlice,
+			ellipsisFrames: ellipsisSlice,
+		}
+		animCacheMap.Set(cacheKey, cached)
+	}
+
+	// Random assign a birth to each character for a stagged entrance effect.
+	a.birthOffsets = make([]time.Duration, a.width)
+	for i := range a.birthOffsets {
+		a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
+	}
+
+	return a
+}
+
+// SetLabel updates the label text and re-renders it.
+func (a *Anim) SetLabel(newLabel string) {
+	a.labelWidth = lipgloss.Width(newLabel)
+
+	// Update total width
+	a.width = a.cyclingCharWidth
+	if newLabel != "" {
+		a.width += labelGapWidth + a.labelWidth
+	}
+
+	// Re-render the label
+	a.renderLabel(newLabel)
+}
+
+// renderLabel renders the label with the current label color.
+func (a *Anim) renderLabel(label string) {
+	if a.labelWidth > 0 {
+		// Pre-render the label.
+		labelRunes := []rune(label)
+		a.label = csync.NewSlice[string]()
+		for i := range labelRunes {
+			rendered := lipgloss.NewStyle().
+				Foreground(a.labelColor).
+				Render(string(labelRunes[i]))
+			a.label.Append(rendered)
+		}
+
+		// Pre-render the ellipsis frames which come after the label.
+		a.ellipsisFrames = csync.NewSlice[string]()
+		for _, frame := range ellipsisFrames {
+			rendered := lipgloss.NewStyle().
+				Foreground(a.labelColor).
+				Render(frame)
+			a.ellipsisFrames.Append(rendered)
+		}
+	} else {
+		a.label = csync.NewSlice[string]()
+		a.ellipsisFrames = csync.NewSlice[string]()
+	}
+}
+
+// Width returns the total width of the animation.
+func (a *Anim) Width() (w int) {
+	w = a.width
+	if a.labelWidth > 0 {
+		w += labelGapWidth + a.labelWidth
+
+		var widestEllipsisFrame int
+		for _, f := range ellipsisFrames {
+			fw := lipgloss.Width(f)
+			if fw > widestEllipsisFrame {
+				widestEllipsisFrame = fw
+			}
+		}
+		w += widestEllipsisFrame
+	}
+	return w
+}
+
+// Init starts the animation.
+func (a *Anim) Init() tea.Cmd {
+	return a.Step()
+}
+
+// Update processes animation steps (or not).
+func (a *Anim) Update(msg tea.Msg) (*Anim, tea.Cmd) {
+	switch msg := msg.(type) {
+	case StepMsg:
+		if msg.id != a.id {
+			// Reject messages that are not for this instance.
+			return a, nil
+		}
+
+		step := a.step.Add(1)
+		if int(step) >= len(a.cyclingFrames) {
+			a.step.Store(0)
+		}
+
+		if a.initialized.Load() && a.labelWidth > 0 {
+			// Manage the ellipsis animation.
+			ellipsisStep := a.ellipsisStep.Add(1)
+			if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
+				a.ellipsisStep.Store(0)
+			}
+		} else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
+			a.initialized.Store(true)
+		}
+		return a, a.Step()
+	default:
+		return a, nil
+	}
+}
+
+// View renders the current state of the animation.
+func (a *Anim) View() string {
+	var b strings.Builder
+	step := int(a.step.Load())
+	for i := range a.width {
+		switch {
+		case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
+			// Birth offset not reached: render initial character.
+			b.WriteString(a.initialFrames[step][i])
+		case i < a.cyclingCharWidth:
+			// Render a cycling character.
+			b.WriteString(a.cyclingFrames[step][i])
+		case i == a.cyclingCharWidth:
+			// Render label gap.
+			b.WriteString(labelGap)
+		case i > a.cyclingCharWidth:
+			// Label.
+			if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
+				b.WriteString(labelChar)
+			}
+		}
+	}
+	// Render animated ellipsis at the end of the label if all characters
+	// have been initialized.
+	if a.initialized.Load() && a.labelWidth > 0 {
+		ellipsisStep := int(a.ellipsisStep.Load())
+		if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
+			b.WriteString(ellipsisFrame)
+		}
+	}
+
+	return b.String()
+}
+
+// Step is a command that triggers the next step in the animation.
+func (a *Anim) Step() tea.Cmd {
+	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
+		return StepMsg{id: a.id}
+	})
+}
+
+// makeGradientRamp() returns a slice of colors blended between the given keys.
+// Blending is done as Hcl to stay in gamut.
+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
+	if len(stops) < 2 {
+		return nil
+	}
+
+	points := make([]colorful.Color, len(stops))
+	for i, k := range stops {
+		points[i], _ = colorful.MakeColor(k)
+	}
+
+	numSegments := len(stops) - 1
+	if numSegments == 0 {
+		return nil
+	}
+	blended := make([]color.Color, 0, size)
+
+	// Calculate how many colors each segment should have.
+	segmentSizes := make([]int, numSegments)
+	baseSize := size / numSegments
+	remainder := size % numSegments
+
+	// Distribute the remainder across segments.
+	for i := range numSegments {
+		segmentSizes[i] = baseSize
+		if i < remainder {
+			segmentSizes[i]++
+		}
+	}
+
+	// Generate colors for each segment.
+	for i := range numSegments {
+		c1 := points[i]
+		c2 := points[i+1]
+		segmentSize := segmentSizes[i]
+
+		for j := range segmentSize {
+			if segmentSize == 0 {
+				continue
+			}
+			t := float64(j) / float64(segmentSize)
+			c := c1.BlendHcl(c2, t)
+			blended = append(blended, c)
+		}
+	}
+
+	return blended
+}
+
+func colorIsUnset(c color.Color) bool {
+	if c == nil {
+		return true
+	}
+	_, _, _, a := c.RGBA()
+	return a == 0
+}

internal/ui/list/item.go 🔗

@@ -13,6 +13,14 @@ type Item interface {
 	Render(width int) string
 }
 
+// Updatable represents an item that can handle tea.Msg updates (e.g., for
+// animations or interactive state changes).
+type Updatable interface {
+	// Update processes a message and returns an updated item and optional
+	// command. The returned Item should be the same type as the receiver.
+	Update(tea.Msg) (Item, tea.Cmd)
+}
+
 // FocusStylable represents an item that can be styled based on focus state.
 type FocusStylable interface {
 	// FocusStyle returns the style to apply when the item is focused.

internal/ui/list/list.go 🔗

@@ -417,6 +417,64 @@ func (l *List) AppendItems(items ...Item) {
 	l.items = append(l.items, items...)
 }
 
+// UpdateItemAt updates the item at the given index and invalidates its cache.
+// Returns true if the index was valid and the item was updated.
+func (l *List) UpdateItemAt(idx int, item Item) bool {
+	if idx < 0 || idx >= len(l.items) {
+		return false
+	}
+	l.items[idx] = item
+	l.invalidateItem(idx)
+	return true
+}
+
+// GetItemAt returns the item at the given index. Returns nil if the index is
+// out of bounds.
+func (l *List) GetItemAt(idx int) Item {
+	if idx < 0 || idx >= len(l.items) {
+		return nil
+	}
+	return l.items[idx]
+}
+
+// InvalidateItemAt invalidates the render cache for the item at the given
+// index without replacing the item. Use this when you've mutated an item's
+// internal state and need to force a re-render.
+func (l *List) InvalidateItemAt(idx int) {
+	if idx >= 0 && idx < len(l.items) {
+		l.invalidateItem(idx)
+	}
+}
+
+// DeleteItemAt removes the item at the given index. Returns true if the index
+// was valid and the item was removed.
+func (l *List) DeleteItemAt(idx int) bool {
+	if idx < 0 || idx >= len(l.items) {
+		return false
+	}
+
+	// Remove from items slice.
+	l.items = append(l.items[:idx], l.items[idx+1:]...)
+
+	// Clear and rebuild cache with shifted indices.
+	newCache := make(map[int]renderedItem, len(l.renderedItems))
+	for i, val := range l.renderedItems {
+		if i < idx {
+			newCache[i] = val
+		} else if i > idx {
+			newCache[i-1] = val
+		}
+	}
+	l.renderedItems = newCache
+
+	// Adjust selection if needed.
+	if l.selectedIdx >= len(l.items) && len(l.items) > 0 {
+		l.selectedIdx = len(l.items) - 1
+	}
+
+	return true
+}
+
 // Focus sets the focus state of the list.
 func (l *List) Focus() {
 	l.focused = true
@@ -700,6 +758,31 @@ func (l *List) HandleKeyPress(msg tea.KeyPressMsg) bool {
 	return false
 }
 
+// UpdateItems propagates a message to all items that implement Updatable.
+// This is typically used for animation messages like anim.StepMsg.
+// Returns commands from updated items.
+func (l *List) UpdateItems(msg tea.Msg) tea.Cmd {
+	var cmds []tea.Cmd
+	for i, item := range l.items {
+		if updatable, ok := item.(Updatable); ok {
+			updated, cmd := updatable.Update(msg)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+				// Invalidate cache when animation updates, even if pointer is same.
+				l.invalidateItem(i)
+			}
+			if updated != item {
+				l.items[i] = updated
+				l.invalidateItem(i)
+			}
+		}
+	}
+	if len(cmds) == 0 {
+		return nil
+	}
+	return tea.Batch(cmds...)
+}
+
 // findItemAtY finds the item at the given viewport y coordinate.
 // Returns the item index and the y offset within that item. It returns -1, -1
 // if no item is found.

internal/ui/model/ui.go 🔗

@@ -25,6 +25,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/common/anim"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -122,6 +123,10 @@ type UI struct {
 
 	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 	sidebarLogo string
+
+	// lastUserMessageTime tracks the timestamp of the last user message for
+	// calculating response duration.
+	lastUserMessageTime int64
 }
 
 // New creates a new instance of the [UI] model.
@@ -227,6 +232,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.lspStates = app.GetLSPStates()
 	case pubsub.Event[mcp.Event]:
 		m.mcpStates = mcp.GetStates()
+	case pubsub.Event[message.Message]:
+		cmds = append(cmds, m.handleMessageEvent(msg))
+	case anim.StepMsg:
+		// Forward animation updates to chat items.
+		cmds = append(cmds, m.chat.UpdateItems(msg))
 	case tea.TerminalVersionMsg:
 		termVersion := strings.ToLower(msg.Name)
 		// Only enable progress bar for the following terminals.
@@ -708,6 +718,12 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 		case uiFocusMain:
 		case uiFocusEditor:
 			switch {
+			case key.Matches(msg, m.keyMap.Editor.SendMessage):
+				text := strings.TrimSpace(m.textarea.Value())
+				if text != "" {
+					cmds = append(cmds, m.sendMessage(text, nil))
+				}
+				return cmds
 			case key.Matches(msg, m.keyMap.Editor.Newline):
 				m.textarea.InsertRune('\n')
 			}
@@ -1039,8 +1055,12 @@ func (m *UI) convertAssistantMessage(msg message.Message, toolResultMap map[stri
 	isError := msg.FinishReason() == message.FinishReasonError
 	isCancelled := msg.FinishReason() == message.FinishReasonCanceled
 
-	// Show assistant message if there's content, thinking, or status to display.
-	if content != "" || thinking != "" || isError || isCancelled {
+	hasToolCalls := len(msg.ToolCalls()) > 0
+	reasoning := msg.ReasoningContent()
+
+	// Show when: no tool calls yet, OR has content, OR has thinking, OR is in thinking state.
+	shouldShow := !hasToolCalls || content != "" || thinking != "" || msg.IsThinking()
+	if shouldShow || isError || isCancelled {
 		var finish message.Finish
 		if fp := msg.FinishPart(); fp != nil {
 			finish = *fp
@@ -1052,6 +1072,10 @@ func (m *UI) convertAssistantMessage(msg message.Message, toolResultMap map[stri
 			thinking,
 			msg.IsFinished(),
 			finish,
+			hasToolCalls,
+			msg.IsSummaryMessage,
+			reasoning.StartedAt,
+			reasoning.FinishedAt,
 			m.com.Styles,
 		))
 	}
@@ -1149,3 +1173,247 @@ func renderLogo(t *styles.Styles, compact bool, width int) string {
 		Width:        width,
 	})
 }
+
+// -----------------------------------------------------------------------------
+// Message Event Handling
+// -----------------------------------------------------------------------------
+
+// handleMessageEvent processes pubsub message events (created/updated/deleted).
+func (m *UI) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
+	// Ignore events for other sessions.
+	if m.session == nil || event.Payload.SessionID != m.session.ID {
+		return m.handleChildSessionEvent(event)
+	}
+
+	switch event.Type {
+	case pubsub.CreatedEvent:
+		if m.chat.GetMessage(event.Payload.ID) != nil {
+			return nil // Already exists.
+		}
+		return m.handleNewMessage(event.Payload)
+	case pubsub.UpdatedEvent:
+		return m.handleUpdateMessage(event.Payload)
+	case pubsub.DeletedEvent:
+		m.chat.DeleteMessage(event.Payload.ID)
+	}
+	return nil
+}
+
+// handleChildSessionEvent handles messages from child sessions (agent tools).
+func (m *UI) handleChildSessionEvent(event pubsub.Event[message.Message]) tea.Cmd {
+	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
+		return nil
+	}
+
+	// Check if this is an agent tool session.
+	childSessionID := event.Payload.SessionID
+	parentMessageID, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
+	if !ok {
+		return nil
+	}
+
+	// Find and update the parent tool call with new nested calls.
+	if tool := m.chat.GetToolItem(toolCallID); tool != nil {
+		tool.SetNestedCalls(m.loadNestedToolCalls(parentMessageID, toolCallID))
+		m.chat.InvalidateItem(toolCallID)
+	}
+	return nil
+}
+
+// handleNewMessage routes new messages to appropriate handlers based on role.
+func (m *UI) handleNewMessage(msg message.Message) tea.Cmd {
+	switch msg.Role {
+	case message.User:
+		return m.handleNewUserMessage(msg)
+	case message.Assistant:
+		return m.handleNewAssistantMessage(msg)
+	case message.Tool:
+		return m.handleToolResultMessage(msg)
+	}
+	return nil
+}
+
+// handleNewUserMessage adds a new user message to the chat.
+func (m *UI) handleNewUserMessage(msg message.Message) tea.Cmd {
+	m.lastUserMessageTime = msg.CreatedAt
+	userItem := chat.NewUserMessage(msg.ID, msg.Content().Text, msg.BinaryContent(), m.com.Styles)
+	m.chat.AppendMessages(userItem)
+	m.chat.ScrollToBottom()
+	return nil
+}
+
+// handleNewAssistantMessage adds a new assistant message and its tool calls.
+func (m *UI) handleNewAssistantMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+
+	content := strings.TrimSpace(msg.Content().Text)
+	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+	hasToolCalls := len(msg.ToolCalls()) > 0
+
+	// Add assistant message if it has content to display.
+	if m.shouldShowAssistantMessage(msg) {
+		assistantItem := m.createAssistantItem(msg, content, thinking, hasToolCalls)
+		m.chat.AppendMessages(assistantItem)
+		cmds = append(cmds, assistantItem.InitAnimation())
+	}
+
+	// Add tool call items.
+	for _, tc := range msg.ToolCalls() {
+		ctx := m.buildToolCallContext(tc, msg, nil)
+		if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
+			ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
+		}
+		toolItem := chat.NewToolItem(ctx)
+		m.chat.AppendMessages(toolItem)
+		if animatable, ok := toolItem.(chat.Animatable); ok {
+			cmds = append(cmds, animatable.InitAnimation())
+		}
+	}
+
+	m.chat.ScrollToBottom()
+	return tea.Batch(cmds...)
+}
+
+// handleUpdateMessage routes update messages based on role.
+func (m *UI) handleUpdateMessage(msg message.Message) tea.Cmd {
+	switch msg.Role {
+	case message.Assistant:
+		return m.handleUpdateAssistantMessage(msg)
+	case message.Tool:
+		return m.handleToolResultMessage(msg)
+	}
+	return nil
+}
+
+// handleUpdateAssistantMessage updates existing assistant message and tool calls.
+func (m *UI) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+
+	content := strings.TrimSpace(msg.Content().Text)
+	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+	hasToolCalls := len(msg.ToolCalls()) > 0
+	shouldShow := m.shouldShowAssistantMessage(msg)
+
+	// Update or create/delete assistant message item.
+	existingItem := m.chat.GetMessage(msg.ID)
+	if existingItem != nil {
+		if shouldShow {
+			// Update existing message.
+			if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
+				assistantItem.SetContent(content, thinking, msg.IsFinished(), msg.FinishPart(), hasToolCalls, msg.IsSummaryMessage, msg.ReasoningContent())
+				m.chat.InvalidateItem(msg.ID)
+			}
+
+			// Add section separator when assistant finishes with EndTurn.
+			if msg.FinishReason() == message.FinishReasonEndTurn {
+				modelName := m.getModelName(msg)
+				sectionItem := chat.NewSectionItem(msg, time.Unix(m.lastUserMessageTime, 0), modelName, m.com.Styles)
+				m.chat.AppendMessages(sectionItem)
+			}
+		} else if hasToolCalls && content == "" && thinking == "" {
+			// Remove if it's now just tool calls with no content.
+			m.chat.DeleteMessage(msg.ID)
+		}
+	}
+
+	// Update existing or add new tool calls.
+	for _, tc := range msg.ToolCalls() {
+		if tool := m.chat.GetToolItem(tc.ID); tool != nil {
+			// Update existing tool call.
+			tool.UpdateCall(tc)
+			if msg.FinishReason() == message.FinishReasonCanceled {
+				tool.SetCancelled()
+			}
+			if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
+				tool.SetNestedCalls(m.loadNestedToolCalls(msg.ID, tc.ID))
+			}
+			m.chat.InvalidateItem(tc.ID)
+		} else {
+			// Add new tool call.
+			ctx := m.buildToolCallContext(tc, msg, nil)
+			if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
+				ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
+			}
+			toolItem := chat.NewToolItem(ctx)
+			m.chat.AppendMessages(toolItem)
+			if animatable, ok := toolItem.(chat.Animatable); ok {
+				cmds = append(cmds, animatable.InitAnimation())
+			}
+		}
+	}
+
+	return tea.Batch(cmds...)
+}
+
+// handleToolResultMessage updates tool calls with their results.
+func (m *UI) handleToolResultMessage(msg message.Message) tea.Cmd {
+	for _, tr := range msg.ToolResults() {
+		if tool := m.chat.GetToolItem(tr.ToolCallID); tool != nil {
+			tool.SetResult(tr)
+			m.chat.InvalidateItem(tr.ToolCallID)
+		}
+	}
+	return nil
+}
+
+// shouldShowAssistantMessage returns true if the message should be displayed.
+func (m *UI) shouldShowAssistantMessage(msg message.Message) bool {
+	hasToolCalls := len(msg.ToolCalls()) > 0
+	content := strings.TrimSpace(msg.Content().Text)
+	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+	return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking()
+}
+
+// createAssistantItem creates a new assistant message item.
+func (m *UI) createAssistantItem(msg message.Message, content, thinking string, hasToolCalls bool) *chat.AssistantMessageItem {
+	var finish message.Finish
+	if fp := msg.FinishPart(); fp != nil {
+		finish = *fp
+	}
+	reasoning := msg.ReasoningContent()
+
+	return chat.NewAssistantMessage(
+		msg.ID,
+		content,
+		thinking,
+		msg.IsFinished(),
+		finish,
+		hasToolCalls,
+		msg.IsSummaryMessage,
+		reasoning.StartedAt,
+		reasoning.FinishedAt,
+		m.com.Styles,
+	)
+}
+
+// sendMessage sends a user message to the agent coordinator.
+func (m *UI) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
+	sess := m.session
+
+	// Create a new session if we don't have one.
+	if sess == nil || sess.ID == "" {
+		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
+		if err != nil {
+			return nil
+		}
+		sess = &newSession
+		m.session = sess
+		m.state = uiChat
+	}
+
+	// Check if the agent coordinator is available.
+	if m.com.App.AgentCoordinator == nil {
+		return nil
+	}
+
+	// Clear the textarea.
+	m.textarea.Reset()
+	m.randomizePlaceholders()
+
+	// Run the agent in a goroutine.
+	sessionID := sess.ID
+	return func() tea.Msg {
+		_, _ = m.com.App.AgentCoordinator.Run(context.Background(), sessionID, text, attachments...)
+		return nil
+	}
+}

internal/ui/styles/styles.go 🔗

@@ -207,13 +207,19 @@ type Styles struct {
 			Attachment       lipgloss.Style
 			ToolCallFocused  lipgloss.Style
 			ToolCallBlurred  lipgloss.Style
-			ThinkingFooter   lipgloss.Style
 			SectionHeader    lipgloss.Style
 
 			// Section styles - for assistant response metadata
 			SectionIcon     lipgloss.Style // Model icon
 			SectionModel    lipgloss.Style // Model name
 			SectionDuration lipgloss.Style // Response duration
+
+			// Thinking section styles
+			ThinkingBox             lipgloss.Style // Background for thinking content
+			ThinkingTruncationHint  lipgloss.Style // "… (N lines hidden)" hint
+			ThinkingFooterTitle     lipgloss.Style // "Thought for" text
+			ThinkingFooterDuration  lipgloss.Style // Duration value
+			ThinkingFooterCancelled lipgloss.Style // "*Canceled*" text
 		}
 	}
 
@@ -1066,9 +1072,15 @@ func DefaultStyles() Styles {
 		BorderLeft(true).
 		BorderForeground(greenDark)
 	s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
-	s.Chat.Message.ThinkingFooter = s.Base
 	s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
 
+	// Thinking section styles
+	s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter)
+	s.Chat.Message.ThinkingTruncationHint = s.Muted
+	s.Chat.Message.ThinkingFooterTitle = s.Muted
+	s.Chat.Message.ThinkingFooterDuration = s.Subtle
+	s.Chat.Message.ThinkingFooterCancelled = s.Subtle
+
 	// Section metadata styles
 	s.Chat.Message.SectionIcon = s.Subtle
 	s.Chat.Message.SectionModel = s.Muted