feat(ui): add common utils and refactor chat message items and tools

Ayman Bagabas created

Change summary

internal/ui/common/diff.go       |  16 
internal/ui/common/highlight.go  |  57 ++
internal/ui/model/chat.go        |  24 
internal/ui/model/items.go       | 753 ++++++++++++++++++++++++++++
internal/ui/model/ui.go          |  17 
internal/ui/styles/styles.go     | 286 ++++++++++
internal/ui/toolrender/render.go | 889 ++++++++++++++++++++++++++++++++++
7 files changed, 2,008 insertions(+), 34 deletions(-)

Detailed changes

internal/ui/common/diff.go 🔗

@@ -0,0 +1,16 @@
+package common
+
+import (
+	"github.com/alecthomas/chroma/v2"
+	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// DiffFormatter returns a diff formatter with the given styles that can be
+// used to format diff outputs.
+func DiffFormatter(s *styles.Styles) *diffview.DiffView {
+	formatDiff := diffview.New()
+	style := chroma.MustNewStyle("crush", s.ChromaTheme())
+	diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
+	return diff
+}

internal/ui/common/highlight.go 🔗

@@ -0,0 +1,57 @@
+package common
+
+import (
+	"bytes"
+	"image/color"
+
+	"github.com/alecthomas/chroma/v2"
+	"github.com/alecthomas/chroma/v2/formatters"
+	"github.com/alecthomas/chroma/v2/lexers"
+	chromastyles "github.com/alecthomas/chroma/v2/styles"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// SyntaxHighlight applies syntax highlighting to the given source code based
+// on the file name and background color. It returns the highlighted code as a
+// string.
+func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color) (string, error) {
+	// Determine the language lexer to use
+	l := lexers.Match(fileName)
+	if l == nil {
+		l = lexers.Analyse(source)
+	}
+	if l == nil {
+		l = lexers.Fallback
+	}
+	l = chroma.Coalesce(l)
+
+	// Get the formatter
+	f := formatters.Get("terminal16m")
+	if f == nil {
+		f = formatters.Fallback
+	}
+
+	style := chroma.MustNewStyle("crush", st.ChromaTheme())
+
+	// Modify the style to use the provided background
+	s, err := style.Builder().Transform(
+		func(t chroma.StyleEntry) chroma.StyleEntry {
+			r, g, b, _ := bg.RGBA()
+			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
+			return t
+		},
+	).Build()
+	if err != nil {
+		s = chromastyles.Fallback
+	}
+
+	// Tokenize and format
+	it, err := l.Tokenise(nil, source)
+	if err != nil {
+		return "", err
+	}
+
+	var buf bytes.Buffer
+	err = f.Format(&buf, s, it)
+	return buf.String(), err
+}

internal/ui/model/chat.go 🔗

@@ -65,7 +65,7 @@ type ChatNoContentItem struct {
 func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem {
 	c := new(ChatNoContentItem)
 	c.StringItem = list.NewStringItem("No message content").
-		WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage)
+		WithFocusStyles(&t.Chat.Message.NoContent, &t.Chat.Message.NoContent)
 	return c
 }
 
@@ -88,7 +88,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
 	switch msg.Role {
 	case message.User:
 		item := list.NewMarkdownItem(msg.Content().String()).
-			WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred)
+			WithFocusStyles(&t.Chat.Message.UserFocused, &t.Chat.Message.UserBlurred)
 		item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
 		// TODO: Add attachments
 		c.item = item
@@ -102,13 +102,13 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
 		reasoningThinking := strings.TrimSpace(reasoningContent.Thinking)
 
 		if finished && content == "" && finishedData.Reason == message.FinishReasonError {
-			tag := t.Chat.ErrorTag.Render("ERROR")
-			title := t.Chat.ErrorTitle.Render(finishedData.Message)
-			details := t.Chat.ErrorDetails.Render(finishedData.Details)
+			tag := t.Chat.Message.ErrorTag.Render("ERROR")
+			title := t.Chat.Message.ErrorTitle.Render(finishedData.Message)
+			details := t.Chat.Message.ErrorDetails.Render(finishedData.Details)
 			errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
 
 			item := list.NewStringItem(errContent).
-				WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
+				WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
 
 			c.item = item
 
@@ -136,7 +136,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
 		}
 
 		item := list.NewMarkdownItem(strings.Join(parts, "\n")).
-			WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
+			WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
 		item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
 
 		c.item = item
@@ -234,12 +234,10 @@ func (m *Chat) PrependItem(item list.Item) {
 	m.list.PrependItem(item)
 }
 
-// AppendMessage appends a new message item to the chat list.
-func (m *Chat) AppendMessage(msg message.Message) {
-	if msg.ID == "" {
-		m.AppendItem(NewChatNoContentItem(m.com.Styles))
-	} else {
-		m.AppendItem(NewChatMessageItem(m.com.Styles, msg))
+// AppendMessages appends a new message item to the chat list.
+func (m *Chat) AppendMessages(msgs ...MessageItem) {
+	for _, msg := range msgs {
+		m.AppendItem(msg)
 	}
 }
 

internal/ui/model/items.go 🔗

@@ -0,0 +1,753 @@
+package model
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"charm.land/lipgloss/v2"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/x/ansi"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/ui/toolrender"
+)
+
+// Identifiable is an interface for items that can provide a unique identifier.
+type Identifiable interface {
+	ID() string
+}
+
+// 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 {
+	list.Item
+	list.Focusable
+	list.Highlightable
+	Identifiable
+}
+
+// MessageContentItem represents rendered message content (text, markdown, errors, etc).
+type MessageContentItem struct {
+	list.BaseFocusable
+	list.BaseHighlightable
+	id         string
+	content    string
+	isMarkdown bool
+	maxWidth   int
+	cache      map[int]string // Cache for rendered content at different widths
+	sty        *styles.Styles
+}
+
+// NewMessageContentItem creates a new message content item.
+func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
+	m := &MessageContentItem{
+		id:         id,
+		content:    content,
+		isMarkdown: isMarkdown,
+		maxWidth:   120,
+		cache:      make(map[int]string),
+		sty:        sty,
+	}
+	m.InitHighlight()
+	return m
+}
+
+// ID implements Identifiable.
+func (m *MessageContentItem) ID() string {
+	return m.id
+}
+
+// Height implements list.Item.
+func (m *MessageContentItem) Height(width int) int {
+	// Calculate content width accounting for frame size
+	contentWidth := width
+	if style := m.CurrentStyle(); style != nil {
+		contentWidth -= style.GetHorizontalFrameSize()
+	}
+
+	rendered := m.render(contentWidth)
+
+	// Apply focus/blur styling if configured to get accurate height
+	if style := m.CurrentStyle(); style != nil {
+		rendered = style.Render(rendered)
+	}
+
+	return strings.Count(rendered, "\n") + 1
+}
+
+// Draw implements list.Item.
+func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	width := area.Dx()
+	height := area.Dy()
+
+	// Calculate content width accounting for frame size
+	contentWidth := width
+	style := m.CurrentStyle()
+	if style != nil {
+		contentWidth -= style.GetHorizontalFrameSize()
+	}
+
+	rendered := m.render(contentWidth)
+
+	// Apply focus/blur styling if configured
+	if style != nil {
+		rendered = style.Render(rendered)
+	}
+
+	// Create temp buffer to draw content with highlighting
+	tempBuf := uv.NewScreenBuffer(width, height)
+
+	// Draw the rendered content to temp buffer
+	styled := uv.NewStyledString(rendered)
+	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+	// Apply highlighting if active
+	m.ApplyHighlight(&tempBuf, width, height, style)
+
+	// Copy temp buffer to actual screen at the target area
+	tempBuf.Draw(scr, area)
+}
+
+// render renders the content at the given width, using cache if available.
+func (m *MessageContentItem) render(width int) string {
+	// Cap width to maxWidth for markdown
+	cappedWidth := width
+	if m.isMarkdown {
+		cappedWidth = min(width, m.maxWidth)
+	}
+
+	// Check cache first
+	if cached, ok := m.cache[cappedWidth]; ok {
+		return cached
+	}
+
+	// Not cached - render now
+	var rendered string
+	if m.isMarkdown {
+		renderer := common.MarkdownRenderer(m.sty, cappedWidth)
+		result, err := renderer.Render(m.content)
+		if err != nil {
+			rendered = m.content
+		} else {
+			rendered = strings.TrimSuffix(result, "\n")
+		}
+	} else {
+		rendered = m.content
+	}
+
+	// Cache the result
+	m.cache[cappedWidth] = rendered
+	return rendered
+}
+
+// SetHighlight implements list.Highlightable and extends BaseHighlightable.
+func (m *MessageContentItem) SetHighlight(startLine, startCol, endLine, endCol int) {
+	m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
+	// Clear cache when highlight changes
+	m.cache = make(map[int]string)
+}
+
+// ToolCallItem represents a rendered tool call with its header and content.
+type ToolCallItem struct {
+	list.BaseFocusable
+	list.BaseHighlightable
+	id         string
+	toolCall   message.ToolCall
+	toolResult message.ToolResult
+	cancelled  bool
+	isNested   bool
+	maxWidth   int
+	cache      map[int]cachedToolRender // Cache for rendered content at different widths
+	cacheKey   string                   // Key to invalidate cache when content changes
+	sty        *styles.Styles
+}
+
+// cachedToolRender stores both the rendered string and its height.
+type cachedToolRender struct {
+	content string
+	height  int
+}
+
+// NewToolCallItem creates a new tool call item.
+func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem {
+	t := &ToolCallItem{
+		id:         id,
+		toolCall:   toolCall,
+		toolResult: toolResult,
+		cancelled:  cancelled,
+		isNested:   isNested,
+		maxWidth:   120,
+		cache:      make(map[int]cachedToolRender),
+		cacheKey:   generateCacheKey(toolCall, toolResult, cancelled),
+		sty:        sty,
+	}
+	t.InitHighlight()
+	return t
+}
+
+// generateCacheKey creates a key that changes when tool call content changes.
+func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string {
+	// Simple key based on result state - when result arrives or changes, key changes
+	return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled)
+}
+
+// ID implements Identifiable.
+func (t *ToolCallItem) ID() string {
+	return t.id
+}
+
+// Height implements list.Item.
+func (t *ToolCallItem) Height(width int) int {
+	// Calculate content width accounting for frame size
+	contentWidth := width
+	frameSize := 0
+	if style := t.CurrentStyle(); style != nil {
+		frameSize = style.GetHorizontalFrameSize()
+		contentWidth -= frameSize
+	}
+
+	cached := t.renderCached(contentWidth)
+
+	// Add frame size to height if needed
+	height := cached.height
+	if frameSize > 0 {
+		// Frame can add to height (borders, padding)
+		if style := t.CurrentStyle(); style != nil {
+			// Quick render to get accurate height with frame
+			rendered := style.Render(cached.content)
+			height = strings.Count(rendered, "\n") + 1
+		}
+	}
+
+	return height
+}
+
+// Draw implements list.Item.
+func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	width := area.Dx()
+	height := area.Dy()
+
+	// Calculate content width accounting for frame size
+	contentWidth := width
+	style := t.CurrentStyle()
+	if style != nil {
+		contentWidth -= style.GetHorizontalFrameSize()
+	}
+
+	cached := t.renderCached(contentWidth)
+	rendered := cached.content
+
+	if style != nil {
+		rendered = style.Render(rendered)
+	}
+
+	tempBuf := uv.NewScreenBuffer(width, height)
+	styled := uv.NewStyledString(rendered)
+	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+	t.ApplyHighlight(&tempBuf, width, height, style)
+	tempBuf.Draw(scr, area)
+}
+
+// renderCached renders the tool call at the given width with caching.
+func (t *ToolCallItem) renderCached(width int) cachedToolRender {
+	cappedWidth := min(width, t.maxWidth)
+
+	// Check if we have a valid cache entry
+	if cached, ok := t.cache[cappedWidth]; ok {
+		return cached
+	}
+
+	// Render the tool call
+	ctx := &toolrender.RenderContext{
+		Call:      t.toolCall,
+		Result:    t.toolResult,
+		Cancelled: t.cancelled,
+		IsNested:  t.isNested,
+		Width:     cappedWidth,
+		Styles:    t.sty,
+	}
+
+	rendered := toolrender.Render(ctx)
+	height := strings.Count(rendered, "\n") + 1
+
+	cached := cachedToolRender{
+		content: rendered,
+		height:  height,
+	}
+	t.cache[cappedWidth] = cached
+	return cached
+}
+
+// SetHighlight implements list.Highlightable.
+func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
+	t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
+	// Clear cache when highlight changes
+	t.cache = make(map[int]cachedToolRender)
+}
+
+// UpdateResult updates the tool result and invalidates the cache if needed.
+func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
+	newKey := generateCacheKey(t.toolCall, result, t.cancelled)
+	if newKey != t.cacheKey {
+		t.toolResult = result
+		t.cacheKey = newKey
+		t.cache = make(map[int]cachedToolRender)
+	}
+}
+
+// AttachmentItem represents a file attachment in a user message.
+type AttachmentItem struct {
+	list.BaseFocusable
+	list.BaseHighlightable
+	id       string
+	filename string
+	path     string
+	sty      *styles.Styles
+}
+
+// NewAttachmentItem creates a new attachment item.
+func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem {
+	a := &AttachmentItem{
+		id:       id,
+		filename: filename,
+		path:     path,
+		sty:      sty,
+	}
+	a.InitHighlight()
+	return a
+}
+
+// ID implements Identifiable.
+func (a *AttachmentItem) ID() string {
+	return a.id
+}
+
+// Height implements list.Item.
+func (a *AttachmentItem) Height(width int) int {
+	return 1
+}
+
+// Draw implements list.Item.
+func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	width := area.Dx()
+	height := area.Dy()
+
+	// Calculate content width accounting for frame size
+	contentWidth := width
+	style := a.CurrentStyle()
+	if style != nil {
+		contentWidth -= style.GetHorizontalFrameSize()
+	}
+
+	const maxFilenameWidth = 10
+	content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
+		" %s %s ",
+		styles.DocumentIcon,
+		ansi.Truncate(a.filename, maxFilenameWidth, "..."),
+	))
+
+	if style != nil {
+		content = style.Render(content)
+	}
+
+	tempBuf := uv.NewScreenBuffer(width, height)
+	styled := uv.NewStyledString(content)
+	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+	a.ApplyHighlight(&tempBuf, width, height, style)
+	tempBuf.Draw(scr, area)
+}
+
+// ThinkingItem represents thinking/reasoning content in assistant messages.
+type ThinkingItem struct {
+	list.BaseFocusable
+	list.BaseHighlightable
+	id       string
+	thinking string
+	duration time.Duration
+	finished bool
+	maxWidth int
+	cache    map[int]string
+	sty      *styles.Styles
+}
+
+// NewThinkingItem creates a new thinking item.
+func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem {
+	t := &ThinkingItem{
+		id:       id,
+		thinking: thinking,
+		duration: duration,
+		finished: finished,
+		maxWidth: 120,
+		cache:    make(map[int]string),
+		sty:      sty,
+	}
+	t.InitHighlight()
+	return t
+}
+
+// ID implements Identifiable.
+func (t *ThinkingItem) ID() string {
+	return t.id
+}
+
+// Height implements list.Item.
+func (t *ThinkingItem) Height(width int) int {
+	// Calculate content width accounting for frame size
+	contentWidth := width
+	if style := t.CurrentStyle(); style != nil {
+		contentWidth -= style.GetHorizontalFrameSize()
+	}
+
+	rendered := t.render(contentWidth)
+	return strings.Count(rendered, "\n") + 1
+}
+
+// Draw implements list.Item.
+func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	width := area.Dx()
+	height := area.Dy()
+
+	// Calculate content width accounting for frame size
+	contentWidth := width
+	style := t.CurrentStyle()
+	if style != nil {
+		contentWidth -= style.GetHorizontalFrameSize()
+	}
+
+	rendered := t.render(contentWidth)
+
+	if style != nil {
+		rendered = style.Render(rendered)
+	}
+
+	tempBuf := uv.NewScreenBuffer(width, height)
+	styled := uv.NewStyledString(rendered)
+	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+	t.ApplyHighlight(&tempBuf, width, height, style)
+	tempBuf.Draw(scr, area)
+}
+
+// render renders the thinking content.
+func (t *ThinkingItem) render(width int) string {
+	cappedWidth := min(width, t.maxWidth)
+
+	if cached, ok := t.cache[cappedWidth]; ok {
+		return cached
+	}
+
+	renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
+	rendered, err := renderer.Render(t.thinking)
+	if err != nil {
+		// Fallback to line-by-line rendering
+		lines := strings.Split(t.thinking, "\n")
+		var content strings.Builder
+		lineStyle := t.sty.PanelMuted
+		for i, line := range lines {
+			if line == "" {
+				continue
+			}
+			content.WriteString(lineStyle.Width(cappedWidth).Render(line))
+			if i < len(lines)-1 {
+				content.WriteString("\n")
+			}
+		}
+		rendered = content.String()
+	}
+
+	fullContent := strings.TrimSpace(rendered)
+
+	// Add footer if finished
+	if t.finished && t.duration > 0 {
+		footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String()))
+		fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer)
+	}
+
+	result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
+
+	t.cache[cappedWidth] = result
+	return result
+}
+
+// SetHighlight implements list.Highlightable.
+func (t *ThinkingItem) SetHighlight(startLine, startCol, endLine, endCol int) {
+	t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
+	t.cache = make(map[int]string)
+}
+
+// SectionHeaderItem represents a section header (e.g., assistant info).
+type SectionHeaderItem struct {
+	list.BaseFocusable
+	list.BaseHighlightable
+	id              string
+	modelName       string
+	duration        time.Duration
+	isSectionHeader bool
+	sty             *styles.Styles
+}
+
+// NewSectionHeaderItem creates a new section header item.
+func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem {
+	s := &SectionHeaderItem{
+		id:              id,
+		modelName:       modelName,
+		duration:        duration,
+		isSectionHeader: true,
+		sty:             sty,
+	}
+	s.InitHighlight()
+	return s
+}
+
+// ID implements Identifiable.
+func (s *SectionHeaderItem) ID() string {
+	return s.id
+}
+
+// IsSectionHeader returns true if this is a section header.
+func (s *SectionHeaderItem) IsSectionHeader() bool {
+	return s.isSectionHeader
+}
+
+// Height implements list.Item.
+func (s *SectionHeaderItem) Height(width int) int {
+	return 1
+}
+
+// Draw implements list.Item.
+func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	width := area.Dx()
+	height := area.Dy()
+
+	// Calculate content width accounting for frame size
+	contentWidth := width
+	style := s.CurrentStyle()
+	if style != nil {
+		contentWidth -= style.GetHorizontalFrameSize()
+	}
+
+	infoMsg := s.sty.Subtle.Render(s.duration.String())
+	icon := s.sty.Subtle.Render(styles.ModelIcon)
+	modelFormatted := s.sty.Muted.Render(s.modelName)
+	content := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
+
+	content = s.sty.Chat.Message.SectionHeader.Render(content)
+
+	if style != nil {
+		content = style.Render(content)
+	}
+
+	tempBuf := uv.NewScreenBuffer(width, height)
+	styled := uv.NewStyledString(content)
+	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+	s.ApplyHighlight(&tempBuf, width, height, style)
+	tempBuf.Draw(scr, area)
+}
+
+// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
+// all parts of the message as [MessageItem]s.
+//
+// For assistant messages with tool calls, pass a toolResults map to link results.
+// Use BuildToolResultMap to create this map from all messages in a session.
+func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
+	sty := styles.DefaultStyles()
+	var items []MessageItem
+
+	// Skip tool result messages - they're displayed inline with tool calls
+	if msg.Role == message.Tool {
+		return items
+	}
+
+	// Create base styles for the message
+	var focusStyle, blurStyle lipgloss.Style
+	if msg.Role == message.User {
+		focusStyle = sty.Chat.Message.UserFocused
+		blurStyle = sty.Chat.Message.UserBlurred
+	} else {
+		focusStyle = sty.Chat.Message.AssistantFocused
+		blurStyle = sty.Chat.Message.AssistantBlurred
+	}
+
+	// Process user messages
+	if msg.Role == message.User {
+		// Add main text content
+		content := msg.Content().String()
+		if content != "" {
+			item := NewMessageContentItem(
+				fmt.Sprintf("%s-content", msg.ID),
+				content,
+				true, // User messages are markdown
+				&sty,
+			)
+			item.SetFocusStyles(&focusStyle, &blurStyle)
+			items = append(items, item)
+		}
+
+		// Add attachments
+		for i, attachment := range msg.BinaryContent() {
+			filename := filepath.Base(attachment.Path)
+			item := NewAttachmentItem(
+				fmt.Sprintf("%s-attachment-%d", msg.ID, i),
+				filename,
+				attachment.Path,
+				&sty,
+			)
+			item.SetFocusStyles(&focusStyle, &blurStyle)
+			items = append(items, item)
+		}
+
+		return items
+	}
+
+	// Process assistant messages
+	if msg.Role == message.Assistant {
+		// Check if we need to add a section header
+		finishData := msg.FinishPart()
+		if finishData != nil && msg.Model != "" {
+			model := config.Get().GetModel(msg.Provider, msg.Model)
+			modelName := "Unknown Model"
+			if model != nil {
+				modelName = model.Name
+			}
+
+			// Calculate duration (this would need the last user message time)
+			duration := time.Duration(0)
+			if finishData.Time > 0 {
+				duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second
+			}
+
+			header := NewSectionHeaderItem(
+				fmt.Sprintf("%s-header", msg.ID),
+				modelName,
+				duration,
+				&sty,
+			)
+			items = append(items, header)
+		}
+
+		// Add thinking content if present
+		reasoning := msg.ReasoningContent()
+		if strings.TrimSpace(reasoning.Thinking) != "" {
+			duration := time.Duration(0)
+			if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 {
+				duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second
+			}
+
+			item := NewThinkingItem(
+				fmt.Sprintf("%s-thinking", msg.ID),
+				reasoning.Thinking,
+				duration,
+				reasoning.FinishedAt > 0,
+				&sty,
+			)
+			item.SetFocusStyles(&focusStyle, &blurStyle)
+			items = append(items, item)
+		}
+
+		// Add main text content
+		content := msg.Content().String()
+		finished := msg.IsFinished()
+
+		// Handle special finish states
+		if finished && content == "" && finishData != nil {
+			switch finishData.Reason {
+			case message.FinishReasonEndTurn:
+				// No content to show
+			case message.FinishReasonCanceled:
+				item := NewMessageContentItem(
+					fmt.Sprintf("%s-content", msg.ID),
+					"*Canceled*",
+					true,
+					&sty,
+				)
+				item.SetFocusStyles(&focusStyle, &blurStyle)
+				items = append(items, item)
+			case message.FinishReasonError:
+				// Render error
+				errTag := sty.Chat.Message.ErrorTag.Render("ERROR")
+				truncated := ansi.Truncate(finishData.Message, 100, "...")
+				title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated))
+				details := sty.Chat.Message.ErrorDetails.Render(finishData.Details)
+				errorContent := fmt.Sprintf("%s\n\n%s", title, details)
+
+				item := NewMessageContentItem(
+					fmt.Sprintf("%s-error", msg.ID),
+					errorContent,
+					false,
+					&sty,
+				)
+				item.SetFocusStyles(&focusStyle, &blurStyle)
+				items = append(items, item)
+			}
+		} else if content != "" {
+			item := NewMessageContentItem(
+				fmt.Sprintf("%s-content", msg.ID),
+				content,
+				true, // Assistant messages are markdown
+				&sty,
+			)
+			item.SetFocusStyles(&focusStyle, &blurStyle)
+			items = append(items, item)
+		}
+
+		// Add tool calls
+		toolCalls := msg.ToolCalls()
+
+		// Use passed-in tool results map (if nil, use empty map)
+		resultMap := toolResults
+		if resultMap == nil {
+			resultMap = make(map[string]message.ToolResult)
+		}
+
+		for _, tc := range toolCalls {
+			result, hasResult := resultMap[tc.ID]
+			if !hasResult {
+				result = message.ToolResult{}
+			}
+
+			item := NewToolCallItem(
+				fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID),
+				tc,
+				result,
+				false, // cancelled state would need to be tracked separately
+				false, // nested state would be detected from tool results
+				&sty,
+			)
+
+			// Tool calls use muted style with optional focus border
+			item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
+
+			items = append(items, item)
+		}
+
+		return items
+	}
+
+	return items
+}
+
+// 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.
+func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
+	resultMap := make(map[string]message.ToolResult)
+	for _, msg := range messages {
+		if msg.Role == message.Tool {
+			for _, result := range msg.ToolResults() {
+				if result.ToolCallID != "" {
+					resultMap[result.ToolCallID] = result
+				}
+			}
+		}
+	}
+	return resultMap
+}

