wip: initial tools rendering

Kujtim Hoxha created

Change summary

internal/ui/chat/assistant.go    |  181 +++++
internal/ui/chat/chat.go         |   25 
internal/ui/chat/section.go      |   56 +
internal/ui/chat/tool_base.go    |  610 ++++++++++++++++++
internal/ui/chat/tool_items.go   | 1100 ++++++++++++++++++++++++++++++++++
internal/ui/chat/user.go         |  111 +++
internal/ui/common/markdown.go   |   11 
internal/ui/list/item.go         |    8 
internal/ui/list/list.go         |   19 
internal/ui/model/items.go       |  548 ----------------
internal/ui/model/ui.go          |  193 +++++
internal/ui/styles/styles.go     |  195 +++++
internal/ui/toolrender/render.go |  889 ---------------------------
13 files changed, 2,470 insertions(+), 1,476 deletions(-)

Detailed changes

internal/ui/chat/assistant.go 🔗

@@ -0,0 +1,181 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+const maxCollapsedThinkingHeight = 10
+
+// AssistantMessageItem represents an assistant message that can be displayed
+// in the chat UI.
+type AssistantMessageItem struct {
+	id                string
+	content           string
+	thinking          string
+	finished          bool
+	finish            message.Finish
+	sty               *styles.Styles
+	thinkingExpanded  bool
+	thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
+}
+
+// NewAssistantMessage creates a new assistant message item.
+func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, sty *styles.Styles) *AssistantMessageItem {
+	return &AssistantMessageItem{
+		id:       id,
+		content:  content,
+		thinking: thinking,
+		finished: finished,
+		finish:   finish,
+		sty:      sty,
+	}
+}
+
+// ID implements Identifiable.
+func (m *AssistantMessageItem) ID() string {
+	return m.id
+}
+
+// FocusStyle returns the focus style.
+func (m *AssistantMessageItem) FocusStyle() lipgloss.Style {
+	return m.sty.Chat.Message.AssistantFocused
+}
+
+// BlurStyle returns the blur style.
+func (m *AssistantMessageItem) BlurStyle() lipgloss.Style {
+	return m.sty.Chat.Message.AssistantBlurred
+}
+
+// HighlightStyle returns the highlight style.
+func (m *AssistantMessageItem) HighlightStyle() lipgloss.Style {
+	return m.sty.TextSelection
+}
+
+// Render implements list.Item.
+func (m *AssistantMessageItem) Render(width int) string {
+	cappedWidth := min(width, maxTextWidth)
+	content := strings.TrimSpace(m.content)
+	thinking := strings.TrimSpace(m.thinking)
+
+	// Handle empty finished messages.
+	if m.finished && content == "" {
+		switch m.finish.Reason {
+		case message.FinishReasonEndTurn:
+			return ""
+		case message.FinishReasonCanceled:
+			return m.renderMarkdown("*Canceled*", cappedWidth)
+		case message.FinishReasonError:
+			return m.renderError(cappedWidth)
+		}
+	}
+
+	var parts []string
+
+	// Render thinking content if present.
+	if thinking != "" {
+		parts = append(parts, m.renderThinking(thinking, cappedWidth))
+	}
+
+	// Render main content.
+	if content != "" {
+		if len(parts) > 0 {
+			parts = append(parts, "")
+		}
+		parts = append(parts, m.renderMarkdown(content, cappedWidth))
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, parts...)
+}
+
+// renderMarkdown renders content as markdown.
+func (m *AssistantMessageItem) renderMarkdown(content string, width int) string {
+	renderer := common.MarkdownRenderer(m.sty, width)
+	result, err := renderer.Render(content)
+	if err != nil {
+		return content
+	}
+	return strings.TrimSuffix(result, "\n")
+}
+
+// renderThinking renders the thinking/reasoning content.
+func (m *AssistantMessageItem) renderThinking(thinking string, width int) string {
+	renderer := common.PlainMarkdownRenderer(m.sty, width-2)
+	rendered, err := renderer.Render(thinking)
+	if err != nil {
+		rendered = thinking
+	}
+	rendered = strings.TrimSpace(rendered)
+
+	lines := strings.Split(rendered, "\n")
+	totalLines := len(lines)
+
+	// Collapse if not expanded and exceeds max height.
+	isTruncated := totalLines > maxCollapsedThinkingHeight
+	if !m.thinkingExpanded && isTruncated {
+		lines = lines[totalLines-maxCollapsedThinkingHeight:]
+	}
+
+	// Add hint if truncated and not expanded.
+	if !m.thinkingExpanded && isTruncated {
+		hint := m.sty.Muted.Render(fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight))
+		lines = append([]string{hint}, lines...)
+	}
+
+	thinkingStyle := m.sty.Subtle.Background(m.sty.BgBaseLighter).Width(width)
+	result := thinkingStyle.Render(strings.Join(lines, "\n"))
+
+	// Track the rendered height for click detection.
+	m.thinkingBoxHeight = lipgloss.Height(result)
+
+	return result
+}
+
+// HandleMouseClick implements list.MouseClickable.
+func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	// Only handle left clicks.
+	if btn != ansi.MouseLeft {
+		return false
+	}
+
+	// Check if click is within the thinking box area.
+	if m.thinking != "" && y < m.thinkingBoxHeight {
+		m.thinkingExpanded = !m.thinkingExpanded
+		return true
+	}
+
+	return false
+}
+
+// HandleKeyPress implements list.KeyPressable.
+func (m *AssistantMessageItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
+	// Only handle space key on thinking content.
+	if m.thinking == "" {
+		return false
+	}
+
+	if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
+		// Toggle thinking expansion.
+		m.thinkingExpanded = !m.thinkingExpanded
+		return true
+	}
+
+	return false
+}
+
+// renderError renders an error message.
+func (m *AssistantMessageItem) renderError(width int) string {
+	errTag := m.sty.Chat.Message.ErrorTag.Render("ERROR")
+	truncated := ansi.Truncate(m.finish.Message, width-2-lipgloss.Width(errTag), "...")
+	title := fmt.Sprintf("%s %s", errTag, m.sty.Chat.Message.ErrorTitle.Render(truncated))
+	details := m.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(m.finish.Details)
+	return fmt.Sprintf("%s\n\n%s", title, details)
+}

internal/ui/model/chat.go → internal/ui/chat/chat.go 🔗

