refactor(chat): implement user message (#1644)

Kujtim Hoxha created

Change summary

AGENTS.md                           |   3 
cspell.json                         |   0 
internal/ui/AGENTS.md               |  17 
internal/ui/chat/messages.go        | 140 +++++++
internal/ui/chat/user.go            | 122 ++++++
internal/ui/dialog/sessions_item.go |   4 
internal/ui/list/highlight.go       |   8 
internal/ui/model/chat.go           |   5 
internal/ui/model/items.go          | 548 -------------------------------
internal/ui/model/ui.go             |  27 
10 files changed, 304 insertions(+), 570 deletions(-)

Detailed changes

CRUSH.md → AGENTS.md 🔗

@@ -70,3 +70,6 @@ func TestYourFunction(t *testing.T) {
 - ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
 - Try to keep commits to one line, not including your attribution. Only use
   multi-line commits when additional context is truly necessary.
+
+## Working on the TUI (UI)
+Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file

internal/ui/AGENTS.md 🔗

@@ -0,0 +1,17 @@
+# UI Development Instructions
+
+## General guideline
+- Never use commands to send messages when you can directly mutate children or state
+- Keep things simple do not overcomplicated
+- Create files if needed to separate logic do not nest models
+
+## Big model
+Keep most of the logic and state in the main model `internal/ui/model/ui.go`.
+
+
+## When working on components
+Whenever you work on components make them dumb they should not handle bubble tea messages they should have methods.
+
+## When adding logic that has to do with the chat
+Most of the logic with the chat should be in the chat component `internal/ui/model/chat.go`, keep individual items dumb and handle logic in this component.
+

internal/ui/chat/messages.go 🔗

@@ -1,9 +1,147 @@
 package chat
 
-import "github.com/charmbracelet/crush/internal/message"
+import (
+	"image"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// this is the total width that is taken up by the border + padding
+// we also cap the width so text is readable to the maxTextWidth(120)
+const messageLeftPaddingTotal = 2
+
+// 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
+	list.Highlightable
+	list.Focusable
+	Identifiable
+}
 
 // SendMsg represents a message to send a chat message.
 type SendMsg struct {
 	Text        string
 	Attachments []message.Attachment
 }
+
+type highlightableMessageItem struct {
+	startLine   int
+	startCol    int
+	endLine     int
+	endCol      int
+	highlighter list.Highlighter
+}
+
+// isHighlighted returns true if the item has a highlight range set.
+func (h *highlightableMessageItem) isHighlighted() bool {
+	return h.startLine != -1 || h.endLine != -1
+}
+
+// renderHighlighted highlights the content if necessary.
+func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
+	if !h.isHighlighted() {
+		return content
+	}
+	area := image.Rect(0, 0, width, height)
+	return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
+}
+
+// Highlight implements MessageItem.
+func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) {
+	// Adjust columns for the style's left inset (border + padding) since we
+	// highlight the content only.
+	offset := messageLeftPaddingTotal
+	h.startLine = startLine
+	h.startCol = max(0, startCol-offset)
+	h.endLine = endLine
+	if endCol >= 0 {
+		h.endCol = max(0, endCol-offset)
+	} else {
+		h.endCol = endCol
+	}
+}
+
+func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
+	return &highlightableMessageItem{
+		startLine:   -1,
+		startCol:    -1,
+		endLine:     -1,
+		endCol:      -1,
+		highlighter: list.ToHighlighter(sty.TextSelection),
+	}
+}
+
+// cachedMessageItem caches rendered message content to avoid re-rendering.
+//
+// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on
+//
+// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
+// the issue with that could be memory usage
+type cachedMessageItem struct {
+	// rendered is the cached rendered string
+	rendered string
+	// width and height are the dimensions of the cached render
+	width  int
+	height int
+}
+
+// getCachedRender returns the cached render if it exists for the given width.
+func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
+	if c.width == width && c.rendered != "" {
+		return c.rendered, c.height, true
+	}
+	return "", 0, false
+}
+
+// setCachedRender sets the cached render.
+func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
+	c.rendered = rendered
+	c.width = width
+	c.height = height
+}
+
+// cappedMessageWidth returns the maximum width for message content for readability.
+func cappedMessageWidth(availableWidth int) int {
+	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
+}
+
+// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
+// all parts of the message as [MessageItem]s.
+//
+// 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 {
+	switch msg.Role {
+	case message.User:
+		return []MessageItem{NewUserMessageItem(sty, msg)}
+	}
+	return []MessageItem{}
+}
+
+// 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/chat/user.go 🔗