internal/ui/model/ui.go 🔗

@@ -18,6 +18,7 @@ import (
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -195,9 +196,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.session = &msg.sess
 		// Load the last 20 messages from this session.
 		msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
-		for _, message := range msgs {
-			m.chat.AppendMessage(message)
+
+		// Build tool result map to link tool calls with their results
+		msgPtrs := make([]*message.Message, len(msgs))
+		for i := range msgs {
+			msgPtrs[i] = &msgs[i]
+		}
+		toolResultMap := BuildToolResultMap(msgPtrs)
+
+		// Add messages to chat with linked tool results
+		items := make([]MessageItem, 0, len(msgs)*2)
+		for _, msg := range msgPtrs {
+			items = append(items, GetMessageItems(msg, toolResultMap)...)
 		}
+		m.chat.AppendMessages(items...)
+
 		// Notify that session loading is done to scroll to bottom. This is
 		// needed because we need to draw the chat list first before we can
 		// scroll to bottom.

internal/ui/styles/styles.go 🔗

@@ -9,6 +9,7 @@ import (
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/alecthomas/chroma/v2"
 	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
 	"github.com/charmbracelet/glamour/v2/ansi"
 	"github.com/charmbracelet/x/exp/charmtone"
@@ -117,6 +118,7 @@ type Styles struct {
 
 	// Background
 	Background color.Color
+
 	// Logo
 	LogoFieldColor   color.Color
 	LogoTitleColorA  color.Color
@@ -124,6 +126,31 @@ type Styles struct {
 	LogoCharmColor   color.Color
 	LogoVersionColor color.Color
 
+	// Colors - semantic colors for tool rendering.
+	Primary       color.Color
+	Secondary     color.Color
+	Tertiary      color.Color
+	BgBase        color.Color
+	BgBaseLighter color.Color
+	BgSubtle      color.Color
+	BgOverlay     color.Color
+	FgBase        color.Color
+	FgMuted       color.Color
+	FgHalfMuted   color.Color
+	FgSubtle      color.Color
+	Border        color.Color
+	BorderColor   color.Color // Border focus color
+	Warning       color.Color
+	Info          color.Color
+	White         color.Color
+	BlueLight     color.Color
+	Blue          color.Color
+	Green         color.Color
+	GreenDark     color.Color
+	Red           color.Color
+	RedDark       color.Color
+	Yellow        color.Color
+
 	// Section Title
 	Section struct {
 		Title lipgloss.Style
@@ -154,16 +181,120 @@ type Styles struct {
 
 	// Chat
 	Chat struct {
-		UserMessageBlurred      lipgloss.Style
-		UserMessageFocused      lipgloss.Style
-		AssistantMessageBlurred lipgloss.Style
-		AssistantMessageFocused lipgloss.Style
-		NoContentMessage        lipgloss.Style
-		ThinkingMessage         lipgloss.Style
-
-		ErrorTag     lipgloss.Style
-		ErrorTitle   lipgloss.Style
-		ErrorDetails lipgloss.Style
+		// Message item styles
+		Message struct {
+			UserBlurred      lipgloss.Style
+			UserFocused      lipgloss.Style
+			AssistantBlurred lipgloss.Style
+			AssistantFocused lipgloss.Style
+			NoContent        lipgloss.Style
+			Thinking         lipgloss.Style
+			ErrorTag         lipgloss.Style
+			ErrorTitle       lipgloss.Style
+			ErrorDetails     lipgloss.Style
+			Attachment       lipgloss.Style
+			ToolCallFocused  lipgloss.Style
+			ToolCallBlurred  lipgloss.Style
+			ThinkingFooter   lipgloss.Style
+			SectionHeader    lipgloss.Style
+		}
+	}
+
+	// Tool - styles for tool call rendering
+	Tool struct {
+		// Icon styles with tool status
+		IconPending   lipgloss.Style // Pending operation icon
+		IconSuccess   lipgloss.Style // Successful operation icon
+		IconError     lipgloss.Style // Error operation icon
+		IconCancelled lipgloss.Style // Cancelled operation icon
+
+		// Tool name styles
+		NameNormal lipgloss.Style // Normal tool name
+		NameNested lipgloss.Style // Nested tool name
+
+		// Parameter list styles
+		ParamMain lipgloss.Style // Main parameter
+		ParamKey  lipgloss.Style // Parameter keys
+
+		// Content rendering styles
+		ContentLine       lipgloss.Style // Individual content line with background and width
+		ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
+		ContentCodeLine   lipgloss.Style // Code line with background and width
+		ContentCodeBg     color.Color    // Background color for syntax highlighting
+		BodyPadding       lipgloss.Style // Body content padding (PaddingLeft(2))
+
+		// Deprecated - kept for backward compatibility
+		ContentBg         lipgloss.Style // Content background
+		ContentText       lipgloss.Style // Content text
+		ContentLineNumber lipgloss.Style // Line numbers in code
+
+		// State message styles
+		StateWaiting   lipgloss.Style // "Waiting for tool response..."
+		StateCancelled lipgloss.Style // "Canceled."
+
+		// Error styles
+		ErrorTag     lipgloss.Style // ERROR tag
+		ErrorMessage lipgloss.Style // Error message text
+
+		// Diff styles
+		DiffTruncation lipgloss.Style // Diff truncation message with padding
+
+		// Multi-edit note styles
+		NoteTag     lipgloss.Style // NOTE tag (yellow background)
+		NoteMessage lipgloss.Style // Note message text
+
+		// Job header styles (for bash jobs)
+		JobIconPending lipgloss.Style // Pending job icon (green dark)
+		JobIconError   lipgloss.Style // Error job icon (red dark)
+		JobIconSuccess lipgloss.Style // Success job icon (green)
+		JobToolName    lipgloss.Style // Job tool name "Bash" (blue)
+		JobAction      lipgloss.Style // Action text (Start, Output, Kill)
+		JobPID         lipgloss.Style // PID text
+		JobDescription lipgloss.Style // Description text
+
+		// Agent task styles
+		AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
+		AgentPrompt  lipgloss.Style // Agent prompt text
+	}
+}
+
+// ChromaTheme converts the current markdown chroma styles to a chroma
+// StyleEntries map.
+func (s *Styles) ChromaTheme() chroma.StyleEntries {
+	rules := s.Markdown.CodeBlock
+
+	return chroma.StyleEntries{
+		chroma.Text:                chromaStyle(rules.Chroma.Text),
+		chroma.Error:               chromaStyle(rules.Chroma.Error),
+		chroma.Comment:             chromaStyle(rules.Chroma.Comment),
+		chroma.CommentPreproc:      chromaStyle(rules.Chroma.CommentPreproc),
+		chroma.Keyword:             chromaStyle(rules.Chroma.Keyword),
+		chroma.KeywordReserved:     chromaStyle(rules.Chroma.KeywordReserved),
+		chroma.KeywordNamespace:    chromaStyle(rules.Chroma.KeywordNamespace),
+		chroma.KeywordType:         chromaStyle(rules.Chroma.KeywordType),
+		chroma.Operator:            chromaStyle(rules.Chroma.Operator),
+		chroma.Punctuation:         chromaStyle(rules.Chroma.Punctuation),
+		chroma.Name:                chromaStyle(rules.Chroma.Name),
+		chroma.NameBuiltin:         chromaStyle(rules.Chroma.NameBuiltin),
+		chroma.NameTag:             chromaStyle(rules.Chroma.NameTag),
+		chroma.NameAttribute:       chromaStyle(rules.Chroma.NameAttribute),
+		chroma.NameClass:           chromaStyle(rules.Chroma.NameClass),
+		chroma.NameConstant:        chromaStyle(rules.Chroma.NameConstant),
+		chroma.NameDecorator:       chromaStyle(rules.Chroma.NameDecorator),
+		chroma.NameException:       chromaStyle(rules.Chroma.NameException),
+		chroma.NameFunction:        chromaStyle(rules.Chroma.NameFunction),
+		chroma.NameOther:           chromaStyle(rules.Chroma.NameOther),
+		chroma.Literal:             chromaStyle(rules.Chroma.Literal),
+		chroma.LiteralNumber:       chromaStyle(rules.Chroma.LiteralNumber),
+		chroma.LiteralDate:         chromaStyle(rules.Chroma.LiteralDate),
+		chroma.LiteralString:       chromaStyle(rules.Chroma.LiteralString),
+		chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
+		chroma.GenericDeleted:      chromaStyle(rules.Chroma.GenericDeleted),
+		chroma.GenericEmph:         chromaStyle(rules.Chroma.GenericEmph),
+		chroma.GenericInserted:     chromaStyle(rules.Chroma.GenericInserted),
+		chroma.GenericStrong:       chromaStyle(rules.Chroma.GenericStrong),
+		chroma.GenericSubheading:   chromaStyle(rules.Chroma.GenericSubheading),
+		chroma.Background:          chromaStyle(rules.Chroma.Background),
 	}
 }
 
@@ -202,6 +333,7 @@ func DefaultStyles() Styles {
 		blue      = charmtone.Malibu
 
 		// yellow = charmtone.Mustard
+		yellow = charmtone.Mustard
 		// citron = charmtone.Citron
 
 		green     = charmtone.Julep
@@ -222,6 +354,31 @@ func DefaultStyles() Styles {
 
 	s.Background = bgBase
 
+	// Populate color fields
+	s.Primary = primary
+	s.Secondary = secondary
+	s.Tertiary = tertiary
+	s.BgBase = bgBase
+	s.BgBaseLighter = bgBaseLighter
+	s.BgSubtle = bgSubtle
+	s.BgOverlay = bgOverlay
+	s.FgBase = fgBase
+	s.FgMuted = fgMuted
+	s.FgHalfMuted = fgHalfMuted
+	s.FgSubtle = fgSubtle
+	s.Border = border
+	s.BorderColor = borderFocus
+	s.Warning = warning
+	s.Info = info
+	s.White = white
+	s.BlueLight = blueLight
+	s.Blue = blue
+	s.Green = green
+	s.GreenDark = greenDark
+	s.Red = red
+	s.RedDark = redDark
+	s.Yellow = yellow
+
 	s.TextInput = textinput.Styles{
 		Focused: textinput.StyleState{
 			Text:        base,
@@ -580,6 +737,54 @@ func DefaultStyles() Styles {
 	s.ToolCallCancelled = s.Muted.SetString(ToolPending)
 	s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
 
+	// Tool rendering styles
+	s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending)
+	s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess)
+	s.Tool.IconError = base.Foreground(redDark).SetString(ToolError)
+	s.Tool.IconCancelled = s.Muted.SetString(ToolPending)
+
+	s.Tool.NameNormal = base.Foreground(blue)
+	s.Tool.NameNested = base.Foreground(fgHalfMuted)
+
+	s.Tool.ParamMain = s.Subtle
+	s.Tool.ParamKey = s.Subtle
+
+	// Content rendering - prepared styles that accept width parameter
+	s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
+	s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
+	s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
+	s.Tool.ContentCodeBg = bgBase
+	s.Tool.BodyPadding = base.PaddingLeft(2)
+
+	// Deprecated - kept for backward compatibility
+	s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)
+	s.Tool.ContentText = s.Muted
+	s.Tool.ContentLineNumber = s.Subtle
+
+	s.Tool.StateWaiting = base.Foreground(fgSubtle)
+	s.Tool.StateCancelled = base.Foreground(fgSubtle)
+
+	s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white)
+	s.Tool.ErrorMessage = base.Foreground(fgHalfMuted)
+
+	// Diff and multi-edit styles
+	s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2)
+	s.Tool.NoteTag = base.Padding(0, 1).Background(yellow).Foreground(white)
+	s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
+
+	// Job header styles
+	s.Tool.JobIconPending = base.Foreground(greenDark)
+	s.Tool.JobIconError = base.Foreground(redDark)
+	s.Tool.JobIconSuccess = base.Foreground(green)
+	s.Tool.JobToolName = base.Foreground(blue)
+	s.Tool.JobAction = base.Foreground(fgHalfMuted)
+	s.Tool.JobPID = s.Subtle
+	s.Tool.JobDescription = s.Subtle
+
+	// Agent task styles
+	s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
+	s.Tool.AgentPrompt = s.Muted
+
 	// Buttons
 	s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
 	s.ButtonBlur = s.Base.Background(bgSubtle)
@@ -633,19 +838,29 @@ func DefaultStyles() Styles {
 		Left: "▌",
 	}
 
-	s.Chat.NoContentMessage = lipgloss.NewStyle().Foreground(fgBase)
-	s.Chat.UserMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+	s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase)
+	s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
 		BorderForeground(primary).BorderStyle(normalBorder)
-	s.Chat.UserMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+	s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
 		BorderForeground(primary).BorderStyle(messageFocussedBorder)
-	s.Chat.AssistantMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(2)
-	s.Chat.AssistantMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+	s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2)
+	s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
 		BorderForeground(greenDark).BorderStyle(messageFocussedBorder)
-	s.Chat.ThinkingMessage = lipgloss.NewStyle().MaxHeight(10)
-	s.Chat.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
+	s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10)
+	s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
 		Background(red).Foreground(white)
-	s.Chat.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
-	s.Chat.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
+	s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
+	s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
+
+	// Message item styles
+	s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle)
+	s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
+		BorderStyle(messageFocussedBorder).
+		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)
 
 	// Text selection.
 	s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
@@ -657,3 +872,36 @@ func DefaultStyles() Styles {
 func boolPtr(b bool) *bool       { return &b }
 func stringPtr(s string) *string { return &s }
 func uintPtr(u uint) *uint       { return &u }
+func chromaStyle(style ansi.StylePrimitive) string {
+	var s string
+
+	if style.Color != nil {
+		s = *style.Color
+	}
+	if style.BackgroundColor != nil {
+		if s != "" {
+			s += " "
+		}
+		s += "bg:" + *style.BackgroundColor
+	}
+	if style.Italic != nil && *style.Italic {
+		if s != "" {
+			s += " "
+		}
+		s += "italic"
+	}
+	if style.Bold != nil && *style.Bold {
+		if s != "" {
+			s += " "
+		}
+		s += "bold"
+	}
+	if style.Underline != nil && *style.Underline {
+		if s != "" {
+			s += " "
+		}
+		s += "underline"
+	}
+
+	return s
+}

internal/ui/toolrender/render.go 🔗

@@ -0,0 +1,889 @@
+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) + " "
+	}
+}