Merge pull request #1646 from charmbracelet/chat-ui-assistant-message

Ayman Bagabas created

refactor(chat): simple assistant message

Change summary

cspell.json                      |   0 
internal/ui/anim/anim.go         | 445 +++++++++++++++++
internal/ui/chat/assistant.go    | 251 +++++++++
internal/ui/chat/messages.go     |  55 ++
internal/ui/chat/user.go         |  10 
internal/ui/common/markdown.go   |   9 
internal/ui/list/list.go         |  12 
internal/ui/model/chat.go        | 141 ++++
internal/ui/model/keys.go        |   6 
internal/ui/model/ui.go          | 179 +++++-
internal/ui/styles/styles.go     | 180 ++++++
internal/ui/toolrender/render.go | 889 ----------------------------------
12 files changed, 1,207 insertions(+), 970 deletions(-)

Detailed changes

internal/ui/anim/anim.go 🔗

@@ -0,0 +1,445 @@
+// 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 string }
+
+// Settings defines settings for the animation.
+type Settings struct {
+	ID          string
+	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               string
+}
+
+// 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
+	}
+
+	if opts.ID != "" {
+		a.id = opts.ID
+	} else {
+		a.id = fmt.Sprintf("%d", 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
+}
+
+// Start starts the animation.
+func (a *Anim) Start() tea.Cmd {
+	return a.Step()
+}
+
+// Animate advances the animation to the next step.
+func (a *Anim) Animate(msg StepMsg) tea.Cmd {
+	if msg.ID != a.id {
+		return 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.Step()
+}
+
+// Render renders the current state of the animation.
+func (a *Anim) Render() 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/chat/assistant.go 🔗

@@ -0,0 +1,251 @@
+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/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// maxCollapsedThinkingHeight defines the maximum height of the thinking
+const maxCollapsedThinkingHeight = 10
+
+// AssistantMessageItem represents an assistant message in the chat UI.
+//
+// This item includes thinking, and the content but does not include the tool calls.
+type AssistantMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	message           *message.Message
+	sty               *styles.Styles
+	anim              *anim.Anim
+	thinkingExpanded  bool
+	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
+}
+
+// NewAssistantMessageItem creates a new AssistantMessageItem.
+func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
+	a := &AssistantMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		message:                  message,
+		sty:                      sty,
+	}
+
+	a.anim = anim.New(anim.Settings{
+		ID:          a.ID(),
+		Size:        15,
+		GradColorA:  sty.Primary,
+		GradColorB:  sty.Secondary,
+		LabelColor:  sty.FgBase,
+		CycleColors: true,
+	})
+	return a
+}
+
+// StartAnimation starts the assistant message animation if it should be spinning.
+func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
+	if !a.isSpinning() {
+		return nil
+	}
+	return a.anim.Start()
+}
+
+// Animate progresses the assistant message animation if it should be spinning.
+func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if !a.isSpinning() {
+		return nil
+	}
+	return a.anim.Animate(msg)
+}
+
+// ID implements MessageItem.
+func (a *AssistantMessageItem) ID() string {
+	return a.message.ID
+}
+
+// Render implements MessageItem.
+func (a *AssistantMessageItem) Render(width int) string {
+	cappedWidth := cappedMessageWidth(width)
+	style := a.sty.Chat.Message.AssistantBlurred
+	if a.focused {
+		style = a.sty.Chat.Message.AssistantFocused
+	}
+
+	var spinner string
+	if a.isSpinning() {
+		spinner = a.renderSpinning()
+	}
+
+	content, height, ok := a.getCachedRender(cappedWidth)
+	if !ok {
+		content = a.renderMessageContent(cappedWidth)
+		height = lipgloss.Height(content)
+		// cache the rendered content
+		a.setCachedRender(content, cappedWidth, height)
+	}
+
+	highlightedContent := a.renderHighlighted(content, cappedWidth, height)
+	if spinner != "" {
+		if highlightedContent != "" {
+			highlightedContent += "\n\n"
+		}
+		return style.Render(highlightedContent + spinner)
+	}
+
+	return style.Render(highlightedContent)
+}
+
+// renderMessageContent renders the message content including thinking, main content, and finish reason.
+func (a *AssistantMessageItem) renderMessageContent(width int) string {
+	var messageParts []string
+	thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
+	content := strings.TrimSpace(a.message.Content().Text)
+	// if the massage has reasoning content add that first
+	if thinking != "" {
+		messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
+	}
+
+	// then add the main content
+	if content != "" {
+		// add a spacer between thinking and content
+		if thinking != "" {
+			messageParts = append(messageParts, "")
+		}
+		messageParts = append(messageParts, a.renderMarkdown(content, width))
+	}
+
+	// finally add any finish reason info
+	if a.message.IsFinished() {
+		switch a.message.FinishReason() {
+		case message.FinishReasonCanceled:
+			messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
+		case message.FinishReasonError:
+			messageParts = append(messageParts, a.renderError(width))
+		}
+	}
+
+	return strings.Join(messageParts, "\n")
+}
+
+// renderThinking renders the thinking/reasoning content with footer.
+func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
+	renderer := common.PlainMarkdownRenderer(a.sty, width)
+	rendered, err := renderer.Render(thinking)
+	if err != nil {
+		rendered = thinking
+	}
+	rendered = strings.TrimSpace(rendered)
+
+	lines := strings.Split(rendered, "\n")
+	totalLines := len(lines)
+
+	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...)
+	}
+
+	thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
+	result := thinkingStyle.Render(strings.Join(lines, "\n"))
+	a.thinkingBoxHeight = lipgloss.Height(result)
+
+	var footer string
+	// if thinking is done add the thought for footer
+	if !a.message.IsThinking() {
+		duration := a.message.ThinkingDuration()
+		if duration.String() != "0s" {
+			footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
+				a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
+		}
+	}
+
+	if footer != "" {
+		result += "\n\n" + footer
+	}
+
+	return result
+}
+
+// renderMarkdown renders content as markdown.
+func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
+	renderer := common.MarkdownRenderer(a.sty, width)
+	result, err := renderer.Render(content)
+	if err != nil {
+		return content
+	}
+	return strings.TrimSuffix(result, "\n")
+}
+
+func (a *AssistantMessageItem) renderSpinning() string {
+	if a.message.IsThinking() {
+		a.anim.SetLabel("Thinking")
+	} else if a.message.IsSummaryMessage {
+		a.anim.SetLabel("Summarizing")
+	}
+	return a.anim.Render()
+}
+
+// renderError renders an error message.
+func (a *AssistantMessageItem) renderError(width int) string {
+	finishPart := a.message.FinishPart()
+	errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
+	truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
+	title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
+	details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
+	return fmt.Sprintf("%s\n\n%s", title, details)
+}
+
+// isSpinning returns true if the assistant message is still generating.
+func (a *AssistantMessageItem) isSpinning() bool {
+	isThinking := a.message.IsThinking()
+	isFinished := a.message.IsFinished()
+	hasContent := strings.TrimSpace(a.message.Content().Text) != ""
+	hasToolCalls := len(a.message.ToolCalls()) > 0
+	return (isThinking || !isFinished) && !hasContent && !hasToolCalls
+}
+
+// SetMessage is used to update the underlying message.
+func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
+	wasSpinning := a.isSpinning()
+	a.message = message
+	a.clearCache()
+	if !wasSpinning && a.isSpinning() {
+		return a.StartAnimation()
+	}
+	return nil
+}
+
+// ToggleExpanded toggles the expanded state of the thinking box.
+func (a *AssistantMessageItem) ToggleExpanded() {
+	a.thinkingExpanded = !a.thinkingExpanded
+	a.clearCache()
+}
+
+// HandleMouseClick implements MouseClickable.
+func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	if btn != ansi.MouseLeft {
+		return false
+	}
+	// check if the click is within the thinking box
+	if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
+		a.ToggleExpanded()
+		return true
+	}
+	return false
+}