@@ -0,0 +1,122 @@
+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"
+)
+
+// UserMessageItem represents a user message in the chat UI.
+type UserMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	message *message.Message
+	sty     *styles.Styles
+	focused bool
+}
+
+// NewUserMessageItem creates a new UserMessageItem.
+func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
+	return &UserMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		message:                  message,
+		sty:                      sty,
+		focused:                  false,
+	}
+}
+
+// Render implements MessageItem.
+func (m *UserMessageItem) Render(width int) string {
+	cappedWidth := cappedMessageWidth(width)
+
+	style := m.sty.Chat.Message.UserBlurred
+	if m.focused {
+		style = m.sty.Chat.Message.UserFocused
+	}
+
+	content, height, ok := m.getCachedRender(cappedWidth)
+	// cache hit
+	if ok {
+		return style.Render(m.renderHighlighted(content, cappedWidth, height))
+	}
+
+	renderer := common.MarkdownRenderer(m.sty, cappedWidth)
+
+	msgContent := strings.TrimSpace(m.message.Content().Text)
+	result, err := renderer.Render(msgContent)
+	if err != nil {
+		content = msgContent
+	} else {
+		content = strings.TrimSuffix(result, "\n")
+	}
+
+	if len(m.message.BinaryContent()) > 0 {
+		attachmentsStr := m.renderAttachments(cappedWidth)
+		content = strings.Join([]string{content, "", attachmentsStr}, "\n")
+	}
+
+	height = lipgloss.Height(content)
+	m.setCachedRender(content, cappedWidth, height)
+	return style.Render(m.renderHighlighted(content, cappedWidth, height))
+}
+
+// SetFocused implements MessageItem.
+func (m *UserMessageItem) SetFocused(focused bool) {
+	m.focused = focused
+}
+
+// ID implements MessageItem.
+func (m *UserMessageItem) ID() string {
+	return m.message.ID
+}
+
+// renderAttachments renders attachments with wrapping if they exceed the width.
+// TODO: change the styles here so they match the new design
+func (m *UserMessageItem) renderAttachments(width int) string {
+	const maxFilenameWidth = 10
+
+	attachments := make([]string, len(m.message.BinaryContent()))
+	for i, attachment := range m.message.BinaryContent() {
+		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/dialog/sessions_item.go 🔗

@@ -37,7 +37,7 @@ var _ ListItem = &SessionItem{}
 
 // Filter returns the filterable value of the session.
 func (s *SessionItem) Filter() string {
-	return s.Session.Title
+	return s.Title
 }
 
 // ID returns the unique identifier of the session.
@@ -53,7 +53,7 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
 
 // Render returns the string representation of the session item.
 func (s *SessionItem) Render(width int) string {
-	return renderItem(s.t, s.Session.Title, s.Session.UpdatedAt, s.focused, width, s.cache, &s.m)
+	return renderItem(s.t, s.Title, s.UpdatedAt, s.focused, width, s.cache, &s.m)
 }
 
 func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {

internal/ui/list/highlight.go 🔗

@@ -31,6 +31,14 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
 	styled := uv.NewStyledString(content)
 	styled.Draw(&buf, area)
 
+	// Treat -1 as "end of content"
+	if endLine < 0 {
+		endLine = height - 1
+	}
+	if endCol < 0 {
+		endCol = width
+	}
+
 	for y := startLine; y <= endLine && y < height; y++ {
 		if y >= buf.Height() {
 			break

internal/ui/model/chat.go 🔗

@@ -1,6 +1,7 @@
 package model
 
 import (
+	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	uv "github.com/charmbracelet/ultraviolet"
@@ -63,7 +64,7 @@ func (m *Chat) PrependItems(items ...list.Item) {
 }
 
 // SetMessages sets the chat messages to the provided list of message items.
-func (m *Chat) SetMessages(msgs ...MessageItem) {
+func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
 	items := make([]list.Item, len(msgs))
 	for i, msg := range msgs {
 		items[i] = msg
@@ -73,7 +74,7 @@ func (m *Chat) SetMessages(msgs ...MessageItem) {
 }
 
 // AppendMessages appends a new message item to the chat list.
-func (m *Chat) AppendMessages(msgs ...MessageItem) {
+func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
 	items := make([]list.Item, len(msgs))
 	for i, msg := range msgs {
 		items[i] = msg

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 🔗

@@ -26,9 +26,9 @@ import (
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	"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"
@@ -203,13 +203,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, uiutil.ReportError(err))
 			break
 		}
+		m.setSessionMessages(msgs)
 
-		if cmd := m.handleMessageEvents(msgs...); cmd != nil {
-			cmds = append(cmds, cmd)
-		}
 	case pubsub.Event[message.Message]:
 		// TODO: Finish implementing me
-		cmds = append(cmds, m.handleMessageEvents(msg.Payload))
+		// cmds = append(cmds, m.setMessageEvents(msg.Payload))
 	case pubsub.Event[history.File]:
 		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 	case pubsub.Event[app.LSPEvent]:
@@ -337,29 +335,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *UI) handleMessageEvents(msgs ...message.Message) tea.Cmd {
+// setSessionMessages sets the messages for the current session in the chat
+func (m *UI) setSessionMessages(msgs []message.Message) {
 	// Build tool result map to link tool calls with their results
 	msgPtrs := make([]*message.Message, len(msgs))
 	for i := range msgs {
 		msgPtrs[i] = &msgs[i]
 	}
-	toolResultMap := BuildToolResultMap(msgPtrs)
+	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 
 	// Add messages to chat with linked tool results
-	items := make([]MessageItem, 0, len(msgs)*2)
+	items := make([]chat.MessageItem, 0, len(msgs)*2)
 	for _, msg := range msgPtrs {
-		items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
+		items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
 	}
 
-	if m.session == nil || m.session.ID == "" {
-		m.chat.SetMessages(items...)
-	} else {
-		m.chat.AppendMessages(items...)
-	}
+	m.chat.SetMessages(items...)
 	m.chat.ScrollToBottom()
 	m.chat.SelectLast()
-
-	return nil
 }
 
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -1179,7 +1172,7 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
 			return uiutil.ReportError(err)
 		}
 		session = newSession
-		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
+		cmds = append(cmds, m.loadSession(session.ID))
 	}
 	if m.com.App.AgentCoordinator == nil {
 		return util.ReportError(fmt.Errorf("coder agent is not initialized"))