@@ -1,11 +1,29 @@
-package model
+// Package chat provides the chat UI components for displaying and managing
+// conversation messages between users and assistants.
+package chat
 
 import (
+	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
+// maxTextWidth is the maximum width text messages can be
+const maxTextWidth = 120
+
+// 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
+	Identifiable
+}
+
 // Chat represents the chat UI model that handles chat interactions and
 // messages.
 type Chat struct {
@@ -159,3 +177,8 @@ func (m *Chat) HandleMouseUp(x, y int) {
 func (m *Chat) HandleMouseDrag(x, y int) {
 	m.list.HandleMouseDrag(x, y)
 }
+
+// HandleKeyPress handles key press events for the currently selected item.
+func (m *Chat) HandleKeyPress(msg tea.KeyPressMsg) bool {
+	return m.list.HandleKeyPress(msg)
+}

internal/ui/chat/section.go 🔗

@@ -0,0 +1,56 @@
+package chat
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/google/uuid"
+)
+
+// SectionItem represents a section separator showing model info and response time.
+type SectionItem struct {
+	id                  string
+	msg                 message.Message
+	lastUserMessageTime time.Time
+	modelName           string
+	sty                 *styles.Styles
+}
+
+// NewSectionItem creates a new section item showing assistant response metadata.
+func NewSectionItem(msg message.Message, lastUserMessageTime time.Time, modelName string, sty *styles.Styles) *SectionItem {
+	return &SectionItem{
+		id:                  uuid.NewString(),
+		msg:                 msg,
+		lastUserMessageTime: lastUserMessageTime,
+		modelName:           modelName,
+		sty:                 sty,
+	}
+}
+
+// ID implements Identifiable.
+func (m *SectionItem) ID() string {
+	return m.id
+}
+
+// Render implements list.Item.
+func (m *SectionItem) Render(width int) string {
+	finishData := m.msg.FinishPart()
+	if finishData == nil {
+		return ""
+	}
+
+	finishTime := time.Unix(finishData.Time, 0)
+	duration := finishTime.Sub(m.lastUserMessageTime)
+
+	icon := m.sty.Chat.Message.SectionIcon.Render(styles.ModelIcon)
+	modelFormatted := m.sty.Chat.Message.SectionModel.Render(m.modelName)
+	durationFormatted := m.sty.Chat.Message.SectionDuration.Render(duration.String())
+
+	text := fmt.Sprintf("%s %s %s", icon, modelFormatted, durationFormatted)
+
+	section := common.Section(m.sty, text, width-2)
+	return m.sty.Chat.Message.SectionHeader.Render(section)
+}

internal/ui/chat/tool_base.go 🔗

@@ -0,0 +1,610 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ansiext"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// responseContextHeight limits the number of lines displayed in tool output.
+const responseContextHeight = 10
+
+// ToolStatus represents the current state of a tool call.
+type ToolStatus int
+
+const (
+	ToolStatusAwaitingPermission ToolStatus = iota
+	ToolStatusRunning
+	ToolStatusSuccess
+	ToolStatusError
+	ToolStatusCancelled
+)
+
+// ToolCallContext provides the context needed for rendering a tool call.
+type ToolCallContext struct {
+	Call                message.ToolCall
+	Result              *message.ToolResult
+	Cancelled           bool
+	PermissionRequested bool
+	PermissionGranted   bool
+	IsNested            bool
+	Styles              *styles.Styles
+
+	// NestedCalls holds child tool calls for agent/agentic_fetch.
+	NestedCalls []ToolCallContext
+}
+
+// Status returns the current status of the tool call.
+func (ctx *ToolCallContext) Status() ToolStatus {
+	if ctx.Cancelled {
+		return ToolStatusCancelled
+	}
+	if ctx.HasResult() {
+		if ctx.Result.IsError {
+			return ToolStatusError
+		}
+		return ToolStatusSuccess
+	}
+	// No result yet - check permission state.
+	if ctx.PermissionRequested && !ctx.PermissionGranted {
+		return ToolStatusAwaitingPermission
+	}
+	return ToolStatusRunning
+}
+
+// HasResult returns true if the tool call has a completed result.
+func (ctx *ToolCallContext) HasResult() bool {
+	return ctx.Result != nil && ctx.Result.ToolCallID != ""
+}
+
+// toolStyles provides common FocusStylable and HighlightStylable implementations.
+// Embed this in tool items to avoid repeating style methods.
+type toolStyles struct {
+	sty *styles.Styles
+}
+
+func (s toolStyles) FocusStyle() lipgloss.Style {
+	return s.sty.Chat.Message.ToolCallFocused
+}
+
+func (s toolStyles) BlurStyle() lipgloss.Style {
+	return s.sty.Chat.Message.ToolCallBlurred
+}
+
+func (s toolStyles) HighlightStyle() lipgloss.Style {
+	return s.sty.TextSelection
+}
+
+// toolItem provides common base functionality for all tool items.
+type toolItem struct {
+	toolStyles
+	id           string
+	expanded     bool // Whether truncated content is expanded.
+	wasTruncated bool // Whether the last render was truncated.
+}
+
+// newToolItem creates a new toolItem with the given context.
+func newToolItem(ctx ToolCallContext) toolItem {
+	return toolItem{
+		toolStyles: toolStyles{sty: ctx.Styles},
+		id:         ctx.Call.ID,
+	}
+}
+
+// ID implements Identifiable.
+func (t *toolItem) ID() string {
+	return t.id
+}
+
+// HandleMouseClick implements list.MouseClickable.
+func (t *toolItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	// Only handle left clicks on truncated content.
+	if btn != ansi.MouseLeft || !t.wasTruncated {
+		return false
+	}
+
+	// Toggle expansion.
+	t.expanded = !t.expanded
+	return true
+}
+
+// HandleKeyPress implements list.KeyPressable.
+func (t *toolItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
+	// Only handle space key on truncated content.
+	if !t.wasTruncated {
+		return false
+	}
+
+	if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
+		// Toggle expansion.
+		t.expanded = !t.expanded
+		return true
+	}
+
+	return false
+}
+
+// unmarshalParams unmarshals JSON input into the target struct.
+func unmarshalParams(input string, target any) error {
+	return json.Unmarshal([]byte(input), target)
+}
+
+// ParamBuilder helps construct parameter lists for tool headers.
+type ParamBuilder struct {
+	args []string
+}
+
+// NewParamBuilder creates a new parameter builder.
+func NewParamBuilder() *ParamBuilder {
+	return &ParamBuilder{args: make([]string, 0, 4)}
+}
+
+// Main adds the main parameter (first positional argument).
+func (pb *ParamBuilder) Main(value string) *ParamBuilder {
+	if value != "" {
+		pb.args = append(pb.args, value)
+	}
+	return pb
+}
+
+// KeyValue adds a key-value pair parameter.
+func (pb *ParamBuilder) KeyValue(key, value string) *ParamBuilder {
+	if value != "" {
+		pb.args = append(pb.args, key, value)
+	}
+	return pb
+}
+
+// Flag adds a boolean flag parameter (only if true).
+func (pb *ParamBuilder) Flag(key string, value bool) *ParamBuilder {
+	if value {
+		pb.args = append(pb.args, key, "true")
+	}
+	return pb
+}
+
+// Build returns the parameter list.
+func (pb *ParamBuilder) Build() []string {
+	return pb.args
+}
+
+// renderToolIcon returns the status icon for a tool call.
+func renderToolIcon(status ToolStatus, sty *styles.Styles) string {
+	switch status {
+	case ToolStatusSuccess:
+		return sty.Tool.IconSuccess.String()
+	case ToolStatusError:
+		return sty.Tool.IconError.String()
+	case ToolStatusCancelled:
+		return sty.Tool.IconCancelled.String()
+	default:
+		return sty.Tool.IconPending.String()
+	}
+}
+
+// renderToolHeader builds the tool header line: "● ToolName params..."
+func renderToolHeader(ctx *ToolCallContext, name string, width int, params ...string) string {
+	sty := ctx.Styles
+	icon := renderToolIcon(ctx.Status(), sty)
+
+	var toolName string
+	if ctx.IsNested {
+		toolName = sty.Tool.NameNested.Render(name)
+	} else {
+		toolName = sty.Tool.NameNormal.Render(name)
+	}
+
+	prefix := fmt.Sprintf("%s %s ", icon, toolName)
+	prefixWidth := lipgloss.Width(prefix)
+	remainingWidth := width - prefixWidth
+
+	paramsStr := renderParamList(params, remainingWidth, sty)
+	return prefix + paramsStr
+}
+
+// renderParamList formats parameters as "main (key=value, ...)" with truncation.
+func renderParamList(params []string, width int, sty *styles.Styles) string {
+	if len(params) == 0 {
+		return ""
+	}
+
+	mainParam := params[0]
+	if width >= 0 && lipgloss.Width(mainParam) > width {
+		mainParam = ansi.Truncate(mainParam, width, "…")
+	}
+
+	if len(params) == 1 {
+		return sty.Tool.ParamMain.Render(mainParam)
+	}
+
+	// Build key=value pairs from remaining params.
+	otherParams := params[1:]
+	if len(otherParams)%2 != 0 {
+		otherParams = append(otherParams, "")
+	}
+
+	var parts []string
+	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))
+	}
+
+	if len(parts) == 0 {
+		return sty.Tool.ParamMain.Render(ansi.Truncate(mainParam, width, "…"))
+	}
+
+	partsRendered := strings.Join(parts, ", ")
+	remainingWidth := width - lipgloss.Width(partsRendered) - 3 // " ()"
+	if remainingWidth < 30 {
+		// Not enough space for params, just show main.
+		return sty.Tool.ParamMain.Render(ansi.Truncate(mainParam, width, "…"))
+	}
+
+	fullParam := fmt.Sprintf("%s (%s)", mainParam, partsRendered)
+	return sty.Tool.ParamMain.Render(ansi.Truncate(fullParam, width, "…"))
+}
+
+// renderEarlyState handles error/cancelled/pending states before content rendering.
+// Returns the rendered output and true if early state was handled.
+func renderEarlyState(ctx *ToolCallContext, header string, width int) (string, bool) {
+	sty := ctx.Styles
+
+	var msg string
+	switch ctx.Status() {
+	case ToolStatusError:
+		msg = renderToolError(ctx, width)
+	case ToolStatusCancelled:
+		msg = sty.Tool.StateCancelled.Render("Canceled.")
+	case ToolStatusAwaitingPermission:
+		msg = sty.Tool.StateWaiting.Render("Requesting permission...")
+	case ToolStatusRunning:
+		msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
+	default:
+		return "", false
+	}
+
+	msg = sty.Tool.BodyPadding.Render(msg)
+	return lipgloss.JoinVertical(lipgloss.Left, header, "", msg), true
+}
+
+// renderToolError formats an error message with ERROR tag.
+func renderToolError(ctx *ToolCallContext, width int) string {
+	sty := ctx.Styles
+	errContent := strings.ReplaceAll(ctx.Result.Content, "\n", " ")
+	errTag := sty.Tool.ErrorTag.Render("ERROR")
+	tagWidth := lipgloss.Width(errTag)
+	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
+	return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
+}
+
+// joinHeaderBody combines header and body with proper padding.
+func joinHeaderBody(header, body string, sty *styles.Styles) string {
+	if body == "" {
+		return header
+	}
+	body = sty.Tool.BodyPadding.Render(body)
+	return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
+}
+
+// renderPlainContent renders plain text with optional expansion support.
+func renderPlainContent(content string, width int, sty *styles.Styles, item *toolItem) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+	lines := strings.Split(content, "\n")
+
+	expanded := item != nil && item.expanded
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines) // Show all
+	}
+
+	var out []string
+	for i, ln := range lines {
+		if i >= maxLines {
+			break
+		}
+		ln = " " + ln
+		if lipgloss.Width(ln) > width {
+			ln = ansi.Truncate(ln, width, "…")
+		}
+		out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
+	}
+
+	wasTruncated := len(lines) > responseContextHeight
+	if item != nil {
+		item.wasTruncated = wasTruncated
+	}
+
+	if !expanded && wasTruncated {
+		out = append(out, sty.Tool.ContentTruncation.
+			Width(width).
+			Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
+	}
+
+	return strings.Join(out, "\n")
+}
+
+// formatNonZero returns string representation of non-zero integers, empty for zero.
+func formatNonZero(value int) string {
+	if value == 0 {
+		return ""
+	}
+	return fmt.Sprintf("%d", value)
+}
+
+// renderCodeContent renders syntax-highlighted code with line numbers and optional expansion.
+func renderCodeContent(path, content string, offset, width int, sty *styles.Styles, item *toolItem) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+
+	lines := strings.Split(content, "\n")
+
+	maxLines := responseContextHeight
+	if item != nil && item.expanded {
+		maxLines = len(lines)
+	}
+
+	truncated := lines
+	if len(lines) > maxLines {
+		truncated = lines[:maxLines]
+	}
+
+	// Escape ANSI sequences in content.
+	for i, ln := range truncated {
+		truncated[i] = ansiext.Escape(ln)
+	}
+
+	// Apply syntax highlighting.
+	bg := sty.Tool.ContentCodeBg
+	highlighted, _ := common.SyntaxHighlight(sty, strings.Join(truncated, "\n"), path, bg)
+	highlightedLines := strings.Split(highlighted, "\n")
+
+	// Calculate gutter width for line numbers.
+	maxLineNum := offset + len(highlightedLines)
+	maxDigits := getDigits(maxLineNum)
+	numFmt := fmt.Sprintf("%%%dd", maxDigits)
+
+	// Calculate available width for code (accounting for gutter).
+	const numPR, numPL, codePR, codePL = 1, 1, 1, 2
+	codeWidth := width - maxDigits - numPL - numPR - 2
+
+	var out []string
+	for i, ln := range highlightedLines {
+		lineNum := sty.Base.
+			Foreground(sty.FgMuted).
+			Background(bg).
+			PaddingRight(numPR).
+			PaddingLeft(numPL).
+			Render(fmt.Sprintf(numFmt, offset+i+1))
+
+		codeLine := sty.Base.
+			Width(codeWidth).
+			Background(bg).
+			PaddingRight(codePR).
+			PaddingLeft(codePL).
+			Render(ansi.Truncate(ln, codeWidth-codePL-codePR, "…"))
+
+		out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
+	}
+
+	wasTruncated := len(lines) > responseContextHeight
+	if item != nil {
+		item.wasTruncated = wasTruncated
+	}
+
+	expanded := item != nil && item.expanded
+
+	if !expanded && wasTruncated {
+		msg := fmt.Sprintf(" …(%d lines) [click or space to expand]", len(lines)-responseContextHeight)
+		out = append(out, sty.Muted.Background(bg).Render(msg))
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, out...)
+}
+
+// renderMarkdownContent renders markdown with optional expansion support.
+func renderMarkdownContent(content string, width int, sty *styles.Styles, item *toolItem) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+
+	cappedWidth := min(width, 120)
+	renderer := common.PlainMarkdownRenderer(sty, cappedWidth)
+	rendered, err := renderer.Render(content)
+	if err != nil {
+		return renderPlainContent(content, width, sty, nil)
+	}
+
+	lines := strings.Split(rendered, "\n")
+
+	maxLines := responseContextHeight
+	if item != nil && item.expanded {
+		maxLines = len(lines)
+	}
+
+	var out []string
+	for i, ln := range lines {
+		if i >= maxLines {
+			break
+		}
+		out = append(out, ln)
+	}
+
+	wasTruncated := len(lines) > responseContextHeight
+	if item != nil {
+		item.wasTruncated = wasTruncated
+	}
+
+	expanded := item != nil && item.expanded
+
+	if !expanded && wasTruncated {
+		out = append(out, sty.Tool.ContentTruncation.
+			Width(cappedWidth-2).
+			Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
+	}
+
+	return sty.Tool.ContentLine.Render(strings.Join(out, "\n"))
+}
+
+// renderDiffContent renders a diff with optional expansion support.
+func renderDiffContent(file, oldContent, newContent string, width int, sty *styles.Styles, item *toolItem) string {
+	formatter := common.DiffFormatter(sty).
+		Before(file, oldContent).
+		After(file, newContent).
+		Width(width)
+
+	if width > 120 {
+		formatter = formatter.Split()
+	}
+
+	formatted := formatter.String()
+	lines := strings.Split(formatted, "\n")
+
+	wasTruncated := len(lines) > responseContextHeight
+	if item != nil {
+		item.wasTruncated = wasTruncated
+	}
+
+	expanded := item != nil && item.expanded
+
+	if !expanded && wasTruncated {
+		truncateMsg := sty.Tool.DiffTruncation.
+			Width(width).
+			Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight))
+		formatted = strings.Join(lines[:responseContextHeight], "\n") + "\n" + truncateMsg
+	}
+
+	return formatted
+}
+
+// renderImageContent renders image data with optional text content.
+func renderImageContent(data, mediaType, textContent string, sty *styles.Styles) string {
+	dataSize := len(data) * 3 / 4 // Base64 to bytes approximation.
+	sizeStr := formatSize(dataSize)
+
+	loaded := sty.Tool.IconSuccess.String()
+	arrow := sty.Tool.NameNested.Render("→")
+	typeStyled := sty.Base.Render(mediaType)
+	sizeStyled := sty.Subtle.Render(sizeStr)
+
+	imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
+
+	if strings.TrimSpace(textContent) != "" {
+		textDisplay := sty.Tool.ContentLine.Render(textContent)
+		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
+	}
+
+	return imageDisplay
+}
+
+// renderMediaContent renders non-image media content.
+func renderMediaContent(mediaType, textContent string, sty *styles.Styles) string {
+	loaded := sty.Tool.IconSuccess.String()
+	arrow := sty.Tool.NameNested.Render("→")
+	typeStyled := sty.Base.Render(mediaType)
+	mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
+
+	if strings.TrimSpace(textContent) != "" {
+		textDisplay := sty.Tool.ContentLine.Render(textContent)
+		return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
+	}
+
+	return mediaDisplay
+}
+
+// formatSize formats byte count as human-readable size.
+func formatSize(bytes int) string {
+	if bytes < 1024 {
+		return fmt.Sprintf("%d B", bytes)
+	}
+	if bytes < 1024*1024 {
+		return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
+	}
+	return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
+}
+
+// getDigits returns the number of digits in a number.
+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
+}
+
+// formatTodosList formats a list of todos with status icons.
+func formatTodosList(todos []session.Todo, width int, sty *styles.Styles) string {
+	if len(todos) == 0 {
+		return ""
+	}
+
+	sorted := make([]session.Todo, len(todos))
+	copy(sorted, todos)
+	slices.SortStableFunc(sorted, func(a, b session.Todo) int {
+		return todoStatusOrder(a.Status) - todoStatusOrder(b.Status)
+	})
+
+	var lines []string
+	for _, todo := range sorted {
+		var prefix string
+		var textStyle lipgloss.Style
+
+		switch todo.Status {
+		case session.TodoStatusCompleted:
+			prefix = sty.Base.Foreground(sty.Green).Render(styles.TodoCompletedIcon) + " "
+			textStyle = sty.Base
+		case session.TodoStatusInProgress:
+			prefix = sty.Base.Foreground(sty.GreenDark).Render(styles.ArrowRightIcon) + " "
+			textStyle = sty.Base
+		default:
+			prefix = sty.Muted.Render(styles.TodoPendingIcon) + " "
+			textStyle = sty.Base
+		}
+
+		text := todo.Content
+		if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
+			text = todo.ActiveForm
+		}
+
+		line := prefix + textStyle.Render(text)
+		line = ansi.Truncate(line, width, "…")
+		lines = append(lines, line)
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+// todoStatusOrder returns sort order for todo statuses.
+func todoStatusOrder(s session.TodoStatus) int {
+	switch s {
+	case session.TodoStatusCompleted:
+		return 0
+	case session.TodoStatusInProgress:
+		return 1
+	default:
+		return 2
+	}
+}

internal/ui/chat/tool_items.go 🔗

@@ -0,0 +1,1100 @@
+package chat
+
+import (
+	"cmp"
+	"fmt"
+	"strings"
+	"time"
+
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/tree"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/fsext"
+)
+
+// NewToolItem creates the appropriate tool item for the given context.
+func NewToolItem(ctx ToolCallContext) MessageItem {
+	switch ctx.Call.Name {
+	// Bash tools
+	case tools.BashToolName:
+		return NewBashToolItem(ctx)
+	case tools.JobOutputToolName:
+		return NewJobOutputToolItem(ctx)
+	case tools.JobKillToolName:
+		return NewJobKillToolItem(ctx)
+
+	// File tools
+	case tools.ViewToolName:
+		return NewViewToolItem(ctx)
+	case tools.EditToolName:
+		return NewEditToolItem(ctx)
+	case tools.MultiEditToolName:
+		return NewMultiEditToolItem(ctx)
+	case tools.WriteToolName:
+		return NewWriteToolItem(ctx)
+
+	// Search tools
+	case tools.GlobToolName:
+		return NewGlobToolItem(ctx)
+	case tools.GrepToolName:
+		return NewGrepToolItem(ctx)
+	case tools.LSToolName:
+		return NewLSToolItem(ctx)
+	case tools.SourcegraphToolName:
+		return NewSourcegraphToolItem(ctx)
+
+	// Fetch tools
+	case tools.FetchToolName:
+		return NewFetchToolItem(ctx)
+	case tools.AgenticFetchToolName:
+		return NewAgenticFetchToolItem(ctx)
+	case tools.WebFetchToolName:
+		return NewWebFetchToolItem(ctx)
+	case tools.WebSearchToolName:
+		return NewWebSearchToolItem(ctx)
+	case tools.DownloadToolName:
+		return NewDownloadToolItem(ctx)
+
+	// LSP tools
+	case tools.DiagnosticsToolName:
+		return NewDiagnosticsToolItem(ctx)
+	case tools.ReferencesToolName:
+		return NewReferencesToolItem(ctx)
+
+	// Misc tools
+	case tools.TodosToolName:
+		return NewTodosToolItem(ctx)
+	case agent.AgentToolName:
+		return NewAgentToolItem(ctx)
+
+	default:
+		return NewGenericToolItem(ctx)
+	}
+}
+
+// -----------------------------------------------------------------------------
+// Bash Tools
+// -----------------------------------------------------------------------------
+
+// BashToolItem renders bash command execution.
+type BashToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewBashToolItem(ctx ToolCallContext) *BashToolItem {
+	return &BashToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *BashToolItem) Render(width int) string {
+	var params tools.BashParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	cmd := strings.ReplaceAll(params.Command, "\n", " ")
+	cmd = strings.ReplaceAll(cmd, "\t", "    ")
+
+	// Check if this is a background job that finished
+	if m.ctx.Call.Finished {
+		var meta tools.BashResponseMetadata
+		unmarshalParams(m.ctx.Result.Metadata, &meta)
+		if meta.Background {
+			return m.renderBackgroundJob(params, meta, width)
+		}
+	}
+
+	args := NewParamBuilder().
+		Main(cmd).
+		Flag("background", params.RunInBackground).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "Bash", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	var meta tools.BashResponseMetadata
+	unmarshalParams(m.ctx.Result.Metadata, &meta)
+
+	output := meta.Output
+	if output == "" && m.ctx.Result.Content != tools.BashNoOutput {
+		output = m.ctx.Result.Content
+	}
+
+	if output == "" {
+		return header
+	}
+
+	body := renderPlainContent(output, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+func (m *BashToolItem) renderBackgroundJob(params tools.BashParams, meta tools.BashResponseMetadata, width int) string {
+	description := cmp.Or(meta.Description, params.Command)
+	header := renderJobHeader(&m.ctx, "Start", meta.ShellID, description, width)
+
+	if m.ctx.IsNested {
+		return header
+	}
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	content := "Command: " + params.Command + "\n" + m.ctx.Result.Content
+	body := renderPlainContent(content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// JobOutputToolItem renders job output retrieval.
+type JobOutputToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewJobOutputToolItem(ctx ToolCallContext) *JobOutputToolItem {
+	return &JobOutputToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *JobOutputToolItem) Render(width int) string {
+	var params tools.JobOutputParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	var meta tools.JobOutputResponseMetadata
+	var description string
+	if m.ctx.Result != nil && m.ctx.Result.Metadata != "" {
+		unmarshalParams(m.ctx.Result.Metadata, &meta)
+		description = cmp.Or(meta.Description, meta.Command)
+	}
+
+	header := renderJobHeader(&m.ctx, "Output", params.ShellID, description, width)
+
+	if m.ctx.IsNested {
+		return header
+	}
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// JobKillToolItem renders job termination.
+type JobKillToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewJobKillToolItem(ctx ToolCallContext) *JobKillToolItem {
+	return &JobKillToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *JobKillToolItem) Render(width int) string {
+	var params tools.JobKillParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	var meta tools.JobKillResponseMetadata
+	var description string
+	if m.ctx.Result != nil && m.ctx.Result.Metadata != "" {
+		unmarshalParams(m.ctx.Result.Metadata, &meta)
+		description = cmp.Or(meta.Description, meta.Command)
+	}
+
+	header := renderJobHeader(&m.ctx, "Kill", params.ShellID, description, width)
+
+	if m.ctx.IsNested {
+		return header
+	}
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// renderJobHeader builds a job-specific header with action and PID.
+func renderJobHeader(ctx *ToolCallContext, action, pid, description string, width int) string {
+	sty := ctx.Styles
+	icon := renderToolIcon(ctx.Status(), sty)
+
+	jobPart := sty.Tool.JobToolName.Render("Job")
+	actionPart := sty.Tool.JobAction.Render("(" + action + ")")
+	pidPart := sty.Tool.JobPID.Render("PID " + pid)
+
+	prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
+
+	if description == "" {
+		return prefix
+	}
+
+	descPart := " " + sty.Tool.JobDescription.Render(description)
+	fullHeader := prefix + descPart
+
+	if lipgloss.Width(fullHeader) > width {
+		availableWidth := width - lipgloss.Width(prefix) - 1
+		if availableWidth < 10 {
+			return prefix
+		}
+		descPart = " " + sty.Tool.JobDescription.Render(truncateText(description, availableWidth))
+		fullHeader = prefix + descPart
+	}
+
+	return fullHeader
+}
+
+// -----------------------------------------------------------------------------
+// File Tools
+// -----------------------------------------------------------------------------
+
+// ViewToolItem renders file viewing with syntax highlighting.
+type ViewToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewViewToolItem(ctx ToolCallContext) *ViewToolItem {
+	return &ViewToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *ViewToolItem) Render(width int) string {
+	var params tools.ViewParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	file := fsext.PrettyPath(params.FilePath)
+	args := NewParamBuilder().
+		Main(file).
+		KeyValue("limit", formatNonZero(params.Limit)).
+		KeyValue("offset", formatNonZero(params.Offset)).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "View", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	// Handle image content
+	if m.ctx.Result.Data != "" && strings.HasPrefix(m.ctx.Result.MIMEType, "image/") {
+		body := renderImageContent(m.ctx.Result.Data, m.ctx.Result.MIMEType, "", m.ctx.Styles)
+		return joinHeaderBody(header, body, m.ctx.Styles)
+	}
+
+	var meta tools.ViewResponseMetadata
+	unmarshalParams(m.ctx.Result.Metadata, &meta)
+
+	body := renderCodeContent(meta.FilePath, meta.Content, params.Offset, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// EditToolItem renders file editing with diff visualization.
+type EditToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewEditToolItem(ctx ToolCallContext) *EditToolItem {
+	return &EditToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *EditToolItem) Render(width int) string {
+	var params tools.EditParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	file := fsext.PrettyPath(params.FilePath)
+	args := NewParamBuilder().Main(file).Build()
+
+	header := renderToolHeader(&m.ctx, "Edit", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	var meta tools.EditResponseMetadata
+	if err := unmarshalParams(m.ctx.Result.Metadata, &meta); err != nil {
+		body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, nil)
+		return joinHeaderBody(header, body, m.ctx.Styles)
+	}
+
+	body := renderDiffContent(file, meta.OldContent, meta.NewContent, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// MultiEditToolItem renders multiple file edits with diff visualization.
+type MultiEditToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewMultiEditToolItem(ctx ToolCallContext) *MultiEditToolItem {
+	return &MultiEditToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *MultiEditToolItem) Render(width int) string {
+	var params tools.MultiEditParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	file := fsext.PrettyPath(params.FilePath)
+	args := NewParamBuilder().
+		Main(file).
+		KeyValue("edits", fmt.Sprintf("%d", len(params.Edits))).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "Multi-Edit", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	var meta tools.MultiEditResponseMetadata
+	if err := unmarshalParams(m.ctx.Result.Metadata, &meta); err != nil {
+		body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, nil)
+		return joinHeaderBody(header, body, m.ctx.Styles)
+	}
+
+	body := renderDiffContent(file, meta.OldContent, meta.NewContent, width-2, m.ctx.Styles, &m.toolItem)
+
+	// Add failed edits warning if any exist
+	if len(meta.EditsFailed) > 0 {
+		sty := m.ctx.Styles
+		noteTag := sty.Tool.NoteTag.Render("Note")
+		noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits))
+		note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
+		body = lipgloss.JoinVertical(lipgloss.Left, body, "", note)
+	}
+
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// WriteToolItem renders file writing with syntax-highlighted content preview.
+type WriteToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewWriteToolItem(ctx ToolCallContext) *WriteToolItem {
+	return &WriteToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *WriteToolItem) Render(width int) string {
+	var params tools.WriteParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	file := fsext.PrettyPath(params.FilePath)
+	args := NewParamBuilder().Main(file).Build()
+
+	header := renderToolHeader(&m.ctx, "Write", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderCodeContent(file, params.Content, 0, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// Search Tools
+// -----------------------------------------------------------------------------
+
+// GlobToolItem renders glob file pattern matching results.
+type GlobToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewGlobToolItem(ctx ToolCallContext) *GlobToolItem {
+	return &GlobToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *GlobToolItem) Render(width int) string {
+	var params tools.GlobParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	args := NewParamBuilder().
+		Main(params.Pattern).
+		KeyValue("path", fsext.PrettyPath(params.Path)).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "Glob", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// GrepToolItem renders grep content search results.
+type GrepToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewGrepToolItem(ctx ToolCallContext) *GrepToolItem {
+	return &GrepToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *GrepToolItem) Render(width int) string {
+	var params tools.GrepParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	args := NewParamBuilder().
+		Main(params.Pattern).
+		KeyValue("path", fsext.PrettyPath(params.Path)).
+		KeyValue("include", params.Include).
+		Flag("literal", params.LiteralText).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "Grep", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// LSToolItem renders directory listing results.
+type LSToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewLSToolItem(ctx ToolCallContext) *LSToolItem {
+	return &LSToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *LSToolItem) Render(width int) string {
+	var params tools.LSParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	path := cmp.Or(params.Path, ".")
+	path = fsext.PrettyPath(path)
+
+	args := NewParamBuilder().Main(path).Build()
+	header := renderToolHeader(&m.ctx, "List", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// SourcegraphToolItem renders code search results.
+type SourcegraphToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewSourcegraphToolItem(ctx ToolCallContext) *SourcegraphToolItem {
+	return &SourcegraphToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *SourcegraphToolItem) Render(width int) string {
+	var params tools.SourcegraphParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	args := NewParamBuilder().
+		Main(params.Query).
+		KeyValue("count", formatNonZero(params.Count)).
+		KeyValue("context", formatNonZero(params.ContextWindow)).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "Sourcegraph", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// Fetch Tools
+// -----------------------------------------------------------------------------
+
+// FetchToolItem renders URL fetching with format-specific content display.
+type FetchToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewFetchToolItem(ctx ToolCallContext) *FetchToolItem {
+	return &FetchToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *FetchToolItem) Render(width int) string {
+	var params tools.FetchParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	args := NewParamBuilder().
+		Main(params.URL).
+		KeyValue("format", params.Format).
+		KeyValue("timeout", formatTimeout(params.Timeout)).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "Fetch", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	// Use appropriate extension for syntax highlighting
+	file := "fetch.md"
+	switch params.Format {
+	case "text":
+		file = "fetch.txt"
+	case "html":
+		file = "fetch.html"
+	}
+
+	body := renderCodeContent(file, m.ctx.Result.Content, 0, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// AgenticFetchToolItem renders agentic URL fetching with nested tool calls.
+type AgenticFetchToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewAgenticFetchToolItem(ctx ToolCallContext) *AgenticFetchToolItem {
+	return &AgenticFetchToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *AgenticFetchToolItem) Render(width int) string {
+	var params tools.AgenticFetchParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	var args []string
+	if params.URL != "" {
+		args = NewParamBuilder().Main(params.URL).Build()
+	}
+
+	header := renderToolHeader(&m.ctx, "Agentic Fetch", width, args...)
+
+	// Render with nested tool calls tree
+	body := renderAgentBody(&m.ctx, params.Prompt, "Prompt", header, width)
+	return body
+}
+
+// WebFetchToolItem renders web page fetching.
+type WebFetchToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewWebFetchToolItem(ctx ToolCallContext) *WebFetchToolItem {
+	return &WebFetchToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *WebFetchToolItem) Render(width int) string {
+	var params tools.WebFetchParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	args := NewParamBuilder().Main(params.URL).Build()
+	header := renderToolHeader(&m.ctx, "Fetch", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderMarkdownContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// WebSearchToolItem renders web search results.
+type WebSearchToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewWebSearchToolItem(ctx ToolCallContext) *WebSearchToolItem {
+	return &WebSearchToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *WebSearchToolItem) Render(width int) string {
+	var params tools.WebSearchParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	args := NewParamBuilder().Main(params.Query).Build()
+	header := renderToolHeader(&m.ctx, "Search", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderMarkdownContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// DownloadToolItem renders file downloading.
+type DownloadToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewDownloadToolItem(ctx ToolCallContext) *DownloadToolItem {
+	return &DownloadToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *DownloadToolItem) Render(width int) string {
+	var params tools.DownloadParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	args := NewParamBuilder().
+		Main(params.URL).
+		KeyValue("file_path", fsext.PrettyPath(params.FilePath)).
+		KeyValue("timeout", formatTimeout(params.Timeout)).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "Download", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// LSP Tools
+// -----------------------------------------------------------------------------
+
+// DiagnosticsToolItem renders project-wide diagnostic information.
+type DiagnosticsToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewDiagnosticsToolItem(ctx ToolCallContext) *DiagnosticsToolItem {
+	return &DiagnosticsToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *DiagnosticsToolItem) Render(width int) string {
+	args := NewParamBuilder().Main("project").Build()
+	header := renderToolHeader(&m.ctx, "Diagnostics", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// ReferencesToolItem renders LSP references search results.
+type ReferencesToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewReferencesToolItem(ctx ToolCallContext) *ReferencesToolItem {
+	return &ReferencesToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *ReferencesToolItem) Render(width int) string {
+	var params tools.ReferencesParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	args := NewParamBuilder().
+		Main(params.Symbol).
+		KeyValue("path", fsext.PrettyPath(params.Path)).
+		Build()
+
+	header := renderToolHeader(&m.ctx, "References", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// Misc Tools
+// -----------------------------------------------------------------------------
+
+// TodosToolItem renders todo list management.
+type TodosToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewTodosToolItem(ctx ToolCallContext) *TodosToolItem {
+	return &TodosToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *TodosToolItem) Render(width int) string {
+	sty := m.ctx.Styles
+	var params tools.TodosParams
+	var meta tools.TodosResponseMetadata
+	var headerText string
+	var body string
+
+	// Parse params for pending state
+	if err := unmarshalParams(m.ctx.Call.Input, &params); err == nil {
+		completedCount := 0
+		inProgressTask := ""
+		for _, todo := range params.Todos {
+			if todo.Status == "completed" {
+				completedCount++
+			}
+			if todo.Status == "in_progress" {
+				inProgressTask = cmp.Or(todo.ActiveForm, todo.Content)
+			}
+		}
+
+		// Default display from params
+		ratio := sty.Tool.JobAction.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
+		headerText = ratio
+		if inProgressTask != "" {
+			headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
+		}
+
+		// If we have metadata, use it for richer display
+		if m.ctx.Result != nil && m.ctx.Result.Metadata != "" {
+			if err := unmarshalParams(m.ctx.Result.Metadata, &meta); err == nil {
+				headerText, body = m.formatTodosFromMeta(meta, width)
+			}
+		}
+	}
+
+	args := NewParamBuilder().Main(headerText).Build()
+	header := renderToolHeader(&m.ctx, "To-Do", width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	if body == "" {
+		return header
+	}
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+func (m *TodosToolItem) formatTodosFromMeta(meta tools.TodosResponseMetadata, width int) (string, string) {
+	sty := m.ctx.Styles
+	var headerText, body string
+
+	if meta.IsNew {
+		if meta.JustStarted != "" {
+			headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
+		} else {
+			headerText = fmt.Sprintf("created %d todos", meta.Total)
+		}
+		body = formatTodosList(meta.Todos, width, sty)
+	} else {
+		hasCompleted := len(meta.JustCompleted) > 0
+		hasStarted := meta.JustStarted != ""
+		allCompleted := meta.Completed == meta.Total
+
+		ratio := sty.Tool.JobAction.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
+		if hasCompleted && hasStarted {
+			text := sty.Tool.JobDescription.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
+			headerText = ratio + text
+		} else if hasCompleted {
+			text := " · completed all"
+			if !allCompleted {
+				text = fmt.Sprintf(" · completed %d", len(meta.JustCompleted))
+			}
+			headerText = ratio + sty.Tool.JobDescription.Render(text)
+		} else if hasStarted {
+			headerText = ratio + sty.Tool.JobDescription.Render(" · starting task")
+		} else {
+			headerText = ratio
+		}
+
+		if allCompleted {
+			body = formatTodosList(meta.Todos, width, sty)
+		} else if meta.JustStarted != "" {
+			body = sty.Tool.IconSuccess.String() + " " + sty.Base.Render(meta.JustStarted)
+		}
+	}
+
+	return headerText, body
+}
+
+// AgentToolItem renders agent task execution with nested tool calls.
+type AgentToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewAgentToolItem(ctx ToolCallContext) *AgentToolItem {
+	return &AgentToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *AgentToolItem) Render(width int) string {
+	var params agent.AgentParams
+	unmarshalParams(m.ctx.Call.Input, &params)
+
+	header := renderToolHeader(&m.ctx, "Agent", width)
+	body := renderAgentBody(&m.ctx, params.Prompt, "Task", header, width)
+	return body
+}
+
+// renderAgentBody renders agent/agentic_fetch body with prompt tag and nested calls tree.
+func renderAgentBody(ctx *ToolCallContext, prompt, tagLabel, header string, width int) string {
+	sty := ctx.Styles
+
+	if ctx.Cancelled {
+		if result, done := renderEarlyState(ctx, header, width); done {
+			return result
+		}
+	}
+
+	// Build prompt tag
+	prompt = strings.ReplaceAll(prompt, "\n", " ")
+	taskTag := sty.Tool.AgentTaskTag.Render(tagLabel)
+	tagWidth := lipgloss.Width(taskTag)
+	remainingWidth := min(width-tagWidth-2, 120-tagWidth-2)
+	promptStyled := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+
+	headerWithPrompt := lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		lipgloss.JoinHorizontal(lipgloss.Left, taskTag, " ", promptStyled),
+	)
+
+	// Build tree with nested tool calls
+	childTools := tree.Root(headerWithPrompt)
+	for _, nestedCtx := range ctx.NestedCalls {
+		nestedCtx.IsNested = true
+		nestedItem := NewToolItem(nestedCtx)
+		childTools.Child(nestedItem.Render(remainingWidth))
+	}
+
+	parts := []string{
+		childTools.Enumerator(roundedEnumerator(2, tagWidth-5)).String(),
+	}
+
+	// Add pending indicator if not complete
+	if !ctx.HasResult() {
+		parts = append(parts, "", sty.Tool.StateWaiting.Render("Working..."))
+	}
+
+	treeOutput := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+	if !ctx.HasResult() {
+		return treeOutput
+	}
+
+	body := renderMarkdownContent(ctx.Result.Content, width-2, sty, nil)
+	return joinHeaderBody(treeOutput, body, sty)
+}
+
+// roundedEnumerator creates a tree enumerator with rounded connectors.
+func roundedEnumerator(lPadding, lineWidth int) tree.Enumerator {
+	if lineWidth == 0 {
+		lineWidth = 2
+	}
+	if lPadding == 0 {
+		lPadding = 1
+	}
+	return func(children tree.Children, index int) string {
+		line := strings.Repeat("─", lineWidth)
+		padding := strings.Repeat(" ", lPadding)
+		if children.Length()-1 == index {
+			return padding + "╰" + line
+		}
+		return padding + "├" + line
+	}
+}
+
+// GenericToolItem renders unknown tool types with basic parameter display.
+type GenericToolItem struct {
+	toolItem
+	ctx ToolCallContext
+}
+
+func NewGenericToolItem(ctx ToolCallContext) *GenericToolItem {
+	return &GenericToolItem{
+		toolItem: newToolItem(ctx),
+		ctx:      ctx,
+	}
+}
+
+func (m *GenericToolItem) Render(width int) string {
+	name := prettifyToolName(m.ctx.Call.Name)
+
+	// Handle media content
+	if m.ctx.Result != nil && m.ctx.Result.Data != "" {
+		if strings.HasPrefix(m.ctx.Result.MIMEType, "image/") {
+			args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+			header := renderToolHeader(&m.ctx, name, width, args...)
+			body := renderImageContent(m.ctx.Result.Data, m.ctx.Result.MIMEType, m.ctx.Result.Content, m.ctx.Styles)
+			return joinHeaderBody(header, body, m.ctx.Styles)
+		}
+		args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+		header := renderToolHeader(&m.ctx, name, width, args...)
+		body := renderMediaContent(m.ctx.Result.MIMEType, m.ctx.Result.Content, m.ctx.Styles)
+		return joinHeaderBody(header, body, m.ctx.Styles)
+	}
+
+	args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+	header := renderToolHeader(&m.ctx, name, width, args...)
+
+	if result, done := renderEarlyState(&m.ctx, header, width); done {
+		return result
+	}
+
+	if m.ctx.Result == nil || m.ctx.Result.Content == "" {
+		return header
+	}
+
+	body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+	return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// Helper Functions
+// -----------------------------------------------------------------------------
+
+// prettifyToolName converts tool names to display-friendly format.
+func prettifyToolName(name string) string {
+	switch name {
+	case agent.AgentToolName:
+		return "Agent"
+	case tools.BashToolName:
+		return "Bash"
+	case tools.JobOutputToolName:
+		return "Job: Output"
+	case tools.JobKillToolName:
+		return "Job: Kill"
+	case tools.DownloadToolName:
+		return "Download"
+	case tools.EditToolName:
+		return "Edit"
+	case tools.MultiEditToolName:
+		return "Multi-Edit"
+	case tools.FetchToolName:
+		return "Fetch"
+	case tools.AgenticFetchToolName:
+		return "Agentic Fetch"
+	case tools.WebFetchToolName:
+		return "Fetch"
+	case tools.WebSearchToolName:
+		return "Search"
+	case tools.GlobToolName:
+		return "Glob"
+	case tools.GrepToolName:
+		return "Grep"
+	case tools.LSToolName:
+		return "List"
+	case tools.SourcegraphToolName:
+		return "Sourcegraph"
+	case tools.TodosToolName:
+		return "To-Do"
+	case tools.ViewToolName:
+		return "View"
+	case tools.WriteToolName:
+		return "Write"
+	case tools.DiagnosticsToolName:
+		return "Diagnostics"
+	case tools.ReferencesToolName:
+		return "References"
+	default:
+		// Handle MCP tools and others
+		name = strings.TrimPrefix(name, "mcp_")
+		if name == "" {
+			return "Tool"
+		}
+		return strings.ToUpper(name[:1]) + name[1:]
+	}
+}
+
+// formatTimeout converts timeout seconds to duration string.
+func formatTimeout(timeout int) string {
+	if timeout == 0 {
+		return ""
+	}
+	return (time.Duration(timeout) * time.Second).String()
+}
+
+// truncateText truncates text to fit within width with ellipsis.
+func truncateText(s string, width int) string {
+	if lipgloss.Width(s) <= width {
+		return s
+	}
+	for i := len(s) - 1; i >= 0; i-- {
+		truncated := s[:i] + "…"
+		if lipgloss.Width(truncated) <= width {
+			return truncated
+		}
+	}
+	return "…"
+}

internal/ui/chat/user.go 🔗

@@ -0,0 +1,111 @@
+package chat
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"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"
+)
+
+type UserMessageItem struct {
+	id          string
+	content     string
+	attachments []message.BinaryContent
+	sty         *styles.Styles
+}
+
+func NewUserMessage(id, content string, attachments []message.BinaryContent, sty *styles.Styles) *UserMessageItem {
+	return &UserMessageItem{
+		id:          id,
+		content:     content,
+		attachments: attachments,
+		sty:         sty,
+	}
+}
+
+// ID implements Identifiable.
+func (m *UserMessageItem) ID() string {
+	return m.id
+}
+
+// FocusStyle returns the focus style.
+func (m *UserMessageItem) FocusStyle() lipgloss.Style {
+	return m.sty.Chat.Message.UserFocused
+}
+
+// BlurStyle returns the blur style.
+func (m *UserMessageItem) BlurStyle() lipgloss.Style {
+	return m.sty.Chat.Message.UserBlurred
+}
+
+// HighlightStyle returns the highlight style.
+func (m *UserMessageItem) HighlightStyle() lipgloss.Style {
+	return m.sty.TextSelection
+}
+
+// Render implements MessageItem.
+func (m *UserMessageItem) Render(width int) string {
+	cappedWidth := min(width, maxTextWidth)
+	renderer := common.MarkdownRenderer(m.sty, cappedWidth)
+	result, err := renderer.Render(m.content)
+	var rendered string
+	if err != nil {
+		rendered = m.content
+	} else {
+		rendered = strings.TrimSuffix(result, "\n")
+	}
+
+	if len(m.attachments) > 0 {
+		attachmentsStr := m.renderAttachments(cappedWidth)
+		rendered = strings.Join([]string{rendered, "", attachmentsStr}, "\n")
+	}
+	return rendered
+}
+
+// renderAttachments renders attachments with wrapping if they exceed the width.
+func (m *UserMessageItem) renderAttachments(width int) string {
+	const maxFilenameWidth = 10
+
+	attachments := make([]string, len(m.attachments))
+	for i, attachment := range m.attachments {
+		filename := filepath.Base(attachment.Path)
+		attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
+			" %s %s ",
+			styles.DocumentIcon,
+			ansi.Truncate(filename, maxFilenameWidth, "..."),
+		))
+	}
+
+	// Wrap attachments into lines that fit within the width.
+	var lines []string
+	var currentLine []string
+	currentWidth := 0
+
+	for _, att := range attachments {
+		attWidth := lipgloss.Width(att)
+		sepWidth := 1
+		if len(currentLine) == 0 {
+			sepWidth = 0
+		}
+
+		if currentWidth+sepWidth+attWidth > width && len(currentLine) > 0 {
+			lines = append(lines, strings.Join(currentLine, " "))
+			currentLine = []string{att}
+			currentWidth = attWidth
+		} else {
+			currentLine = append(currentLine, att)
+			currentWidth += sepWidth + attWidth
+		}
+	}
+
+	if len(currentLine) > 0 {
+		lines = append(lines, strings.Join(currentLine, " "))
+	}
+
+	return strings.Join(lines, "\n")
+}

internal/ui/common/markdown.go 🔗

@@ -1,9 +1,8 @@
 package common
 
 import (
-	"github.com/charmbracelet/crush/internal/ui/styles"
 	"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
@@ -16,11 +15,11 @@ func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
 	return r
 }
 
-// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
-// (plain text with structure) and the given width.
-func PlainMarkdownRenderer(width int) *glamour.TermRenderer {
+// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with muted
+// colors on a subtle background, for thinking content.
+func PlainMarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
 	r, _ := glamour.NewTermRenderer(
-		glamour.WithStyles(gstyles.ASCIIStyleConfig),
+		glamour.WithStyles(t.PlainMarkdown),
 		glamour.WithWordWrap(width),
 	)
 	return r

internal/ui/list/item.go 🔗

@@ -1,6 +1,7 @@
 package list
 
 import (
+	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 )
@@ -39,3 +40,10 @@ type FocusAware interface {
 	// SetFocused is called before Render to inform the item of its focus state.
 	SetFocused(focused bool)
 }
+
+// KeyPressable represents an item that can handle key press events.
+type KeyPressable interface {
+	// HandleKeyPress processes a key press event.
+	// It returns true if the event was handled, false otherwise.
+	HandleKeyPress(msg tea.KeyPressMsg) bool
+}

internal/ui/list/list.go 🔗

@@ -4,6 +4,7 @@ import (
 	"image"
 	"strings"
 
+	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
 )
@@ -681,6 +682,24 @@ func (l *List) ClearHighlight() {
 	l.lastHighlighted = make(map[int]bool)
 }
 
+// HandleKeyPress handles key press events for the currently selected item.
+// Returns true if the event was handled.
+func (l *List) HandleKeyPress(msg tea.KeyPressMsg) bool {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return false
+	}
+
+	if keyable, ok := l.items[l.selectedIdx].(KeyPressable); ok {
+		handled := keyable.HandleKeyPress(msg)
+		if handled {
+			l.invalidateItem(l.selectedIdx)
+		}
+		return handled
+	}
+
+	return false
+}
+
 // findItemAtY finds the item at the given viewport y coordinate.
 // Returns the item index and the y offset within that item. It returns -1, -1
 // if no item is found.

internal/ui/model/items.go 🔗

@@ -1,548 +0,0 @@
-package model
-
-import (
-	"fmt"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"charm.land/lipgloss/v2"
-	"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.Item
-	Identifiable
-}
-
-// MessageContentItem represents rendered message content (text, markdown, errors, etc).
-type MessageContentItem struct {
-	id         string
-	content    string
-	role       message.MessageRole
-	isMarkdown bool
-	maxWidth   int
-	sty        *styles.Styles
-}
-
-// NewMessageContentItem creates a new message content item.
-func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
-	m := &MessageContentItem{
-		id:         id,
-		content:    content,
-		isMarkdown: isMarkdown,
-		role:       role,
-		maxWidth:   120,
-		sty:        sty,
-	}
-	return m
-}
-
-// ID implements Identifiable.
-func (m *MessageContentItem) ID() string {
-	return m.id
-}
-
-// FocusStyle returns the focus style.
-func (m *MessageContentItem) FocusStyle() lipgloss.Style {
-	if m.role == message.User {
-		return m.sty.Chat.Message.UserFocused
-	}
-	return m.sty.Chat.Message.AssistantFocused
-}
-
-// BlurStyle returns the blur style.
-func (m *MessageContentItem) BlurStyle() lipgloss.Style {
-	if m.role == message.User {
-		return m.sty.Chat.Message.UserBlurred
-	}
-	return m.sty.Chat.Message.AssistantBlurred
-}
-
-// HighlightStyle returns the highlight style.
-func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
-	return m.sty.TextSelection
-}
-
-// Render renders the content at the given width, using cache if available.
-//
-// It implements [list.Item].
-func (m *MessageContentItem) Render(width int) string {
-	contentWidth := width
-	// Cap width to maxWidth for markdown
-	cappedWidth := contentWidth
-	if m.isMarkdown {
-		cappedWidth = min(contentWidth, m.maxWidth)
-	}
-
-	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
-	}
-
-	return rendered
-}
-
-// ToolCallItem represents a rendered tool call with its header and content.
-type ToolCallItem struct {
-	id         string
-	toolCall   message.ToolCall
-	toolResult message.ToolResult
-	cancelled  bool
-	isNested   bool
-	maxWidth   int
-	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,
-		sty:        sty,
-	}
-	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
-}
-
-// FocusStyle returns the focus style.
-func (t *ToolCallItem) FocusStyle() lipgloss.Style {
-	return t.sty.Chat.Message.ToolCallFocused
-}
-
-// BlurStyle returns the blur style.
-func (t *ToolCallItem) BlurStyle() lipgloss.Style {
-	return t.sty.Chat.Message.ToolCallBlurred
-}
-
-// HighlightStyle returns the highlight style.
-func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
-	return t.sty.TextSelection
-}
-
-// Render implements list.Item.
-func (t *ToolCallItem) Render(width int) string {
-	// Render the tool call
-	ctx := &toolrender.RenderContext{
-		Call:      t.toolCall,
-		Result:    t.toolResult,
-		Cancelled: t.cancelled,
-		IsNested:  t.isNested,
-		Width:     width,
-		Styles:    t.sty,
-	}
-
-	rendered := toolrender.Render(ctx)
-	return rendered
-}
-
-// AttachmentItem represents a file attachment in a user message.
-type AttachmentItem struct {
-	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,
-	}
-	return a
-}
-
-// ID implements Identifiable.
-func (a *AttachmentItem) ID() string {
-	return a.id
-}
-
-// FocusStyle returns the focus style.
-func (a *AttachmentItem) FocusStyle() lipgloss.Style {
-	return a.sty.Chat.Message.AssistantFocused
-}
-
-// BlurStyle returns the blur style.
-func (a *AttachmentItem) BlurStyle() lipgloss.Style {
-	return a.sty.Chat.Message.AssistantBlurred
-}
-
-// HighlightStyle returns the highlight style.
-func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
-	return a.sty.TextSelection
-}
-
-// Render implements list.Item.
-func (a *AttachmentItem) Render(width int) string {
-	const maxFilenameWidth = 10
-	content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
-		" %s %s ",
-		styles.DocumentIcon,
-		ansi.Truncate(a.filename, maxFilenameWidth, "..."),
-	))
-
-	return content
-
-	// return a.RenderWithHighlight(content, width, a.CurrentStyle())
-}
-
-// ThinkingItem represents thinking/reasoning content in assistant messages.
-type ThinkingItem struct {
-	id       string
-	thinking string
-	duration time.Duration
-	finished bool
-	maxWidth int
-	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,
-		sty:      sty,
-	}
-	return t
-}
-
-// ID implements Identifiable.
-func (t *ThinkingItem) ID() string {
-	return t.id
-}
-
-// FocusStyle returns the focus style.
-func (t *ThinkingItem) FocusStyle() lipgloss.Style {
-	return t.sty.Chat.Message.AssistantFocused
-}
-
-// BlurStyle returns the blur style.
-func (t *ThinkingItem) BlurStyle() lipgloss.Style {
-	return t.sty.Chat.Message.AssistantBlurred
-}
-
-// HighlightStyle returns the highlight style.
-func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
-	return t.sty.TextSelection
-}
-
-// Render implements list.Item.
-func (t *ThinkingItem) Render(width int) string {
-	cappedWidth := min(width, t.maxWidth)
-
-	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)
-
-	return result
-}
-
-// SectionHeaderItem represents a section header (e.g., assistant info).
-type SectionHeaderItem struct {
-	id              string
-	modelName       string
-	duration        time.Duration
-	isSectionHeader bool
-	sty             *styles.Styles
-	content         string
-}
-
-// 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,
-	}
-	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
-}
-
-// FocusStyle returns the focus style.
-func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
-	return s.sty.Chat.Message.AssistantFocused
-}
-
-// BlurStyle returns the blur style.
-func (s *SectionHeaderItem) BlurStyle() lipgloss.Style {
-	return s.sty.Chat.Message.AssistantBlurred
-}
-
-// Render implements list.Item.
-func (s *SectionHeaderItem) Render(width int) string {
-	content := fmt.Sprintf("%s %s %s",
-		s.sty.Subtle.Render(styles.ModelIcon),
-		s.sty.Muted.Render(s.modelName),
-		s.sty.Subtle.Render(s.duration.String()),
-	)
-
-	return s.sty.Chat.Message.SectionHeader.Render(content)
-}
-
-// 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(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
-	var items []MessageItem
-
-	// Skip tool result messages - they're displayed inline with tool calls
-	if msg.Role == message.Tool {
-		return items
-	}
-
-	// 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,
-				msg.Role,
-				true, // User messages are markdown
-				sty,
-			)
-			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,
-			)
-			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,
-			)
-			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*",
-					msg.Role,
-					true,
-					sty,
-				)
-				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,
-					msg.Role,
-					false,
-					sty,
-				)
-				items = append(items, item)
-			}
-		} else if content != "" {
-			item := NewMessageContentItem(
-				fmt.Sprintf("%s-content", msg.ID),
-				content,
-				msg.Role,
-				true, // Assistant messages are markdown
-				sty,
-			)
-			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,
-			)
-
-			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 🔗

@@ -7,12 +7,15 @@ import (
 	"os"
 	"slices"
 	"strings"
+	"time"
 
 	"charm.land/bubbles/v2/help"
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/textarea"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
@@ -20,6 +23,7 @@ import (
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
 	"github.com/charmbracelet/crush/internal/ui/logo"
@@ -103,7 +107,7 @@ type UI struct {
 	workingPlaceholder string
 
 	// Chat components
-	chat *Chat
+	chat *chat.Chat
 
 	// onboarding state
 	onboarding struct {
@@ -130,7 +134,7 @@ func New(com *common.Common) *UI {
 	ta.SetVirtualCursor(false)
 	ta.Focus()
 
-	ch := NewChat(com)
+	ch := chat.NewChat(com)
 
 	ui := &UI{
 		com:      com,
@@ -201,23 +205,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case sessionLoadedMsg:
 		m.state = uiChat
 		m.session = &msg.sess
-		// Load the last 20 messages from this session.
+		// TODO: handle error.
 		msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
 
-		// 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(m.com.Styles, msg, toolResultMap)...)
-		}
-
-		m.chat.SetMessages(items...)
+		m.chat.SetMessages(m.convertChatMessages(msgs)...)
 
 		// 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
@@ -407,43 +398,47 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 				m.chat.Focus()
 				m.chat.SetSelected(m.chat.Len() - 1)
 			}
-		case key.Matches(msg, m.keyMap.Chat.Up):
+		case key.Matches(msg, m.keyMap.Chat.Up) && m.focus == uiFocusMain:
 			m.chat.ScrollBy(-1)
 			if !m.chat.SelectedItemInView() {
 				m.chat.SelectPrev()
 				m.chat.ScrollToSelected()
 			}
-		case key.Matches(msg, m.keyMap.Chat.Down):
+		case key.Matches(msg, m.keyMap.Chat.Down) && m.focus == uiFocusMain:
 			m.chat.ScrollBy(1)
 			if !m.chat.SelectedItemInView() {
 				m.chat.SelectNext()
 				m.chat.ScrollToSelected()
 			}
-		case key.Matches(msg, m.keyMap.Chat.UpOneItem):
+		case key.Matches(msg, m.keyMap.Chat.UpOneItem) && m.focus == uiFocusMain:
 			m.chat.SelectPrev()
 			m.chat.ScrollToSelected()
-		case key.Matches(msg, m.keyMap.Chat.DownOneItem):
+		case key.Matches(msg, m.keyMap.Chat.DownOneItem) && m.focus == uiFocusMain:
 			m.chat.SelectNext()
 			m.chat.ScrollToSelected()
-		case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
+		case key.Matches(msg, m.keyMap.Chat.HalfPageUp) && m.focus == uiFocusMain:
 			m.chat.ScrollBy(-m.chat.Height() / 2)
 			m.chat.SelectFirstInView()
-		case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
+		case key.Matches(msg, m.keyMap.Chat.HalfPageDown) && m.focus == uiFocusMain:
 			m.chat.ScrollBy(m.chat.Height() / 2)
 			m.chat.SelectLastInView()
-		case key.Matches(msg, m.keyMap.Chat.PageUp):
+		case key.Matches(msg, m.keyMap.Chat.PageUp) && m.focus == uiFocusMain:
 			m.chat.ScrollBy(-m.chat.Height())
 			m.chat.SelectFirstInView()
-		case key.Matches(msg, m.keyMap.Chat.PageDown):
+		case key.Matches(msg, m.keyMap.Chat.PageDown) && m.focus == uiFocusMain:
 			m.chat.ScrollBy(m.chat.Height())
 			m.chat.SelectLastInView()
-		case key.Matches(msg, m.keyMap.Chat.Home):
+		case key.Matches(msg, m.keyMap.Chat.Home) && m.focus == uiFocusMain:
 			m.chat.ScrollToTop()
 			m.chat.SelectFirst()
-		case key.Matches(msg, m.keyMap.Chat.End):
+		case key.Matches(msg, m.keyMap.Chat.End) && m.focus == uiFocusMain:
 			m.chat.ScrollToBottom()
 			m.chat.SelectLast()
 		default:
+			// Try to handle key press in focused item (for expansion, etc.)
+			if m.focus == uiFocusMain && m.chat.HandleKeyPress(msg) {
+				return cmds
+			}
 			handleGlobalKeys(msg)
 		}
 	default:
@@ -976,6 +971,150 @@ func (m *UI) loadSessionsCmd() tea.Msg {
 	return sessionsLoadedMsg{sessions: allSessions}
 }
 
+// convertChatMessages converts messages to chat message items
+func (m *UI) convertChatMessages(msgs []message.Message) []chat.MessageItem {
+	items := make([]chat.MessageItem, 0)
+
+	// Build tool result map for efficient lookup.
+	toolResultMap := m.buildToolResultMap(msgs)
+
+	var lastUserMessageTime time.Time
+
+	for _, msg := range msgs {
+		switch msg.Role {
+		case message.User:
+			lastUserMessageTime = time.Unix(msg.CreatedAt, 0)
+			items = append(items, chat.NewUserMessage(msg.ID, msg.Content().Text, msg.BinaryContent(), m.com.Styles))
+		case message.Assistant:
+			// Add assistant message and its tool calls.
+			assistantItems := m.convertAssistantMessage(msg, toolResultMap)
+			items = append(items, assistantItems...)
+
+			// Add section separator if assistant finished with EndTurn.
+			if msg.FinishReason() == message.FinishReasonEndTurn {
+				modelName := m.getModelName(msg)
+				items = append(items, chat.NewSectionItem(msg, lastUserMessageTime, modelName, m.com.Styles))
+			}
+		}
+	}
+	return items
+}
+
+// getModelName returns the display name for a model, or "Unknown Model" if not found.
+func (m *UI) getModelName(msg message.Message) string {
+	model := m.com.Config().GetModel(msg.Provider, msg.Model)
+	if model == nil {
+		return "Unknown Model"
+	}
+	return model.Name
+}
+
+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
+func (m *UI) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
+	toolResultMap := make(map[string]message.ToolResult)
+	for _, msg := range messages {
+		for _, tr := range msg.ToolResults() {
+			toolResultMap[tr.ToolCallID] = tr
+		}
+	}
+	return toolResultMap
+}
+
+// convertAssistantMessage converts an assistant message and its tool calls to UI items.
+func (m *UI) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []chat.MessageItem {
+	var items []chat.MessageItem
+
+	// Add assistant text/thinking message if it has content.
+	content := strings.TrimSpace(msg.Content().Text)
+	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+	isError := msg.FinishReason() == message.FinishReasonError
+	isCancelled := msg.FinishReason() == message.FinishReasonCanceled
+
+	// Show assistant message if there's content, thinking, or status to display.
+	if content != "" || thinking != "" || isError || isCancelled {
+		var finish message.Finish
+		if fp := msg.FinishPart(); fp != nil {
+			finish = *fp
+		}
+
+		items = append(items, chat.NewAssistantMessage(
+			msg.ID,
+			content,
+			thinking,
+			msg.IsFinished(),
+			finish,
+			m.com.Styles,
+		))
+	}
+
+	// Add tool call items.
+	for _, tc := range msg.ToolCalls() {
+		ctx := m.buildToolCallContext(tc, msg, toolResultMap)
+
+		// Handle nested tool calls for agent/agentic_fetch.
+		if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
+			ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
+		}
+
+		items = append(items, chat.NewToolItem(ctx))
+	}
+
+	return items
+}
+
+// buildToolCallContext creates a ToolCallContext from a tool call and its result.
+func (m *UI) buildToolCallContext(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) chat.ToolCallContext {
+	ctx := chat.ToolCallContext{
+		Call:      tc,
+		Styles:    m.com.Styles,
+		IsNested:  false,
+		Cancelled: msg.FinishReason() == message.FinishReasonCanceled,
+	}
+
+	// Add tool result if available.
+	if tr, ok := toolResultMap[tc.ID]; ok {
+		ctx.Result = &tr
+	}
+
+	// TODO: Add permission tracking when we have permission service.
+	// ctx.PermissionRequested = ...
+	// ctx.PermissionGranted = ...
+
+	return ctx
+}
+
+// loadNestedToolCalls loads nested tool calls for agent/agentic_fetch tools.
+func (m *UI) loadNestedToolCalls(msgID, toolCallID string) []chat.ToolCallContext {
+	agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(msgID, toolCallID)
+	nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
+	if err != nil || len(nestedMsgs) == 0 {
+		return nil
+	}
+
+	// Build nested tool result map.
+	nestedToolResultMap := m.buildToolResultMap(nestedMsgs)
+
+	var nestedContexts []chat.ToolCallContext
+	for _, nestedMsg := range nestedMsgs {
+		for _, nestedTC := range nestedMsg.ToolCalls() {
+			ctx := chat.ToolCallContext{
+				Call:      nestedTC,
+				Styles:    m.com.Styles,
+				IsNested:  true,
+				Cancelled: nestedMsg.FinishReason() == message.FinishReasonCanceled,
+			}
+
+			if tr, ok := nestedToolResultMap[nestedTC.ID]; ok {
+				ctx.Result = &tr
+			}
+
+			nestedContexts = append(nestedContexts, ctx)
+		}
+	}
+
+	return nestedContexts
+}
+
 // renderLogo renders the Crush logo with the given styles and dimensions.
 func renderLogo(t *styles.Styles, compact bool, width int) string {
 	return logo.Render(version.Version, compact, logo.Opts{

internal/ui/styles/styles.go 🔗

@@ -8,10 +8,10 @@ import (
 	"charm.land/bubbles/v2/textarea"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/glamour/v2/ansi"
 	"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"
 )
 
@@ -26,14 +26,25 @@ const (
 	DocumentIcon string = "🖼"
 	ModelIcon    string = "◇"
 
+	// Arrow icons
+	ArrowRightIcon string = "→"
+
+	// Tool call icons
 	ToolPending string = "●"
 	ToolSuccess string = "✓"
 	ToolError   string = "×"
 
+	// Border styles
 	BorderThin  string = "│"
 	BorderThick string = "▌"
 
+	// Section separator
 	SectionSeparator string = "─"
+
+	// Todo icons
+	TodoCompletedIcon  string = "✓"
+	TodoPendingIcon    string = "•"
+	TodoInProgressIcon string = "→"
 )
 
 const (
@@ -85,7 +96,8 @@ type Styles struct {
 	ItemOnlineIcon  lipgloss.Style
 
 	// Markdown & Chroma
-	Markdown ansi.StyleConfig
+	Markdown      ansi.StyleConfig
+	PlainMarkdown ansi.StyleConfig
 
 	// Inputs
 	TextInput textinput.Styles
@@ -197,6 +209,11 @@ type Styles struct {
 			ToolCallBlurred  lipgloss.Style
 			ThinkingFooter   lipgloss.Style
 			SectionHeader    lipgloss.Style
+
+			// Section styles - for assistant response metadata
+			SectionIcon     lipgloss.Style // Model icon
+			SectionModel    lipgloss.Style // Model name
+			SectionDuration lipgloss.Style // Response duration
 		}
 	}
 
@@ -661,6 +678,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),
@@ -779,7 +959,7 @@ func DefaultStyles() Styles {
 	// 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.ContentCodeLine = s.Base.Background(bgBase)
 	s.Tool.ContentCodeBg = bgBase
 	s.Tool.BodyPadding = base.PaddingLeft(2)
 
@@ -796,7 +976,7 @@ func DefaultStyles() Styles {
 
 	// 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.NoteTag = base.Padding(0, 2).Background(info).Foreground(white)
 	s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
 
 	// Job header styles
@@ -880,7 +1060,7 @@ func DefaultStyles() Styles {
 	s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
 
 	// Message item styles
-	s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle)
+	s.Chat.Message.Attachment = lipgloss.NewStyle().Background(bgSubtle)
 	s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
 		BorderStyle(messageFocussedBorder).
 		BorderLeft(true).
@@ -889,6 +1069,11 @@ func DefaultStyles() Styles {
 	s.Chat.Message.ThinkingFooter = s.Base
 	s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
 
+	// Section metadata styles
+	s.Chat.Message.SectionIcon = s.Subtle
+	s.Chat.Message.SectionModel = s.Muted
+	s.Chat.Message.SectionDuration = 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) + " "
-	}
-}