internal/ui/chat/messages.go 🔗

@@ -1,9 +1,15 @@
+// Package chat provides UI components for displaying and managing chat messages.
+// It defines message item types that can be rendered in a list view, including
+// support for highlighting, focusing, and caching rendered content.
 package chat
 
 import (
 	"image"
+	"strings"
 
+	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
@@ -20,6 +26,17 @@ type Identifiable interface {
 	ID() string
 }
 
+// Animatable is an interface for items that support animation.
+type Animatable interface {
+	StartAnimation() tea.Cmd
+	Animate(msg anim.StepMsg) tea.Cmd
+}
+
+// Expandable is an interface for items that can be expanded or collapsed.
+type Expandable interface {
+	ToggleExpanded()
+}
+
 // MessageItem represents a [message.Message] item that can be displayed in the
 // UI and be part of a [list.List] identifiable by a unique ID.
 type MessageItem interface {
@@ -84,7 +101,7 @@ func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
 
 // cachedMessageItem caches rendered message content to avoid re-rendering.
 //
-// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on
+// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
 //
 // THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
 // the issue with that could be memory usage
@@ -111,6 +128,23 @@ func (c *cachedMessageItem) setCachedRender(rendered string, width, height int)
 	c.height = height
 }
 
+// clearCache clears the cached render.
+func (c *cachedMessageItem) clearCache() {
+	c.rendered = ""
+	c.width = 0
+	c.height = 0
+}
+
+// focusableMessageItem is a base struct for message items that can be focused.
+type focusableMessageItem struct {
+	focused bool
+}
+
+// SetFocused implements MessageItem.
+func (f *focusableMessageItem) SetFocused(focused bool) {
+	f.focused = focused
+}
+
 // cappedMessageWidth returns the maximum width for message content for readability.
 func cappedMessageWidth(availableWidth int) int {
 	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
@@ -125,10 +159,29 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
 	switch msg.Role {
 	case message.User:
 		return []MessageItem{NewUserMessageItem(sty, msg)}
+	case message.Assistant:
+		var items []MessageItem
+		if shouldRenderAssistantMessage(msg) {
+			items = append(items, NewAssistantMessageItem(sty, msg))
+		}
+		return items
 	}
 	return []MessageItem{}
 }
 
+// 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
+// empty message.
+func shouldRenderAssistantMessage(msg *message.Message) bool {
+	content := strings.TrimSpace(msg.Content().Text)
+	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+	isError := msg.FinishReason() == message.FinishReasonError
+	isCancelled := msg.FinishReason() == message.FinishReasonCanceled
+	hasToolCalls := len(msg.ToolCalls()) > 0
+	return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
+}
+
 // BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
 // Tool result messages (role == message.Tool) contain the results that should be linked
 // to tool calls in assistant messages.

internal/ui/chat/user.go 🔗

@@ -16,9 +16,10 @@ import (
 type UserMessageItem struct {
 	*highlightableMessageItem
 	*cachedMessageItem
+	*focusableMessageItem
+
 	message *message.Message
 	sty     *styles.Styles
-	focused bool
 }
 
 // NewUserMessageItem creates a new UserMessageItem.
@@ -26,9 +27,9 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageIte
 	return &UserMessageItem{
 		highlightableMessageItem: defaultHighlighter(sty),
 		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
 		message:                  message,
 		sty:                      sty,
-		focused:                  false,
 	}
 }
 
@@ -67,11 +68,6 @@ func (m *UserMessageItem) Render(width int) string {
 	return style.Render(m.renderHighlighted(content, cappedWidth, height))
 }
 
-// SetFocused implements MessageItem.
-func (m *UserMessageItem) SetFocused(focused bool) {
-	m.focused = focused
-}
-
 // ID implements MessageItem.
 func (m *UserMessageItem) ID() string {
 	return m.message.ID

internal/ui/common/markdown.go 🔗

@@ -2,15 +2,14 @@ package common
 
 import (
 	"charm.land/glamour/v2"
-	gstyles "charm.land/glamour/v2/styles"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
 
 // MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
 // the given styles and width.
-func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
+func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
 	r, _ := glamour.NewTermRenderer(
-		glamour.WithStyles(t.Markdown),
+		glamour.WithStyles(sty.Markdown),
 		glamour.WithWordWrap(width),
 	)
 	return r
@@ -18,9 +17,9 @@ func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
 
 // PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
 // (plain text with structure) and the given width.
-func PlainMarkdownRenderer(width int) *glamour.TermRenderer {
+func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
 	r, _ := glamour.NewTermRenderer(
-		glamour.WithStyles(gstyles.ASCIIStyleConfig),
+		glamour.WithStyles(sty.PlainMarkdown),
 		glamour.WithWordWrap(width),
 	)
 	return r

internal/ui/list/list.go 🔗

@@ -189,9 +189,9 @@ func (l *List) ScrollBy(lines int) {
 	}
 }
 
-// findVisibleItems finds the range of items that are visible in the viewport.
+// VisibleItemIndices finds the range of items that are visible in the viewport.
 // This is used for checking if selected item is in view.
-func (l *List) findVisibleItems() (startIdx, endIdx int) {
+func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
 	if len(l.items) == 0 {
 		return 0, 0
 	}
@@ -352,7 +352,7 @@ func (l *List) ScrollToSelected() {
 		return
 	}
 
-	startIdx, endIdx := l.findVisibleItems()
+	startIdx, endIdx := l.VisibleItemIndices()
 	if l.selectedIdx < startIdx {
 		// Selected item is above the visible range
 		l.offsetIdx = l.selectedIdx
@@ -385,7 +385,7 @@ func (l *List) SelectedItemInView() bool {
 	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 		return false
 	}
-	startIdx, endIdx := l.findVisibleItems()
+	startIdx, endIdx := l.VisibleItemIndices()
 	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
 }
 
@@ -466,13 +466,13 @@ func (l *List) SelectedItem() Item {
 
 // SelectFirstInView selects the first item currently in view.
 func (l *List) SelectFirstInView() {
-	startIdx, _ := l.findVisibleItems()
+	startIdx, _ := l.VisibleItemIndices()
 	l.selectedIdx = startIdx
 }
 
 // SelectLastInView selects the last item currently in view.
 func (l *List) SelectLastInView() {
-	_, endIdx := l.findVisibleItems()
+	_, endIdx := l.VisibleItemIndices()
 	l.selectedIdx = endIdx
 }
 

internal/ui/model/chat.go 🔗

@@ -1,6 +1,8 @@
 package model
 
 import (
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
@@ -11,8 +13,14 @@ import (
 // Chat represents the chat UI model that handles chat interactions and
 // messages.
 type Chat struct {
-	com  *common.Common
-	list *list.List
+	com      *common.Common
+	list     *list.List
+	idInxMap map[string]int // Map of message IDs to their indices in the list
+
+	// Animation visibility optimization: track animations paused due to items
+	// being scrolled out of view. When items become visible again, their
+	// animations are restarted.
+	pausedAnimations map[string]struct{}
 
 	// Mouse state
 	mouseDown     bool
@@ -27,7 +35,11 @@ type Chat struct {
 // NewChat creates a new instance of [Chat] that handles chat interactions and
 // messages.
 func NewChat(com *common.Common) *Chat {
-	c := &Chat{com: com}
+	c := &Chat{
+		com:              com,
+		idInxMap:         make(map[string]int),
+		pausedAnimations: make(map[string]struct{}),
+	}
 	l := list.NewList()
 	l.SetGap(1)
 	l.RegisterRenderCallback(c.applyHighlightRange)
@@ -57,16 +69,14 @@ func (m *Chat) Len() int {
 	return m.list.Len()
 }
 
-// PrependItems prepends new items to the chat list.
-func (m *Chat) PrependItems(items ...list.Item) {
-	m.list.PrependItems(items...)
-	m.list.ScrollToIndex(0)
-}
-
 // SetMessages sets the chat messages to the provided list of message items.
 func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
+	m.idInxMap = make(map[string]int)
+	m.pausedAnimations = make(map[string]struct{})
+
 	items := make([]list.Item, len(msgs))
 	for i, msg := range msgs {
+		m.idInxMap[msg.ID()] = i
 		items[i] = msg
 	}
 	m.list.SetItems(items...)
@@ -76,16 +86,77 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
 // AppendMessages appends a new message item to the chat list.
 func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
 	items := make([]list.Item, len(msgs))
+	indexOffset := m.list.Len()
 	for i, msg := range msgs {
+		m.idInxMap[msg.ID()] = indexOffset + i
 		items[i] = msg
 	}
 	m.list.AppendItems(items...)
 }
 
-// AppendItems appends new items to the chat list.
-func (m *Chat) AppendItems(items ...list.Item) {
-	m.list.AppendItems(items...)
-	m.list.ScrollToIndex(m.list.Len() - 1)
+// Animate animates items in the chat list. Only propagates animation messages
+// to visible items to save CPU. When items are not visible, their animation ID
+// is tracked so it can be restarted when they become visible again.
+func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
+	idx, ok := m.idInxMap[msg.ID]
+	if !ok {
+		return nil
+	}
+
+	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
+	if !ok {
+		return nil
+	}
+
+	// Check if item is currently visible.
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	isVisible := idx >= startIdx && idx <= endIdx
+
+	if !isVisible {
+		// Item not visible - pause animation by not propagating.
+		// Track it so we can restart when it becomes visible.
+		m.pausedAnimations[msg.ID] = struct{}{}
+		return nil
+	}
+
+	// Item is visible - remove from paused set and animate.
+	delete(m.pausedAnimations, msg.ID)
+	return animatable.Animate(msg)
+}
+
+// RestartPausedVisibleAnimations restarts animations for items that were paused
+// due to being scrolled out of view but are now visible again.
+func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
+	if len(m.pausedAnimations) == 0 {
+		return nil
+	}
+
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	var cmds []tea.Cmd
+
+	for id := range m.pausedAnimations {
+		idx, ok := m.idInxMap[id]
+		if !ok {
+			// Item no longer exists.
+			delete(m.pausedAnimations, id)
+			continue
+		}
+
+		if idx >= startIdx && idx <= endIdx {
+			// Item is now visible - restart its animation.
+			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+			delete(m.pausedAnimations, id)
+		}
+	}
+
+	if len(cmds) == 0 {
+		return nil
+	}
+	return tea.Batch(cmds...)
 }
 
 // Focus sets the focus state of the chat component.
@@ -98,24 +169,32 @@ func (m *Chat) Blur() {
 	m.list.Blur()
 }
 
-// ScrollToTop scrolls the chat view to the top.
-func (m *Chat) ScrollToTop() {
+// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
+// any paused animations that are now visible.
+func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
 	m.list.ScrollToTop()
+	return m.RestartPausedVisibleAnimations()
 }
 
-// ScrollToBottom scrolls the chat view to the bottom.
-func (m *Chat) ScrollToBottom() {
+// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
+// restart any paused animations that are now visible.
+func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
 	m.list.ScrollToBottom()
+	return m.RestartPausedVisibleAnimations()
 }
 
-// ScrollBy scrolls the chat view by the given number of line deltas.
-func (m *Chat) ScrollBy(lines int) {
+// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
+// a command to restart any paused animations that are now visible.
+func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
 	m.list.ScrollBy(lines)
+	return m.RestartPausedVisibleAnimations()
 }
 
-// ScrollToSelected scrolls the chat view to the selected item.
-func (m *Chat) ScrollToSelected() {
+// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
+// command to restart any paused animations that are now visible.
+func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
 	m.list.ScrollToSelected()
+	return m.RestartPausedVisibleAnimations()
 }
 
 // SelectedItemInView returns whether the selected item is currently in view.
@@ -158,6 +237,26 @@ func (m *Chat) SelectLastInView() {
 	m.list.SelectLastInView()
 }
 
+// GetMessageItem returns the message item at the given id.
+func (m *Chat) GetMessageItem(id string) chat.MessageItem {
+	idx, ok := m.idInxMap[id]
+	if !ok {
+		return nil
+	}
+	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
+	if !ok {
+		return nil
+	}
+	return item
+}
+
+// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
+func (m *Chat) ToggleExpandedSelectedItem() {
+	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
+		expandable.ToggleExpanded()
+	}
+}
+
 // HandleMouseDown handles mouse down events for the chat component.
 func (m *Chat) HandleMouseDown(x, y int) bool {
 	if m.list.Len() == 0 {

internal/ui/model/keys.go 🔗

@@ -37,6 +37,7 @@ type KeyMap struct {
 		End            key.Binding
 		Copy           key.Binding
 		ClearHighlight key.Binding
+		Expand         key.Binding
 	}
 
 	Initialize struct {
@@ -205,7 +206,10 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("esc", "alt+esc"),
 		key.WithHelp("esc", "clear selection"),
 	)
-
+	km.Chat.Expand = key.NewBinding(
+		key.WithKeys("space"),
+		key.WithHelp("space", "expand/collapse"),
+	)
 	km.Initialize.Yes = key.NewBinding(
 		key.WithKeys("y", "Y"),
 		key.WithHelp("y", "yes"),

internal/ui/model/ui.go 🔗

@@ -27,7 +27,7 @@ import (
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
-	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
@@ -203,9 +203,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, uiutil.ReportError(err))
 			break
 		}
-		m.setSessionMessages(msgs)
+		if cmd := m.setSessionMessages(msgs); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 
 	case pubsub.Event[message.Message]:
+		if m.session == nil || msg.Payload.SessionID != m.session.ID {
+			break
+		}
+		switch msg.Type {
+		case pubsub.CreatedEvent:
+			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
+		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]:
@@ -257,16 +268,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch m.state {
 		case uiChat:
 			if msg.Y <= 0 {
-				m.chat.ScrollBy(-1)
+				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectPrev()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			} else if msg.Y >= m.chat.Height()-1 {
-				m.chat.ScrollBy(1)
+				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectNext()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			}
 
@@ -291,19 +310,33 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case uiChat:
 			switch msg.Button {
 			case tea.MouseWheelUp:
-				m.chat.ScrollBy(-5)
+				if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectPrev()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			case tea.MouseWheelDown:
-				m.chat.ScrollBy(5)
+				if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectNext()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			}
 		}
+	case anim.StepMsg:
+		if m.state == uiChat {
+			if cmd := m.chat.Animate(msg); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	case tea.KeyPressMsg:
 		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 			cmds = append(cmds, cmd)
@@ -336,7 +369,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 // setSessionMessages sets the messages for the current session in the chat
-func (m *UI) setSessionMessages(msgs []message.Message) {
+func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
+	var cmds []tea.Cmd
 	// Build tool result map to link tool calls with their results
 	msgPtrs := make([]*message.Message, len(msgs))
 	for i := range msgs {
@@ -350,9 +384,54 @@ func (m *UI) setSessionMessages(msgs []message.Message) {
 		items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
 	}
 
+	// If the user switches between sessions while the agent is working we want
+	// to make sure the animations are shown.
+	for _, item := range items {
+		if animatable, ok := item.(chat.Animatable); ok {
+			if cmd := animatable.StartAnimation(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+
 	m.chat.SetMessages(items...)
-	m.chat.ScrollToBottom()
+	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
 	m.chat.SelectLast()
+	return tea.Batch(cmds...)
+}
+
+// appendSessionMessage appends a new message to the current session in the chat
+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)
+			}
+		}
+	}
+	m.chat.AppendMessages(items...)
+	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
 }
 
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -498,41 +577,67 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				m.focus = uiFocusEditor
 				cmds = append(cmds, m.textarea.Focus())
 				m.chat.Blur()
+			case key.Matches(msg, m.keyMap.Chat.Expand):
+				m.chat.ToggleExpandedSelectedItem()
 			case key.Matches(msg, m.keyMap.Chat.Up):
-				m.chat.ScrollBy(-1)
+				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectPrev()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			case key.Matches(msg, m.keyMap.Chat.Down):
-				m.chat.ScrollBy(1)
+				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectNext()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
 				m.chat.SelectPrev()
-				m.chat.ScrollToSelected()
+				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
 				m.chat.SelectNext()
-				m.chat.ScrollToSelected()
+				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
-				m.chat.ScrollBy(-m.chat.Height() / 2)
+				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectFirstInView()
 			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
-				m.chat.ScrollBy(m.chat.Height() / 2)
+				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectLastInView()
 			case key.Matches(msg, m.keyMap.Chat.PageUp):
-				m.chat.ScrollBy(-m.chat.Height())
+				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectFirstInView()
 			case key.Matches(msg, m.keyMap.Chat.PageDown):
-				m.chat.ScrollBy(m.chat.Height())
+				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectLastInView()
 			case key.Matches(msg, m.keyMap.Chat.Home):
-				m.chat.ScrollToTop()
+				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectFirst()
 			case key.Matches(msg, m.keyMap.Chat.End):
-				m.chat.ScrollToBottom()
+				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectLast()
 			default:
 				handleGlobalKeys(msg)
@@ -1161,33 +1266,33 @@ func (m *UI) renderSidebarLogo(width int) {
 
 // sendMessage sends a message with the given content and attachments.
 func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
-	if m.session == nil {
-		return uiutil.ReportError(fmt.Errorf("no session selected"))
+	if m.com.App.AgentCoordinator == nil {
+		return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
 	}
-	session := *m.session
+
 	var cmds []tea.Cmd
-	if m.session.ID == "" {
+	if m.session == nil || m.session.ID == "" {
 		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
 		if err != nil {
 			return uiutil.ReportError(err)
 		}
-		session = newSession
-		cmds = append(cmds, m.loadSession(session.ID))
-	}
-	if m.com.App.AgentCoordinator == nil {
-		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
+		m.state = uiChat
+		m.session = &newSession
+		cmds = append(cmds, m.loadSession(newSession.ID))
 	}
-	m.chat.ScrollToBottom()
+
+	// Capture session ID to avoid race with main goroutine updating m.session.
+	sessionID := m.session.ID
 	cmds = append(cmds, func() tea.Msg {
-		_, err := m.com.App.AgentCoordinator.Run(context.Background(), session.ID, content, attachments...)
+		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
 		if err != nil {
 			isCancelErr := errors.Is(err, context.Canceled)
 			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
 			if isCancelErr || isPermissionErr {
 				return nil
 			}
-			return util.InfoMsg{
-				Type: util.InfoTypeError,
+			return uiutil.InfoMsg{
+				Type: uiutil.InfoTypeError,
 				Msg:  err.Error(),
 			}
 		}

internal/ui/styles/styles.go 🔗

@@ -85,7 +85,8 @@ type Styles struct {
 	ItemOnlineIcon  lipgloss.Style
 
 	// Markdown & Chroma
-	Markdown ansi.StyleConfig
+	Markdown      ansi.StyleConfig
+	PlainMarkdown ansi.StyleConfig
 
 	// Inputs
 	TextInput textinput.Styles
@@ -195,8 +196,13 @@ type Styles struct {
 			Attachment       lipgloss.Style
 			ToolCallFocused  lipgloss.Style
 			ToolCallBlurred  lipgloss.Style
-			ThinkingFooter   lipgloss.Style
 			SectionHeader    lipgloss.Style
+
+			// 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
 		}
 	}
 
@@ -667,6 +673,169 @@ func DefaultStyles() Styles {
 		},
 	}
 
+	// PlainMarkdown style - muted colors on subtle background for thinking content.
+	plainBg := stringPtr(bgBaseLighter.Hex())
+	plainFg := stringPtr(fgMuted.Hex())
+	s.PlainMarkdown = ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		BlockQuote: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+			Indent:      uintPtr(1),
+			IndentToken: stringPtr("│ "),
+		},
+		List: ansi.StyleList{
+			LevelIndent: defaultListIndent,
+		},
+		Heading: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BlockSuffix:     "\n",
+				Bold:            boolPtr(true),
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H1: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Bold:            boolPtr(true),
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H2: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "## ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H3: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H4: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "#### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H5: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "##### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		H6: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          "###### ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		Strikethrough: ansi.StylePrimitive{
+			CrossedOut:      boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Emph: ansi.StylePrimitive{
+			Italic:          boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Strong: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		HorizontalRule: ansi.StylePrimitive{
+			Format:          "\n--------\n",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Item: ansi.StylePrimitive{
+			BlockPrefix:     "• ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Enumeration: ansi.StylePrimitive{
+			BlockPrefix:     ". ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Task: ansi.StyleTask{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+			Ticked:   "[✓] ",
+			Unticked: "[ ] ",
+		},
+		Link: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		LinkText: ansi.StylePrimitive{
+			Bold:            boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Image: ansi.StylePrimitive{
+			Underline:       boolPtr(true),
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		ImageText: ansi.StylePrimitive{
+			Format:          "Image: {{.text}} →",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+		Code: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           plainFg,
+				BackgroundColor: plainBg,
+			},
+		},
+		CodeBlock: ansi.StyleCodeBlock{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           plainFg,
+					BackgroundColor: plainBg,
+				},
+				Margin: uintPtr(defaultMargin),
+			},
+		},
+		Table: ansi.StyleTable{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color:           plainFg,
+					BackgroundColor: plainBg,
+				},
+			},
+		},
+		DefinitionDescription: ansi.StylePrimitive{
+			BlockPrefix:     "\n ",
+			Color:           plainFg,
+			BackgroundColor: plainBg,
+		},
+	}
+
 	s.Help = help.Styles{
 		ShortKey:       base.Foreground(fgMuted),
 		ShortDesc:      base.Foreground(fgSubtle),
@@ -892,9 +1061,14 @@ 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
+
 	// Text selection.
 	s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
 

internal/ui/toolrender/render.go 🔗

@@ -1,889 +0,0 @@
-package toolrender
-
-import (
-	"cmp"
-	"encoding/json"
-	"fmt"
-	"strings"
-
-	"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/ansiext"
-	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/message"
-	"github.com/charmbracelet/crush/internal/ui/common"
-	"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
-
-// RenderContext provides the context needed for rendering a tool call.
-type RenderContext struct {
-	Call      message.ToolCall
-	Result    message.ToolResult
-	Cancelled bool
-	IsNested  bool
-	Width     int
-	Styles    *styles.Styles
-}
-
-// TextWidth returns the available width for content accounting for borders.
-func (rc *RenderContext) TextWidth() int {
-	if rc.IsNested {
-		return rc.Width - 6
-	}
-	return rc.Width - 5
-}
-
-// Fit truncates content to fit within the specified width with ellipsis.
-func (rc *RenderContext) Fit(content string, width int) string {
-	lineStyle := rc.Styles.Muted
-	dots := lineStyle.Render("…")
-	return ansi.Truncate(content, width, dots)
-}
-
-// Render renders a tool call using the appropriate renderer based on tool name.
-func Render(ctx *RenderContext) string {
-	switch ctx.Call.Name {
-	case tools.ViewToolName:
-		return renderView(ctx)
-	case tools.EditToolName:
-		return renderEdit(ctx)
-	case tools.MultiEditToolName:
-		return renderMultiEdit(ctx)
-	case tools.WriteToolName:
-		return renderWrite(ctx)
-	case tools.BashToolName:
-		return renderBash(ctx)
-	case tools.JobOutputToolName:
-		return renderJobOutput(ctx)
-	case tools.JobKillToolName:
-		return renderJobKill(ctx)
-	case tools.FetchToolName:
-		return renderSimpleFetch(ctx)
-	case tools.AgenticFetchToolName:
-		return renderAgenticFetch(ctx)
-	case tools.WebFetchToolName:
-		return renderWebFetch(ctx)
-	case tools.DownloadToolName:
-		return renderDownload(ctx)
-	case tools.GlobToolName:
-		return renderGlob(ctx)
-	case tools.GrepToolName:
-		return renderGrep(ctx)
-	case tools.LSToolName:
-		return renderLS(ctx)
-	case tools.SourcegraphToolName:
-		return renderSourcegraph(ctx)
-	case tools.DiagnosticsToolName:
-		return renderDiagnostics(ctx)
-	case agent.AgentToolName:
-		return renderAgent(ctx)
-	default:
-		return renderGeneric(ctx)
-	}
-}
-
-// Helper functions
-
-func unmarshalParams(input string, target any) error {
-	return json.Unmarshal([]byte(input), target)
-}
-
-type paramBuilder struct {
-	args []string
-}
-
-func newParamBuilder() *paramBuilder {
-	return &paramBuilder{args: make([]string, 0)}
-}
-
-func (pb *paramBuilder) addMain(value string) *paramBuilder {
-	if value != "" {
-		pb.args = append(pb.args, value)
-	}
-	return pb
-}
-
-func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
-	if value != "" {
-		pb.args = append(pb.args, key, value)
-	}
-	return pb
-}
-
-func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
-	if value {
-		pb.args = append(pb.args, key, "true")
-	}
-	return pb
-}
-
-func (pb *paramBuilder) build() []string {
-	return pb.args
-}
-
-func formatNonZero[T comparable](value T) string {
-	var zero T
-	if value == zero {
-		return ""
-	}
-	return fmt.Sprintf("%v", value)
-}
-
-func makeHeader(ctx *RenderContext, toolName string, args []string) string {
-	if ctx.IsNested {
-		return makeNestedHeader(ctx, toolName, args)
-	}
-	s := ctx.Styles
-	var icon string
-	if ctx.Result.ToolCallID != "" {
-		if ctx.Result.IsError {
-			icon = s.Tool.IconError.Render()
-		} else {
-			icon = s.Tool.IconSuccess.Render()
-		}
-	} else if ctx.Cancelled {
-		icon = s.Tool.IconCancelled.Render()
-	} else {
-		icon = s.Tool.IconPending.Render()
-	}
-	tool := s.Tool.NameNormal.Render(toolName)
-	prefix := fmt.Sprintf("%s %s ", icon, tool)
-	return prefix + renderParamList(ctx, false, ctx.TextWidth()-lipgloss.Width(prefix), args...)
-}
-
-func makeNestedHeader(ctx *RenderContext, toolName string, args []string) string {
-	s := ctx.Styles
-	var icon string
-	if ctx.Result.ToolCallID != "" {
-		if ctx.Result.IsError {
-			icon = s.Tool.IconError.Render()
-		} else {
-			icon = s.Tool.IconSuccess.Render()
-		}
-	} else if ctx.Cancelled {
-		icon = s.Tool.IconCancelled.Render()
-	} else {
-		icon = s.Tool.IconPending.Render()
-	}
-	tool := s.Tool.NameNested.Render(toolName)
-	prefix := fmt.Sprintf("%s %s ", icon, tool)
-	return prefix + renderParamList(ctx, true, ctx.TextWidth()-lipgloss.Width(prefix), args...)
-}
-
-func renderParamList(ctx *RenderContext, nested bool, paramsWidth int, params ...string) string {
-	s := ctx.Styles
-	if len(params) == 0 {
-		return ""
-	}
-	mainParam := params[0]
-	if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
-		mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
-	}
-
-	if len(params) == 1 {
-		return s.Tool.ParamMain.Render(mainParam)
-	}
-	otherParams := params[1:]
-	if len(otherParams)%2 != 0 {
-		otherParams = append(otherParams, "")
-	}
-	parts := make([]string, 0, len(otherParams)/2)
-	for i := 0; i < len(otherParams); i += 2 {
-		key := otherParams[i]
-		value := otherParams[i+1]
-		if value == "" {
-			continue
-		}
-		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
-	}
-
-	partsRendered := strings.Join(parts, ", ")
-	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3
-	if remainingWidth < 30 {
-		return s.Tool.ParamMain.Render(mainParam)
-	}
-
-	if len(parts) > 0 {
-		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
-	}
-
-	return s.Tool.ParamMain.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
-}
-
-func earlyState(ctx *RenderContext, header string) (string, bool) {
-	s := ctx.Styles
-	message := ""
-	switch {
-	case ctx.Result.IsError:
-		message = renderToolError(ctx)
-	case ctx.Cancelled:
-		message = s.Tool.StateCancelled.Render("Canceled.")
-	case ctx.Result.ToolCallID == "":
-		message = s.Tool.StateWaiting.Render("Waiting for tool response...")
-	default:
-		return "", false
-	}
-
-	message = s.Tool.BodyPadding.Render(message)
-	return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
-}
-
-func renderToolError(ctx *RenderContext) string {
-	s := ctx.Styles
-	errTag := s.Tool.ErrorTag.Render("ERROR")
-	msg := ctx.Result.Content
-	if msg == "" {
-		msg = "An error occurred"
-	}
-	truncated := ansi.Truncate(msg, ctx.TextWidth()-3-lipgloss.Width(errTag), "…")
-	return errTag + " " + s.Tool.ErrorMessage.Render(truncated)
-}
-
-func joinHeaderBody(ctx *RenderContext, header, body string) string {
-	s := ctx.Styles
-	if body == "" {
-		return header
-	}
-	body = s.Tool.BodyPadding.Render(body)
-	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
-}
-
-func renderWithParams(ctx *RenderContext, toolName string, args []string, contentRenderer func() string) string {
-	header := makeHeader(ctx, toolName, args)
-	if ctx.IsNested {
-		return header
-	}
-	if res, done := earlyState(ctx, header); done {
-		return res
-	}
-	body := contentRenderer()
-	return joinHeaderBody(ctx, header, body)
-}
-
-func renderError(ctx *RenderContext, message string) string {
-	s := ctx.Styles
-	header := makeHeader(ctx, prettifyToolName(ctx.Call.Name), []string{})
-	errorTag := s.Tool.ErrorTag.Render("ERROR")
-	message = s.Tool.ErrorMessage.Render(ctx.Fit(message, ctx.TextWidth()-3-lipgloss.Width(errorTag)))
-	return joinHeaderBody(ctx, header, errorTag+" "+message)
-}
-
-func renderPlainContent(ctx *RenderContext, content string) string {
-	s := ctx.Styles
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
-	lines := strings.Split(content, "\n")
-
-	width := ctx.TextWidth() - 2
-	var out []string
-	for i, ln := range lines {
-		if i >= responseContextHeight {
-			break
-		}
-		ln = ansiext.Escape(ln)
-		ln = " " + ln
-		if len(ln) > width {
-			ln = ctx.Fit(ln, width)
-		}
-		out = append(out, s.Tool.ContentLine.Width(width).Render(ln))
-	}
-
-	if len(lines) > responseContextHeight {
-		out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
-	}
-
-	return strings.Join(out, "\n")
-}
-
-func renderMarkdownContent(ctx *RenderContext, content string) string {
-	s := ctx.Styles
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	content = strings.TrimSpace(content)
-
-	width := ctx.TextWidth() - 2
-	width = min(width, 120)
-
-	renderer := common.PlainMarkdownRenderer(width)
-	rendered, err := renderer.Render(content)
-	if err != nil {
-		return renderPlainContent(ctx, content)
-	}
-
-	lines := strings.Split(rendered, "\n")
-
-	var out []string
-	for i, ln := range lines {
-		if i >= responseContextHeight {
-			break
-		}
-		out = append(out, ln)
-	}
-
-	style := s.Tool.ContentLine
-	if len(lines) > responseContextHeight {
-		out = append(out, s.Tool.ContentTruncation.
-			Width(width-2).
-			Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
-	}
-
-	return style.Render(strings.Join(out, "\n"))
-}
-
-func renderCodeContent(ctx *RenderContext, path, content string, offset int) string {
-	s := ctx.Styles
-	content = strings.ReplaceAll(content, "\r\n", "\n")
-	content = strings.ReplaceAll(content, "\t", "    ")
-	truncated := truncateHeight(content, responseContextHeight)
-
-	lines := strings.Split(truncated, "\n")
-	for i, ln := range lines {
-		lines[i] = ansiext.Escape(ln)
-	}
-
-	bg := s.Tool.ContentCodeBg
-	highlighted, _ := common.SyntaxHighlight(ctx.Styles, strings.Join(lines, "\n"), path, bg)
-	lines = strings.Split(highlighted, "\n")
-
-	width := ctx.TextWidth() - 2
-	gutterWidth := getDigits(offset+len(lines)) + 1
-
-	var out []string
-	for i, ln := range lines {
-		lineNum := fmt.Sprintf("%*d", gutterWidth, offset+i+1)
-		gutter := s.Subtle.Render(lineNum + " ")
-		ln = " " + ln
-		if lipgloss.Width(gutter+ln) > width {
-			ln = ctx.Fit(ln, width-lipgloss.Width(gutter))
-		}
-		out = append(out, s.Tool.ContentCodeLine.Width(width).Render(gutter+ln))
-	}
-
-	contentLines := strings.Split(content, "\n")
-	if len(contentLines) > responseContextHeight {
-		out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)))
-	}
-
-	return strings.Join(out, "\n")
-}
-
-func getDigits(n int) int {
-	if n == 0 {
-		return 1
-	}
-	if n < 0 {
-		n = -n
-	}
-
-	digits := 0
-	for n > 0 {
-		n /= 10
-		digits++
-	}
-
-	return digits
-}
-
-func truncateHeight(content string, maxLines int) string {
-	lines := strings.Split(content, "\n")
-	if len(lines) <= maxLines {
-		return content
-	}
-	return strings.Join(lines[:maxLines], "\n")
-}
-
-func prettifyToolName(name string) string {
-	switch name {
-	case "agent":
-		return "Agent"
-	case "bash":
-		return "Bash"
-	case "job_output":
-		return "Job: Output"
-	case "job_kill":
-		return "Job: Kill"
-	case "download":
-		return "Download"
-	case "edit":
-		return "Edit"
-	case "multiedit":
-		return "Multi-Edit"
-	case "fetch":
-		return "Fetch"
-	case "agentic_fetch":
-		return "Agentic Fetch"
-	case "web_fetch":
-		return "Fetching"
-	case "glob":
-		return "Glob"
-	case "grep":
-		return "Grep"
-	case "ls":
-		return "List"
-	case "sourcegraph":
-		return "Sourcegraph"
-	case "view":
-		return "View"
-	case "write":
-		return "Write"
-	case "lsp_references":
-		return "Find References"
-	case "lsp_diagnostics":
-		return "Diagnostics"
-	default:
-		parts := strings.Split(name, "_")
-		for i := range parts {
-			if len(parts[i]) > 0 {
-				parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
-			}
-		}
-		return strings.Join(parts, " ")
-	}
-}
-
-// Tool-specific renderers
-
-func renderGeneric(ctx *RenderContext) string {
-	return renderWithParams(ctx, prettifyToolName(ctx.Call.Name), []string{ctx.Call.Input}, func() string {
-		return renderPlainContent(ctx, ctx.Result.Content)
-	})
-}
-
-func renderView(ctx *RenderContext) string {
-	var params tools.ViewParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid view parameters")
-	}
-
-	file := fsext.PrettyPath(params.FilePath)
-	args := newParamBuilder().
-		addMain(file).
-		addKeyValue("limit", formatNonZero(params.Limit)).
-		addKeyValue("offset", formatNonZero(params.Offset)).
-		build()
-
-	return renderWithParams(ctx, "View", args, func() string {
-		var meta tools.ViewResponseMetadata
-		if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
-			return renderPlainContent(ctx, ctx.Result.Content)
-		}
-		return renderCodeContent(ctx, meta.FilePath, meta.Content, params.Offset)
-	})
-}
-
-func renderEdit(ctx *RenderContext) string {
-	s := ctx.Styles
-	var params tools.EditParams
-	var args []string
-	if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
-		file := fsext.PrettyPath(params.FilePath)
-		args = newParamBuilder().addMain(file).build()
-	}
-
-	return renderWithParams(ctx, "Edit", args, func() string {
-		var meta tools.EditResponseMetadata
-		if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
-			return renderPlainContent(ctx, ctx.Result.Content)
-		}
-
-		formatter := common.DiffFormatter(ctx.Styles).
-			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
-			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
-			Width(ctx.TextWidth() - 2)
-		if ctx.TextWidth() > 120 {
-			formatter = formatter.Split()
-		}
-		formatted := formatter.String()
-		if lipgloss.Height(formatted) > responseContextHeight {
-			contentLines := strings.Split(formatted, "\n")
-			truncateMessage := s.Tool.DiffTruncation.
-				Width(ctx.TextWidth() - 2).
-				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
-			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
-		}
-		return formatted
-	})
-}
-
-func renderMultiEdit(ctx *RenderContext) string {
-	s := ctx.Styles
-	var params tools.MultiEditParams
-	var args []string
-	if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
-		file := fsext.PrettyPath(params.FilePath)
-		args = newParamBuilder().
-			addMain(file).
-			addKeyValue("edits", fmt.Sprintf("%d", len(params.Edits))).
-			build()
-	}
-
-	return renderWithParams(ctx, "Multi-Edit", args, func() string {
-		var meta tools.MultiEditResponseMetadata
-		if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
-			return renderPlainContent(ctx, ctx.Result.Content)
-		}
-
-		formatter := common.DiffFormatter(ctx.Styles).
-			Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
-			After(fsext.PrettyPath(params.FilePath), meta.NewContent).
-			Width(ctx.TextWidth() - 2)
-		if ctx.TextWidth() > 120 {
-			formatter = formatter.Split()
-		}
-		formatted := formatter.String()
-		if lipgloss.Height(formatted) > responseContextHeight {
-			contentLines := strings.Split(formatted, "\n")
-			truncateMessage := s.Tool.DiffTruncation.
-				Width(ctx.TextWidth() - 2).
-				Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
-			formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
-		}
-
-		// Add note about failed edits if any.
-		if len(meta.EditsFailed) > 0 {
-			noteTag := s.Tool.NoteTag.Render("NOTE")
-			noteMsg := s.Tool.NoteMessage.Render(
-				fmt.Sprintf("%d of %d edits failed", len(meta.EditsFailed), len(params.Edits)))
-			formatted = formatted + "\n\n" + noteTag + " " + noteMsg
-		}
-
-		return formatted
-	})
-}
-
-func renderWrite(ctx *RenderContext) string {
-	var params tools.WriteParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid write parameters")
-	}
-
-	file := fsext.PrettyPath(params.FilePath)
-	args := newParamBuilder().addMain(file).build()
-
-	return renderWithParams(ctx, "Write", args, func() string {
-		return renderCodeContent(ctx, params.FilePath, params.Content, 0)
-	})
-}
-
-func renderBash(ctx *RenderContext) string {
-	var params tools.BashParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid bash parameters")
-	}
-
-	cmd := strings.ReplaceAll(params.Command, "\n", " ")
-	cmd = strings.ReplaceAll(cmd, "\t", "    ")
-	args := newParamBuilder().
-		addMain(cmd).
-		addFlag("background", params.RunInBackground).
-		build()
-
-	if ctx.Call.Finished {
-		var meta tools.BashResponseMetadata
-		_ = unmarshalParams(ctx.Result.Metadata, &meta)
-		if meta.Background {
-			description := cmp.Or(meta.Description, params.Command)
-			width := ctx.TextWidth()
-			if ctx.IsNested {
-				width -= 4
-			}
-			header := makeJobHeader(ctx, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
-			if ctx.IsNested {
-				return header
-			}
-			if res, done := earlyState(ctx, header); done {
-				return res
-			}
-			content := "Command: " + params.Command + "\n" + ctx.Result.Content
-			body := renderPlainContent(ctx, content)
-			return joinHeaderBody(ctx, header, body)
-		}
-	}
-
-	return renderWithParams(ctx, "Bash", args, func() string {
-		var meta tools.BashResponseMetadata
-		if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
-			return renderPlainContent(ctx, ctx.Result.Content)
-		}
-		if meta.Output == "" && ctx.Result.Content != tools.BashNoOutput {
-			meta.Output = ctx.Result.Content
-		}
-
-		if meta.Output == "" {
-			return ""
-		}
-		return renderPlainContent(ctx, meta.Output)
-	})
-}
-
-func makeJobHeader(ctx *RenderContext, action, pid, description string, width int) string {
-	s := ctx.Styles
-	icon := s.Tool.JobIconPending.Render(styles.ToolPending)
-	if ctx.Result.ToolCallID != "" {
-		if ctx.Result.IsError {
-			icon = s.Tool.JobIconError.Render(styles.ToolError)
-		} else {
-			icon = s.Tool.JobIconSuccess.Render(styles.ToolSuccess)
-		}
-	} else if ctx.Cancelled {
-		icon = s.Muted.Render(styles.ToolPending)
-	}
-
-	toolName := s.Tool.JobToolName.Render("Bash")
-	actionPart := s.Tool.JobAction.Render(action)
-	pidPart := s.Tool.JobPID.Render(pid)
-
-	prefix := fmt.Sprintf("%s %s %s %s ", icon, toolName, actionPart, pidPart)
-	remainingWidth := width - lipgloss.Width(prefix)
-
-	descDisplay := ansi.Truncate(description, remainingWidth, "…")
-	descDisplay = s.Tool.JobDescription.Render(descDisplay)
-
-	return prefix + descDisplay
-}
-
-func renderJobOutput(ctx *RenderContext) string {
-	var params tools.JobOutputParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid job output parameters")
-	}
-
-	width := ctx.TextWidth()
-	if ctx.IsNested {
-		width -= 4
-	}
-
-	var meta tools.JobOutputResponseMetadata
-	_ = unmarshalParams(ctx.Result.Metadata, &meta)
-	description := cmp.Or(meta.Description, meta.Command)
-
-	header := makeJobHeader(ctx, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
-	if ctx.IsNested {
-		return header
-	}
-	if res, done := earlyState(ctx, header); done {
-		return res
-	}
-	body := renderPlainContent(ctx, ctx.Result.Content)
-	return joinHeaderBody(ctx, header, body)
-}
-
-func renderJobKill(ctx *RenderContext) string {
-	var params tools.JobKillParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid job kill parameters")
-	}
-
-	width := ctx.TextWidth()
-	if ctx.IsNested {
-		width -= 4
-	}
-
-	var meta tools.JobKillResponseMetadata
-	_ = unmarshalParams(ctx.Result.Metadata, &meta)
-	description := cmp.Or(meta.Description, meta.Command)
-
-	header := makeJobHeader(ctx, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
-	if ctx.IsNested {
-		return header
-	}
-	if res, done := earlyState(ctx, header); done {
-		return res
-	}
-	body := renderPlainContent(ctx, ctx.Result.Content)
-	return joinHeaderBody(ctx, header, body)
-}
-
-func renderSimpleFetch(ctx *RenderContext) string {
-	var params tools.FetchParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid fetch parameters")
-	}
-
-	args := newParamBuilder().
-		addMain(params.URL).
-		addKeyValue("format", params.Format).
-		addKeyValue("timeout", formatNonZero(params.Timeout)).
-		build()
-
-	return renderWithParams(ctx, "Fetch", args, func() string {
-		path := "file." + params.Format
-		return renderCodeContent(ctx, path, ctx.Result.Content, 0)
-	})
-}
-
-func renderAgenticFetch(ctx *RenderContext) string {
-	// TODO: Implement nested tool call rendering with tree.
-	return renderGeneric(ctx)
-}
-
-func renderWebFetch(ctx *RenderContext) string {
-	var params tools.WebFetchParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid web fetch parameters")
-	}
-
-	args := newParamBuilder().addMain(params.URL).build()
-
-	return renderWithParams(ctx, "Fetching", args, func() string {
-		return renderMarkdownContent(ctx, ctx.Result.Content)
-	})
-}
-
-func renderDownload(ctx *RenderContext) string {
-	var params tools.DownloadParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid download parameters")
-	}
-
-	args := newParamBuilder().
-		addMain(params.URL).
-		addKeyValue("file", fsext.PrettyPath(params.FilePath)).
-		addKeyValue("timeout", formatNonZero(params.Timeout)).
-		build()
-
-	return renderWithParams(ctx, "Download", args, func() string {
-		return renderPlainContent(ctx, ctx.Result.Content)
-	})
-}
-
-func renderGlob(ctx *RenderContext) string {
-	var params tools.GlobParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid glob parameters")
-	}
-
-	args := newParamBuilder().
-		addMain(params.Pattern).
-		addKeyValue("path", params.Path).
-		build()
-
-	return renderWithParams(ctx, "Glob", args, func() string {
-		return renderPlainContent(ctx, ctx.Result.Content)
-	})
-}
-
-func renderGrep(ctx *RenderContext) string {
-	var params tools.GrepParams
-	var args []string
-	if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
-		args = newParamBuilder().
-			addMain(params.Pattern).
-			addKeyValue("path", params.Path).
-			addKeyValue("include", params.Include).
-			addFlag("literal", params.LiteralText).
-			build()
-	}
-
-	return renderWithParams(ctx, "Grep", args, func() string {
-		return renderPlainContent(ctx, ctx.Result.Content)
-	})
-}
-
-func renderLS(ctx *RenderContext) string {
-	var params tools.LSParams
-	path := cmp.Or(params.Path, ".")
-	args := newParamBuilder().addMain(path).build()
-
-	if err := unmarshalParams(ctx.Call.Input, &params); err == nil && params.Path != "" {
-		args = newParamBuilder().addMain(params.Path).build()
-	}
-
-	return renderWithParams(ctx, "List", args, func() string {
-		return renderPlainContent(ctx, ctx.Result.Content)
-	})
-}
-
-func renderSourcegraph(ctx *RenderContext) string {
-	var params tools.SourcegraphParams
-	if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
-		return renderError(ctx, "Invalid sourcegraph parameters")
-	}
-
-	args := newParamBuilder().
-		addMain(params.Query).
-		addKeyValue("count", formatNonZero(params.Count)).
-		addKeyValue("context", formatNonZero(params.ContextWindow)).
-		build()
-
-	return renderWithParams(ctx, "Sourcegraph", args, func() string {
-		return renderPlainContent(ctx, ctx.Result.Content)
-	})
-}
-
-func renderDiagnostics(ctx *RenderContext) string {
-	args := newParamBuilder().addMain("project").build()
-
-	return renderWithParams(ctx, "Diagnostics", args, func() string {
-		return renderPlainContent(ctx, ctx.Result.Content)
-	})
-}
-
-func renderAgent(ctx *RenderContext) string {
-	s := ctx.Styles
-	var params agent.AgentParams
-	unmarshalParams(ctx.Call.Input, &params)
-
-	prompt := params.Prompt
-	prompt = strings.ReplaceAll(prompt, "\n", " ")
-
-	header := makeHeader(ctx, "Agent", []string{})
-	if res, done := earlyState(ctx, header); ctx.Cancelled && done {
-		return res
-	}
-	taskTag := s.Tool.AgentTaskTag.Render("Task")
-	remainingWidth := ctx.TextWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
-	remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
-	prompt = s.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
-	header = lipgloss.JoinVertical(
-		lipgloss.Left,
-		header,
-		"",
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			taskTag,
-			" ",
-			prompt,
-		),
-	)
-	childTools := tree.Root(header)
-
-	// TODO: Render nested tool calls when available.
-
-	parts := []string{
-		childTools.Enumerator(roundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
-	}
-
-	if ctx.Result.ToolCallID == "" {
-		// Pending state - would show animation in TUI.
-		parts = append(parts, "", s.Subtle.Render("Working..."))
-	}
-
-	header = lipgloss.JoinVertical(
-		lipgloss.Left,
-		parts...,
-	)
-
-	if ctx.Result.ToolCallID == "" {
-		return header
-	}
-
-	body := renderMarkdownContent(ctx, ctx.Result.Content)
-	return joinHeaderBody(ctx, header, body)
-}
-
-func roundedEnumeratorWithWidth(width int, offset int) func(tree.Children, int) string {
-	return func(children tree.Children, i int) string {
-		if children.Length()-1 == i {
-			return strings.Repeat(" ", offset) + "└" + strings.Repeat("─", width-1) + " "
-		}
-		return strings.Repeat(" ", offset) + "├" + strings.Repeat("─", width-1) + " "
-	}
-}