refactor and document

Kujtim Hoxha created

Change summary

.editorconfig                                     |   18 
OpenCode.md                                       |   22 
internal/tui/components/chat/editor.go            |    4 
internal/tui/components/chat/list.go              |  447 ++++++
internal/tui/components/chat/list_v2.go           |  279 ----
internal/tui/components/chat/messages/messages.go |   71 
internal/tui/components/chat/messages/renderer.go |  447 ++++--
internal/tui/components/chat/messages/tool.go     |   92 +
internal/tui/components/core/list/keys.go         |    2 
internal/tui/components/core/list/list.go         | 1093 ++++++++++------
10 files changed, 1,497 insertions(+), 978 deletions(-)

Detailed changes

.editorconfig πŸ”—

@@ -1,18 +0,0 @@
-# https://editorconfig.org/
-
-root = true
-
-[*]
-charset = utf-8
-insert_final_newline = true
-trim_trailing_whitespace = true
-indent_style = space
-indent_size = 2
-
-[*.go]
-indent_style = tab
-indent_size = 4
-
-[*.golden]
-insert_final_newline = false
-trim_trailing_whitespace = false

OpenCode.md πŸ”—

@@ -0,0 +1,22 @@
+# OpenCode Development Guide
+
+## Build/Test/Lint Commands
+
+- **Build**: `go build ./...` or `go build .` (for main binary)
+- **Test**: `task test` or `go test ./...`
+- **Single test**: `go test ./internal/path/to/package -run TestName`
+- **Lint**: `task lint` or `golangci-lint run`
+- **Format**: `task fmt` or `gofumpt -w .`
+
+## Code Style Guidelines
+
+- **Imports**: Standard library first, then third-party, then internal packages (separated by blank lines)
+- **Types**: Use `any` instead of `interface{}`, prefer concrete types over interfaces when possible
+- **Naming**: Use camelCase for private, PascalCase for public, descriptive names (e.g., `messageListCmp`, `handleNewUserMessage`)
+- **Constants**: Use `const` blocks with descriptive names (e.g., `NotFound = -1`)
+- **Error handling**: Always check errors, use `require.NoError()` in tests, return errors up the stack
+- **Documentation**: Add comments for all public types/methods, explain complex logic in private methods
+- **Testing**: Use testify/assert and testify/require, table-driven tests with `t.Run()`, mark helpers with `t.Helper()`
+- **File organization**: Group related functionality, extract helper methods for complex logic, use meaningful method names
+- **TUI components**: Implement interfaces (util.Model, layout.Sizeable), document component purpose and behavior
+- **Message handling**: Use pubsub events, handle different message roles (User/Assistant/Tool), manage tool calls separately

internal/tui/components/chat/editor.go πŸ”—

@@ -185,10 +185,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, nil
 			}
 		}
-		if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
-			key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
-			return m, nil
-		}
 		if key.Matches(msg, editorMaps.OpenEditor) {
 			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
 				return m, util.ReportWarn("Agent is working, please wait...")

internal/tui/components/chat/list.go πŸ”—

@@ -1,29 +1,424 @@
 package chat
 
-import "github.com/charmbracelet/bubbles/v2/key"
-
-type MessageKeys struct {
-	PageDown     key.Binding
-	PageUp       key.Binding
-	HalfPageUp   key.Binding
-	HalfPageDown key.Binding
-}
-
-var messageKeys = MessageKeys{
-	PageDown: key.NewBinding(
-		key.WithKeys("pgdown"),
-		key.WithHelp("f/pgdn", "page down"),
-	),
-	PageUp: key.NewBinding(
-		key.WithKeys("pgup"),
-		key.WithHelp("b/pgup", "page up"),
-	),
-	HalfPageUp: key.NewBinding(
-		key.WithKeys("ctrl+u"),
-		key.WithHelp("ctrl+u", "Β½ page up"),
-	),
-	HalfPageDown: key.NewBinding(
-		key.WithKeys("ctrl+d", "ctrl+d"),
-		key.WithHelp("ctrl+d", "Β½ page down"),
-	),
+import (
+	"context"
+	"time"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/opencode-ai/opencode/internal/app"
+	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/opencode-ai/opencode/internal/pubsub"
+	"github.com/opencode-ai/opencode/internal/session"
+	"github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
+	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
+	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
+	"github.com/opencode-ai/opencode/internal/tui/layout"
+	"github.com/opencode-ai/opencode/internal/tui/util"
+)
+
+const (
+	NotFound = -1
+)
+
+// MessageListCmp represents a component that displays a list of chat messages
+// with support for real-time updates and session management.
+type MessageListCmp interface {
+	util.Model
+	layout.Sizeable
+}
+
+// messageListCmp implements MessageListCmp, providing a virtualized list
+// of chat messages with support for tool calls, real-time updates, and
+// session switching.
+type messageListCmp struct {
+	app           *app.App
+	width, height int
+	session       session.Session
+	listCmp       list.ListModel
+
+	lastUserMessageTime int64
+}
+
+// NewMessagesListCmp creates a new message list component with custom keybindings
+// and reverse ordering (newest messages at bottom).
+func NewMessagesListCmp(app *app.App) MessageListCmp {
+	defaultKeymaps := list.DefaultKeymap()
+	defaultKeymaps.NDown.SetEnabled(false)
+	defaultKeymaps.NUp.SetEnabled(false)
+	defaultKeymaps.Home = key.NewBinding(
+		key.WithKeys("ctrl+g"),
+	)
+	defaultKeymaps.End = key.NewBinding(
+		key.WithKeys("ctrl+G"),
+	)
+	return &messageListCmp{
+		app: app,
+		listCmp: list.New(
+			list.WithGapSize(1),
+			list.WithReverse(true),
+			list.WithKeyMap(defaultKeymaps),
+		),
+	}
+}
+
+// Init initializes the component (no initialization needed).
+func (m *messageListCmp) Init() tea.Cmd {
+	return nil
+}
+
+// Update handles incoming messages and updates the component state.
+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case dialog.ThemeChangedMsg:
+		m.listCmp.ResetView()
+		return m, nil
+	case SessionSelectedMsg:
+		if msg.ID != m.session.ID {
+			cmd := m.SetSession(msg)
+			return m, cmd
+		}
+		return m, nil
+	case SessionClearedMsg:
+		m.session = session.Session{}
+		return m, m.listCmp.SetItems([]util.Model{})
+
+	case pubsub.Event[message.Message]:
+		cmd := m.handleMessageEvent(msg)
+		return m, cmd
+	default:
+		var cmds []tea.Cmd
+		u, cmd := m.listCmp.Update(msg)
+		m.listCmp = u.(list.ListModel)
+		cmds = append(cmds, cmd)
+		return m, tea.Batch(cmds...)
+	}
+}
+
+// View renders the message list or an initial screen if empty.
+func (m *messageListCmp) View() string {
+	if len(m.listCmp.Items()) == 0 {
+		return initialScreen()
+	}
+	return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
+}
+
+// handleChildSession handles messages from child sessions (agent tools).
+// TODO: update the agent tool message with the changes
+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
+	// Implementation pending
+}
+
+// handleMessageEvent processes different types of message events (created/updated).
+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
+	switch event.Type {
+	case pubsub.CreatedEvent:
+		if event.Payload.SessionID != m.session.ID {
+			m.handleChildSession(event)
+			return nil
+		}
+		
+		if m.messageExists(event.Payload.ID) {
+			return nil
+		}
+		
+		return m.handleNewMessage(event.Payload)
+	case pubsub.UpdatedEvent:
+		return m.handleUpdateAssistantMessage(event.Payload)
+	}
+	return nil
+}
+
+// messageExists checks if a message with the given ID already exists in the list.
+func (m *messageListCmp) messageExists(messageID string) bool {
+	items := m.listCmp.Items()
+	// Search backwards as new messages are more likely to be at the end
+	for i := len(items) - 1; i >= 0; i-- {
+		if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
+			return true
+		}
+	}
+	return false
+}
+
+// handleNewMessage routes new messages to appropriate handlers based on role.
+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
+	switch msg.Role {
+	case message.User:
+		return m.handleNewUserMessage(msg)
+	case message.Assistant:
+		return m.handleNewAssistantMessage(msg)
+	case message.Tool:
+		return m.handleToolMessage(msg)
+	}
+	return nil
+}
+
+// handleNewUserMessage adds a new user message to the list and updates the timestamp.
+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
+	m.lastUserMessageTime = msg.CreatedAt
+	return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
+}
+
+// handleToolMessage updates existing tool calls with their results.
+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
+	items := m.listCmp.Items()
+	for _, tr := range msg.ToolResults() {
+		if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
+			toolCall := items[toolCallIndex].(messages.ToolCallCmp)
+			toolCall.SetToolResult(tr)
+			m.listCmp.UpdateItem(toolCallIndex, toolCall)
+		}
+	}
+	return nil
+}
+
+// findToolCallByID searches for a tool call with the specified ID.
+// Returns the index if found, NotFound otherwise.
+func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
+	// Search backwards as tool calls are more likely to be recent
+	for i := len(items) - 1; i >= 0; i-- {
+		if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
+			return i
+		}
+	}
+	return NotFound
+}
+
+// handleUpdateAssistantMessage processes updates to assistant messages,
+// managing both message content and associated tool calls.
+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	items := m.listCmp.Items()
+	
+	// Find existing assistant message and tool calls for this message
+	assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
+	
+	logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantIndex, "toolCalls", existingToolCalls)
+	
+	// Handle assistant message content
+	if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	
+	// Handle tool calls
+	if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	
+	return tea.Batch(cmds...)
+}
+
+// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
+	assistantIndex := NotFound
+	toolCalls := make(map[int]messages.ToolCallCmp)
+	
+	// Search backwards as messages are more likely to be at the end
+	for i := len(items) - 1; i >= 0; i-- {
+		item := items[i]
+		if asMsg, ok := item.(messages.MessageCmp); ok {
+			if asMsg.GetMessage().ID == messageID {
+				assistantIndex = i
+			}
+		} else if tc, ok := item.(messages.ToolCallCmp); ok {
+			if tc.ParentMessageId() == messageID {
+				toolCalls[i] = tc
+			}
+		}
+	}
+	
+	return assistantIndex, toolCalls
+}
+
+// updateAssistantMessageContent updates or removes the assistant message based on content.
+func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
+	if assistantIndex == NotFound {
+		return nil
+	}
+	
+	shouldShowMessage := m.shouldShowAssistantMessage(msg)
+	hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
+	
+	if shouldShowMessage {
+		m.listCmp.UpdateItem(
+			assistantIndex,
+			messages.NewMessageCmp(
+				msg,
+				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+			),
+		)
+	} else if hasToolCallsOnly {
+		m.listCmp.DeleteItem(assistantIndex)
+	}
+	
+	return nil
+}
+
+// shouldShowAssistantMessage determines if an assistant message should be displayed.
+func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
+	return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
+}
+
+// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
+func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
+	var cmds []tea.Cmd
+	
+	for _, tc := range msg.ToolCalls() {
+		if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	
+	return tea.Batch(cmds...)
+}
+
+// updateOrAddToolCall updates an existing tool call or adds a new one.
+func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
+	// Try to find existing tool call
+	for index, existingTC := range existingToolCalls {
+		if tc.ID == existingTC.GetToolCall().ID {
+			existingTC.SetToolCall(tc)
+			m.listCmp.UpdateItem(index, existingTC)
+			return nil
+		}
+	}
+	
+	// Add new tool call if not found
+	return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
+}
+
+// handleNewAssistantMessage processes new assistant messages and their tool calls.
+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	
+	// Add assistant message if it should be displayed
+	if m.shouldShowAssistantMessage(msg) {
+		cmd := m.listCmp.AppendItem(
+			messages.NewMessageCmp(
+				msg,
+				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+			),
+		)
+		cmds = append(cmds, cmd)
+	}
+	
+	// Add tool calls
+	for _, tc := range msg.ToolCalls() {
+		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
+		cmds = append(cmds, cmd)
+	}
+	
+	return tea.Batch(cmds...)
+}
+
+// SetSession loads and displays messages for a new session.
+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
+	if m.session.ID == session.ID {
+		return nil
+	}
+	
+	m.session = session
+	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
+	if err != nil {
+		return util.ReportError(err)
+	}
+	
+	if len(sessionMessages) == 0 {
+		return m.listCmp.SetItems([]util.Model{})
+	}
+	
+	// Initialize with first message timestamp
+	m.lastUserMessageTime = sessionMessages[0].CreatedAt
+	
+	// Build tool result map for efficient lookup
+	toolResultMap := m.buildToolResultMap(sessionMessages)
+	
+	// Convert messages to UI components
+	uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
+	
+	return m.listCmp.SetItems(uiMessages)
+}
+
+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
+func (m *messageListCmp) 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
+}
+
+// convertMessagesToUI converts database messages to UI components.
+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
+	uiMessages := make([]util.Model, 0)
+	
+	for _, msg := range sessionMessages {
+		switch msg.Role {
+		case message.User:
+			m.lastUserMessageTime = msg.CreatedAt
+			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
+		case message.Assistant:
+			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
+		}
+	}
+	
+	return uiMessages
+}
+
+// convertAssistantMessage converts an assistant message and its tool calls to UI components.
+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
+	var uiMessages []util.Model
+	
+	// Add assistant message if it should be displayed
+	if m.shouldShowAssistantMessage(msg) {
+		uiMessages = append(
+			uiMessages,
+			messages.NewMessageCmp(
+				msg,
+				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+			),
+		)
+	}
+	
+	// Add tool calls with their results and status
+	for _, tc := range msg.ToolCalls() {
+		options := m.buildToolCallOptions(tc, msg, toolResultMap)
+		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
+	}
+	
+	return uiMessages
+}
+
+// buildToolCallOptions creates options for tool call components based on results and status.
+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
+	var options []messages.ToolCallOption
+	
+	// Add tool result if available
+	if tr, ok := toolResultMap[tc.ID]; ok {
+		options = append(options, messages.WithToolCallResult(tr))
+	}
+	
+	// Add cancelled status if applicable
+	if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
+		options = append(options, messages.WithToolCallCancelled())
+	}
+	
+	return options
+}
+
+// GetSize returns the current width and height of the component.
+func (m *messageListCmp) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+// SetSize updates the component dimensions and propagates to the list component.
+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	m.height = height - 1
+	return m.listCmp.SetSize(width, height-1)
 }

internal/tui/components/chat/list_v2.go πŸ”—

@@ -1,279 +0,0 @@
-package chat
-
-import (
-	"context"
-	"time"
-
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/opencode-ai/opencode/internal/app"
-	"github.com/opencode-ai/opencode/internal/logging"
-	"github.com/opencode-ai/opencode/internal/message"
-	"github.com/opencode-ai/opencode/internal/pubsub"
-	"github.com/opencode-ai/opencode/internal/session"
-	"github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
-	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
-	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
-	"github.com/opencode-ai/opencode/internal/tui/layout"
-	"github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type MessageListCmp interface {
-	util.Model
-	layout.Sizeable
-}
-
-type messageListCmp struct {
-	app           *app.App
-	width, height int
-	session       session.Session
-	listCmp       list.ListModel
-
-	lastUserMessageTime int64
-}
-
-func NewMessagesListCmp(app *app.App) MessageListCmp {
-	return &messageListCmp{
-		app: app,
-		listCmp: list.New(
-			list.WithGapSize(1),
-			list.WithReverse(true),
-		),
-	}
-}
-
-func (m *messageListCmp) Init() tea.Cmd {
-	return nil
-}
-
-func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case dialog.ThemeChangedMsg:
-		m.listCmp.ResetView()
-		return m, nil
-	case SessionSelectedMsg:
-		if msg.ID != m.session.ID {
-			cmd := m.SetSession(msg)
-			return m, cmd
-		}
-		return m, nil
-	case SessionClearedMsg:
-		m.session = session.Session{}
-		return m, m.listCmp.SetItems([]util.Model{})
-
-	case pubsub.Event[message.Message]:
-		cmd := m.handleMessageEvent(msg)
-		return m, cmd
-	default:
-		var cmds []tea.Cmd
-		u, cmd := m.listCmp.Update(msg)
-		m.listCmp = u.(list.ListModel)
-		cmds = append(cmds, cmd)
-		return m, tea.Batch(cmds...)
-	}
-}
-
-func (m *messageListCmp) View() string {
-	if len(m.listCmp.Items()) == 0 {
-		return initialScreen()
-	}
-	return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
-}
-
-func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
-	// TODO: update the agent tool message with the changes
-}
-
-func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
-	switch event.Type {
-	case pubsub.CreatedEvent:
-		if event.Payload.SessionID != m.session.ID {
-			m.handleChildSession(event)
-		}
-		messageExists := false
-		// more likely to be at the end of the list
-		items := m.listCmp.Items()
-		for i := len(items) - 1; i >= 0; i-- {
-			msg, ok := items[i].(messages.MessageCmp)
-			if ok && msg.GetMessage().ID == event.Payload.ID {
-				messageExists = true
-				break
-			}
-		}
-		if messageExists {
-			return nil
-		}
-		switch event.Payload.Role {
-		case message.User:
-			return m.handleNewUserMessage(event.Payload)
-		case message.Assistant:
-			return m.handleNewAssistantMessage(event.Payload)
-		case message.Tool:
-			return m.handleToolMessage(event.Payload)
-		}
-	case pubsub.UpdatedEvent:
-		return m.handleUpdateAssistantMessage(event.Payload)
-	}
-	return nil
-}
-
-func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
-	m.lastUserMessageTime = msg.CreatedAt
-	return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
-}
-
-func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
-	items := m.listCmp.Items()
-	for _, tr := range msg.ToolResults() {
-		for i := len(items) - 1; i >= 0; i-- {
-			message := items[i]
-			if toolCall, ok := message.(messages.ToolCallCmp); ok {
-				if toolCall.GetToolCall().ID == tr.ToolCallID {
-					toolCall.SetToolResult(tr)
-					m.listCmp.UpdateItem(
-						i,
-						toolCall,
-					)
-					break
-				}
-			}
-		}
-	}
-	return nil
-}
-
-func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
-	var cmds []tea.Cmd
-	// Simple update the content
-	items := m.listCmp.Items()
-	assistantMessageInx := -1
-	toolCalls := map[int]messages.ToolCallCmp{}
-
-	// we go backwards because the messages are most likely at the end of the list
-	for i := len(items) - 1; i >= 0; i-- {
-		message := items[i]
-		if asMsg, ok := message.(messages.MessageCmp); ok {
-			if asMsg.GetMessage().ID == msg.ID {
-				assistantMessageInx = i
-			}
-		} else if tc, ok := message.(messages.ToolCallCmp); ok {
-			if tc.ParentMessageId() == msg.ID {
-				toolCalls[i] = tc
-			}
-		}
-	}
-
-	logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantMessageInx, "toolCalls", toolCalls)
-
-	if assistantMessageInx > -1 && (len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()) {
-		m.listCmp.UpdateItem(
-			assistantMessageInx,
-			messages.NewMessageCmp(
-				msg,
-				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
-			),
-		)
-	} else if assistantMessageInx > -1 && len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
-		m.listCmp.DeleteItem(assistantMessageInx)
-	}
-	for _, tc := range msg.ToolCalls() {
-		found := false
-		for inx, tcc := range toolCalls {
-			if tc.ID == tcc.GetToolCall().ID {
-				tcc.SetToolCall(tc)
-				m.listCmp.UpdateItem(
-					inx,
-					tcc,
-				)
-				found = true
-				break
-			}
-		}
-		if !found {
-			cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
-			cmds = append(cmds, cmd)
-		}
-	}
-
-	return tea.Batch(cmds...)
-}
-
-func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
-	var cmds []tea.Cmd
-	// Only add assistant messages if they don't have tool calls or there is some content
-	if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
-		cmd := m.listCmp.AppendItem(
-			messages.NewMessageCmp(
-				msg,
-				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
-			),
-		)
-		cmds = append(cmds, cmd)
-	}
-	for _, tc := range msg.ToolCalls() {
-		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
-		cmds = append(cmds, cmd)
-	}
-	return tea.Batch(cmds...)
-}
-
-func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
-	if m.session.ID == session.ID {
-		return nil
-	}
-	m.session = session
-	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
-	if err != nil {
-		return util.ReportError(err)
-	}
-	uiMessages := make([]util.Model, 0)
-	m.lastUserMessageTime = sessionMessages[0].CreatedAt
-	toolResultMap := make(map[string]message.ToolResult)
-	// first pass to get all tool results
-	for _, msg := range sessionMessages {
-		for _, tr := range msg.ToolResults() {
-			toolResultMap[tr.ToolCallID] = tr
-		}
-	}
-	for _, msg := range sessionMessages {
-		switch msg.Role {
-		case message.User:
-			m.lastUserMessageTime = msg.CreatedAt
-			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
-		case message.Assistant:
-			// Only add assistant messages if they don't have tool calls or there is some content
-			if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
-				uiMessages = append(
-					uiMessages,
-					messages.NewMessageCmp(
-						msg,
-						messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
-					),
-				)
-			}
-			for _, tc := range msg.ToolCalls() {
-				options := []messages.ToolCallOption{}
-				if tr, ok := toolResultMap[tc.ID]; ok {
-					options = append(options, messages.WithToolCallResult(tr))
-				}
-				if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
-					options = append(options, messages.WithToolCallCancelled())
-				}
-				uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
-			}
-		}
-	}
-	return m.listCmp.SetItems(uiMessages)
-}
-
-// GetSize implements MessageListCmp.
-func (m *messageListCmp) GetSize() (int, int) {
-	return m.width, m.height
-}
-
-// SetSize implements MessageListCmp.
-func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
-	m.width = width
-	m.height = height - 1
-	return m.listCmp.SetSize(width, height-1)
-}

internal/tui/components/chat/messages/messages.go πŸ”—

@@ -19,32 +19,42 @@ import (
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
+// MessageCmp defines the interface for message components in the chat interface.
+// It combines standard UI model interfaces with message-specific functionality.
 type MessageCmp interface {
-	util.Model
-	layout.Sizeable
-	layout.Focusable
-	GetMessage() message.Message
-	Spinning() bool
+	util.Model                   // Basic Bubble Tea model interface
+	layout.Sizeable              // Width/height management
+	layout.Focusable             // Focus state management
+	GetMessage() message.Message // Access to underlying message data
+	Spinning() bool              // Animation state for loading messages
 }
 
+// messageCmp implements the MessageCmp interface for displaying chat messages.
+// It handles rendering of user and assistant messages with proper styling,
+// animations, and state management.
 type messageCmp struct {
-	width   int
-	focused bool
-
-	// Used for agent and user messages
-	message             message.Message
-	spinning            bool
-	anim                util.Model
-	lastUserMessageTime time.Time
+	width   int  // Component width for text wrapping
+	focused bool // Focus state for border styling
+
+	// Core message data and state
+	message             message.Message // The underlying message content
+	spinning            bool            // Whether to show loading animation
+	anim                util.Model      // Animation component for loading states
+	lastUserMessageTime time.Time       // Used for calculating response duration
 }
+
+// MessageOption provides functional options for configuring message components
 type MessageOption func(*messageCmp)
 
+// WithLastUserMessageTime sets the timestamp of the last user message
+// for calculating assistant response duration
 func WithLastUserMessageTime(t time.Time) MessageOption {
 	return func(m *messageCmp) {
 		m.lastUserMessageTime = t
 	}
 }
 
+// NewMessageCmp creates a new message component with the given message and options
 func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
 	m := &messageCmp{
 		message: msg,
@@ -56,6 +66,8 @@ func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
 	return m
 }
 
+// Init initializes the message component and starts animations if needed.
+// Returns a command to start the animation for spinning messages.
 func (m *messageCmp) Init() tea.Cmd {
 	m.spinning = m.shouldSpin()
 	if m.spinning {
@@ -64,6 +76,8 @@ func (m *messageCmp) Init() tea.Cmd {
 	return nil
 }
 
+// Update handles incoming messages and updates the component state.
+// Manages animation updates for spinning messages and stops animation when appropriate.
 func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case anim.ColorCycleMsg, anim.StepCharsMsg:
@@ -77,6 +91,8 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
+// View renders the message component based on its current state.
+// Returns different views for spinning, user, and assistant messages.
 func (m *messageCmp) View() string {
 	if m.spinning {
 		return m.style().PaddingLeft(1).Render(m.anim.View())
@@ -93,15 +109,19 @@ func (m *messageCmp) View() string {
 	return "Unknown Message"
 }
 
-// GetMessage implements MessageCmp.
+// GetMessage returns the underlying message data
 func (m *messageCmp) GetMessage() message.Message {
 	return m.message
 }
 
+// textWidth calculates the available width for text content,
+// accounting for borders and padding
 func (m *messageCmp) textWidth() int {
 	return m.width - 1 // take into account the border
 }
 
+// style returns the lipgloss style for the message component.
+// Applies different border colors and styles based on message role and focus state.
 func (msg *messageCmp) style() lipgloss.Style {
 	t := theme.CurrentTheme()
 	var borderColor color.Color
@@ -127,6 +147,8 @@ func (msg *messageCmp) style() lipgloss.Style {
 		BorderStyle(borderStyle)
 }
 
+// renderAssistantMessage renders assistant messages with optional footer information.
+// Shows model name, response time, and finish reason when the message is complete.
 func (m *messageCmp) renderAssistantMessage() string {
 	parts := []string{
 		m.markdownContent(),
@@ -156,6 +178,8 @@ func (m *messageCmp) renderAssistantMessage() string {
 	return m.style().Render(joined)
 }
 
+// renderUserMessage renders user messages with file attachments.
+// Displays message content and any attached files with appropriate icons.
 func (m *messageCmp) renderUserMessage() string {
 	t := theme.CurrentTheme()
 	parts := []string{
@@ -183,12 +207,15 @@ func (m *messageCmp) renderUserMessage() string {
 	return m.style().Render(joined)
 }
 
+// toMarkdown converts text content to rendered markdown using the configured renderer
 func (m *messageCmp) toMarkdown(content string) string {
 	r := styles.GetMarkdownRenderer(m.textWidth())
 	rendered, _ := r.Render(content)
 	return strings.TrimSuffix(rendered, "\n")
 }
 
+// markdownContent processes the message content and handles special states.
+// Returns appropriate content for thinking, finished, and error states.
 func (m *messageCmp) markdownContent() string {
 	content := m.message.Content().String()
 	if m.message.Role == message.Assistant {
@@ -210,6 +237,8 @@ func (m *messageCmp) markdownContent() string {
 	return m.toMarkdown(content)
 }
 
+// shouldSpin determines whether the message should show a loading animation.
+// Only assistant messages without content that aren't finished should spin.
 func (m *messageCmp) shouldSpin() bool {
 	if m.message.Role != message.Assistant {
 		return false
@@ -225,33 +254,39 @@ func (m *messageCmp) shouldSpin() bool {
 	return true
 }
 
-// Blur implements MessageModel.
+// Focus management methods
+
+// Blur removes focus from the message component
 func (m *messageCmp) Blur() tea.Cmd {
 	m.focused = false
 	return nil
 }
 
-// Focus implements MessageModel.
+// Focus sets focus on the message component
 func (m *messageCmp) Focus() tea.Cmd {
 	m.focused = true
 	return nil
 }
 
-// IsFocused implements MessageModel.
+// IsFocused returns whether the message component is currently focused
 func (m *messageCmp) IsFocused() bool {
 	return m.focused
 }
 
+// Size management methods
+
+// GetSize returns the current dimensions of the message component
 func (m *messageCmp) GetSize() (int, int) {
 	return m.width, 0
 }
 
+// SetSize updates the width of the message component for text wrapping
 func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
 	m.width = width
 	return nil
 }
 
-// Spinning implements MessageCmp.
+// Spinning returns whether the message is currently showing a loading animation
 func (m *messageCmp) Spinning() bool {
 	return m.spinning
 }

internal/tui/components/chat/messages/renderer.go πŸ”—

@@ -17,19 +17,26 @@ import (
 	"github.com/opencode-ai/opencode/internal/tui/theme"
 )
 
+// responseContextHeight limits the number of lines displayed in tool output
 const responseContextHeight = 10
 
+// renderer defines the interface for tool-specific rendering implementations
 type renderer interface {
 	// Render returns the complete (already styled) tool‑call view, not
 	// including the outer border.
 	Render(v *toolCallCmp) string
 }
 
+// rendererFactory creates new renderer instances
 type rendererFactory func() renderer
 
+// renderRegistry manages the mapping of tool names to their renderers
 type renderRegistry map[string]rendererFactory
 
+// register adds a new renderer factory to the registry
 func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
+
+// lookup retrieves a renderer for the given tool name, falling back to generic renderer
 func (rr renderRegistry) lookup(name string) renderer {
 	if f, ok := rr[name]; ok {
 		return f()
@@ -37,9 +44,67 @@ func (rr renderRegistry) lookup(name string) renderer {
 	return genericRenderer{} // sensible fallback
 }
 
+// registry holds all registered tool renderers
 var registry = renderRegistry{}
 
-// Registger tool renderers
+// baseRenderer provides common functionality for all tool renderers
+type baseRenderer struct{}
+
+// 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)}
+}
+
+// addMain adds the main parameter (first argument)
+func (pb *paramBuilder) addMain(value string) *paramBuilder {
+	if value != "" {
+		pb.args = append(pb.args, value)
+	}
+	return pb
+}
+
+// addKeyValue adds a key-value pair parameter
+func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
+	if value != "" {
+		pb.args = append(pb.args, key, value)
+	}
+	return pb
+}
+
+// addFlag adds a boolean flag parameter
+func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
+	if value {
+		pb.args = append(pb.args, key, "true")
+	}
+	return pb
+}
+
+// build returns the final parameter list
+func (pb *paramBuilder) build() []string {
+	return pb.args
+}
+
+// renderWithParams provides a common rendering pattern for tools with parameters
+func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
+	header := makeHeader(toolName, v.textWidth(), args...)
+	if res, done := earlyState(header, v); done {
+		return res
+	}
+	body := contentRenderer()
+	return joinHeaderBody(header, body)
+}
+
+// unmarshalParams safely unmarshals JSON parameters
+func (br baseRenderer) unmarshalParams(input string, target any) error {
+	return json.Unmarshal([]byte(input), target)
+}
+
+// Register tool renderers
 func init() {
 	registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
 	registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
@@ -58,304 +123,358 @@ func init() {
 //  Generic renderer
 // -----------------------------------------------------------------------------
 
-type genericRenderer struct{}
+// genericRenderer handles unknown tool types with basic parameter display
+type genericRenderer struct {
+	baseRenderer
+}
 
-func (genericRenderer) Render(v *toolCallCmp) string {
-	header := makeHeader(prettifyToolName(v.call.Name), v.textWidth(), v.call.Input)
-	if res, done := earlyState(header, v); done {
-		return res
-	}
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
+// Render displays the tool call with its raw input and plain content output
+func (gr genericRenderer) Render(v *toolCallCmp) string {
+	return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
 }
 
 // -----------------------------------------------------------------------------
 //  Bash renderer
 // -----------------------------------------------------------------------------
 
-type bashRenderer struct{}
-
-func (bashRenderer) Render(v *toolCallCmp) string {
-	var p tools.BashParams
-	_ = json.Unmarshal([]byte(v.call.Input), &p)
+// bashRenderer handles bash command execution display
+type bashRenderer struct {
+	baseRenderer
+}
 
-	cmd := strings.ReplaceAll(p.Command, "\n", " ")
-	header := makeHeader("Bash", v.textWidth(), cmd)
-	if res, done := earlyState(header, v); done {
-		return res
+// Render displays the bash command with sanitized newlines and plain output
+func (br bashRenderer) Render(v *toolCallCmp) string {
+	var params tools.BashParams
+	if err := br.unmarshalParams(v.call.Input, &params); err != nil {
+		return br.renderError(v, "Invalid bash parameters")
 	}
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
+
+	cmd := strings.ReplaceAll(params.Command, "\n", " ")
+	args := newParamBuilder().addMain(cmd).build()
+
+	return br.renderWithParams(v, "Bash", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
+}
+
+// renderError provides consistent error rendering
+func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
+	header := makeHeader("Error", v.textWidth(), message)
+	return joinHeaderBody(header, "")
 }
 
 // -----------------------------------------------------------------------------
 //  View renderer
 // -----------------------------------------------------------------------------
 
-type viewRenderer struct{}
+// viewRenderer handles file viewing with syntax highlighting and line numbers
+type viewRenderer struct {
+	baseRenderer
+}
 
-func (viewRenderer) Render(v *toolCallCmp) string {
+// Render displays file content with optional limit and offset parameters
+func (vr viewRenderer) Render(v *toolCallCmp) string {
 	var params tools.ViewParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
+	if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
+		return vr.renderError(v, "Invalid view parameters")
+	}
 
 	file := removeWorkingDirPrefix(params.FilePath)
-	args := []string{file}
-	if params.Limit != 0 {
-		args = append(args, "limit", fmt.Sprintf("%d", params.Limit))
-	}
-	if params.Offset != 0 {
-		args = append(args, "offset", fmt.Sprintf("%d", params.Offset))
-	}
+	args := newParamBuilder().
+		addMain(file).
+		addKeyValue("limit", formatNonZero(params.Limit)).
+		addKeyValue("offset", formatNonZero(params.Offset)).
+		build()
+
+	return vr.renderWithParams(v, "View", args, func() string {
+		var meta tools.ViewResponseMetadata
+		if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
+			return renderPlainContent(v, v.result.Content)
+		}
+		return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
+	})
+}
 
-	header := makeHeader("View", v.textWidth(), args...)
-	if res, done := earlyState(header, v); done {
-		return res
+// formatNonZero returns string representation of non-zero integers, empty string for zero
+func formatNonZero(value int) string {
+	if value == 0 {
+		return ""
 	}
-
-	var meta tools.ViewResponseMetadata
-	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
-
-	body := renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
-	return joinHeaderBody(header, body)
+	return fmt.Sprintf("%d", value)
 }
 
 // -----------------------------------------------------------------------------
 //  Edit renderer
 // -----------------------------------------------------------------------------
 
-type editRenderer struct{}
+// editRenderer handles file editing with diff visualization
+type editRenderer struct {
+	baseRenderer
+}
 
-func (editRenderer) Render(v *toolCallCmp) string {
+// Render displays the edited file with a formatted diff of changes
+func (er editRenderer) Render(v *toolCallCmp) string {
 	var params tools.EditParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
+	if err := er.unmarshalParams(v.call.Input, &params); err != nil {
+		return er.renderError(v, "Invalid edit parameters")
+	}
 
 	file := removeWorkingDirPrefix(params.FilePath)
-	header := makeHeader("Edit", v.textWidth(), file)
-	if res, done := earlyState(header, v); done {
-		return res
-	}
+	args := newParamBuilder().addMain(file).build()
 
-	var meta tools.EditResponseMetadata
-	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+	return er.renderWithParams(v, "Edit", args, func() string {
+		var meta tools.EditResponseMetadata
+		if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
+			return renderPlainContent(v, v.result.Content)
+		}
 
-	trunc := truncateHeight(meta.Diff, responseContextHeight)
-	diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
-	return joinHeaderBody(header, diffView)
+		trunc := truncateHeight(meta.Diff, responseContextHeight)
+		diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
+		return diffView
+	})
 }
 
 // -----------------------------------------------------------------------------
 //  Write renderer
 // -----------------------------------------------------------------------------
 
-type writeRenderer struct{}
+// writeRenderer handles file writing with syntax-highlighted content preview
+type writeRenderer struct {
+	baseRenderer
+}
 
-func (writeRenderer) Render(v *toolCallCmp) string {
+// Render displays the file being written with syntax highlighting
+func (wr writeRenderer) Render(v *toolCallCmp) string {
 	var params tools.WriteParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
+	if err := wr.unmarshalParams(v.call.Input, &params); err != nil {
+		return wr.renderError(v, "Invalid write parameters")
+	}
 
 	file := removeWorkingDirPrefix(params.FilePath)
-	header := makeHeader("Write", v.textWidth(), file)
-	if res, done := earlyState(header, v); done {
-		return res
-	}
+	args := newParamBuilder().addMain(file).build()
 
-	body := renderCodeContent(v, file, params.Content, 0)
-	return joinHeaderBody(header, body)
+	return wr.renderWithParams(v, "Write", args, func() string {
+		return renderCodeContent(v, file, params.Content, 0)
+	})
 }
 
 // -----------------------------------------------------------------------------
 //  Fetch renderer
 // -----------------------------------------------------------------------------
 
-type fetchRenderer struct{}
+// fetchRenderer handles URL fetching with format-specific content display
+type fetchRenderer struct {
+	baseRenderer
+}
 
-func (fetchRenderer) Render(v *toolCallCmp) string {
+// Render displays the fetched URL with format and timeout parameters
+func (fr fetchRenderer) Render(v *toolCallCmp) string {
 	var params tools.FetchParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
-
-	args := []string{params.URL}
-	if params.Format != "" {
-		args = append(args, "format", params.Format)
-	}
-	if params.Timeout != 0 {
-		args = append(args, "timeout", (time.Duration(params.Timeout) * time.Second).String())
+	if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
+		return fr.renderError(v, "Invalid fetch parameters")
 	}
 
-	header := makeHeader("Fetch", v.textWidth(), args...)
-	if res, done := earlyState(header, v); done {
-		return res
-	}
+	args := newParamBuilder().
+		addMain(params.URL).
+		addKeyValue("format", params.Format).
+		addKeyValue("timeout", formatTimeout(params.Timeout)).
+		build()
 
-	file := "fetch.md"
-	switch params.Format {
+	return fr.renderWithParams(v, "Fetch", args, func() string {
+		file := fr.getFileExtension(params.Format)
+		return renderCodeContent(v, file, v.result.Content, 0)
+	})
+}
+
+// getFileExtension returns appropriate file extension for syntax highlighting
+func (fr fetchRenderer) getFileExtension(format string) string {
+	switch format {
 	case "text":
-		file = "fetch.txt"
+		return "fetch.txt"
 	case "html":
-		file = "fetch.html"
+		return "fetch.html"
+	default:
+		return "fetch.md"
 	}
+}
 
-	body := renderCodeContent(v, file, v.result.Content, 0)
-	return joinHeaderBody(header, body)
+// formatTimeout converts timeout seconds to duration string
+func formatTimeout(timeout int) string {
+	if timeout == 0 {
+		return ""
+	}
+	return (time.Duration(timeout) * time.Second).String()
 }
 
 // -----------------------------------------------------------------------------
 //  Glob renderer
 // -----------------------------------------------------------------------------
 
-type globRenderer struct{}
+// globRenderer handles file pattern matching with path filtering
+type globRenderer struct {
+	baseRenderer
+}
 
-func (globRenderer) Render(v *toolCallCmp) string {
+// Render displays the glob pattern with optional path parameter
+func (gr globRenderer) Render(v *toolCallCmp) string {
 	var params tools.GlobParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
-
-	args := []string{params.Pattern}
-	if params.Path != "" {
-		args = append(args, "path", params.Path)
+	if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
+		return gr.renderError(v, "Invalid glob parameters")
 	}
 
-	header := makeHeader("Glob", v.textWidth(), args...)
-	if res, done := earlyState(header, v); done {
-		return res
-	}
+	args := newParamBuilder().
+		addMain(params.Pattern).
+		addKeyValue("path", params.Path).
+		build()
 
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
+	return gr.renderWithParams(v, "Glob", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
 }
 
 // -----------------------------------------------------------------------------
 //  Grep renderer
 // -----------------------------------------------------------------------------
 
-type grepRenderer struct{}
+// grepRenderer handles content searching with pattern matching options
+type grepRenderer struct {
+	baseRenderer
+}
 
-func (grepRenderer) Render(v *toolCallCmp) string {
+// Render displays the search pattern with path, include, and literal text options
+func (gr grepRenderer) Render(v *toolCallCmp) string {
 	var params tools.GrepParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
-
-	args := []string{params.Pattern}
-	if params.Path != "" {
-		args = append(args, "path", params.Path)
-	}
-	if params.Include != "" {
-		args = append(args, "include", params.Include)
-	}
-	if params.LiteralText {
-		args = append(args, "literal", "true")
+	if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
+		return gr.renderError(v, "Invalid grep parameters")
 	}
 
-	header := makeHeader("Grep", v.textWidth(), args...)
-	if res, done := earlyState(header, v); done {
-		return res
-	}
+	args := newParamBuilder().
+		addMain(params.Pattern).
+		addKeyValue("path", params.Path).
+		addKeyValue("include", params.Include).
+		addFlag("literal", params.LiteralText).
+		build()
 
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
+	return gr.renderWithParams(v, "Grep", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
 }
 
 // -----------------------------------------------------------------------------
 //  LS renderer
 // -----------------------------------------------------------------------------
 
-type lsRenderer struct{}
+// lsRenderer handles directory listing with default path handling
+type lsRenderer struct {
+	baseRenderer
+}
 
-func (lsRenderer) Render(v *toolCallCmp) string {
+// Render displays the directory path, defaulting to current directory
+func (lr lsRenderer) Render(v *toolCallCmp) string {
 	var params tools.LSParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
+	if err := lr.unmarshalParams(v.call.Input, &params); err != nil {
+		return lr.renderError(v, "Invalid ls parameters")
+	}
 
 	path := params.Path
 	if path == "" {
 		path = "."
 	}
 
-	header := makeHeader("List", v.textWidth(), path)
-	if res, done := earlyState(header, v); done {
-		return res
-	}
+	args := newParamBuilder().addMain(path).build()
 
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
+	return lr.renderWithParams(v, "List", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
 }
 
 // -----------------------------------------------------------------------------
 //  Sourcegraph renderer
 // -----------------------------------------------------------------------------
 
-type sourcegraphRenderer struct{}
+// sourcegraphRenderer handles code search with count and context options
+type sourcegraphRenderer struct {
+	baseRenderer
+}
 
-func (sourcegraphRenderer) Render(v *toolCallCmp) string {
+// Render displays the search query with optional count and context window parameters
+func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
 	var params tools.SourcegraphParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
-
-	args := []string{params.Query}
-	if params.Count != 0 {
-		args = append(args, "count", fmt.Sprintf("%d", params.Count))
-	}
-	if params.ContextWindow != 0 {
-		args = append(args, "context", fmt.Sprintf("%d", params.ContextWindow))
+	if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
+		return sr.renderError(v, "Invalid sourcegraph parameters")
 	}
 
-	header := makeHeader("Sourcegraph", v.textWidth(), args...)
-	if res, done := earlyState(header, v); done {
-		return res
-	}
+	args := newParamBuilder().
+		addMain(params.Query).
+		addKeyValue("count", formatNonZero(params.Count)).
+		addKeyValue("context", formatNonZero(params.ContextWindow)).
+		build()
 
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
+	return sr.renderWithParams(v, "Sourcegraph", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
 }
 
 // -----------------------------------------------------------------------------
 //  Patch renderer
 // -----------------------------------------------------------------------------
 
-type patchRenderer struct{}
+// patchRenderer handles multi-file patches with change summaries
+type patchRenderer struct {
+	baseRenderer
+}
 
-func (patchRenderer) Render(v *toolCallCmp) string {
+// Render displays patch summary with file count and change statistics
+func (pr patchRenderer) Render(v *toolCallCmp) string {
 	var params tools.PatchParams
-	_ = json.Unmarshal([]byte(v.call.Input), &params)
-
-	header := makeHeader("Patch", v.textWidth(), "multiple files")
-	if res, done := earlyState(header, v); done {
-		return res
+	if err := pr.unmarshalParams(v.call.Input, &params); err != nil {
+		return pr.renderError(v, "Invalid patch parameters")
 	}
 
-	var meta tools.PatchResponseMetadata
-	_ = json.Unmarshal([]byte(v.result.Metadata), &meta)
+	args := newParamBuilder().addMain("multiple files").build()
 
-	// Format the result as a summary of changes
-	summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
-		len(meta.FilesChanged), meta.Additions, meta.Removals)
+	return pr.renderWithParams(v, "Patch", args, func() string {
+		var meta tools.PatchResponseMetadata
+		if err := pr.unmarshalParams(v.result.Metadata, &meta); err != nil {
+			return renderPlainContent(v, v.result.Content)
+		}
 
-	// List the changed files
-	filesList := strings.Join(meta.FilesChanged, "\n")
+		summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
+			len(meta.FilesChanged), meta.Additions, meta.Removals)
+		filesList := strings.Join(meta.FilesChanged, "\n")
 
-	body := renderPlainContent(v, summary+"\n\n"+filesList)
-	return joinHeaderBody(header, body)
+		return renderPlainContent(v, summary+"\n\n"+filesList)
+	})
 }
 
 // -----------------------------------------------------------------------------
 //  Diagnostics renderer
 // -----------------------------------------------------------------------------
 
-type diagnosticsRenderer struct{}
+// diagnosticsRenderer handles project-wide diagnostic information
+type diagnosticsRenderer struct {
+	baseRenderer
+}
 
-func (diagnosticsRenderer) Render(v *toolCallCmp) string {
-	header := makeHeader("Diagnostics", v.textWidth(), "project")
-	if res, done := earlyState(header, v); done {
-		return res
-	}
+// Render displays project diagnostics with plain content formatting
+func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
+	args := newParamBuilder().addMain("project").build()
 
-	body := renderPlainContent(v, v.result.Content)
-	return joinHeaderBody(header, body)
+	return dr.renderWithParams(v, "Diagnostics", args, func() string {
+		return renderPlainContent(v, v.result.Content)
+	})
 }
 
 // makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
 func makeHeader(tool string, width int, params ...string) string {
 	prefix := tool + ": "
-	return prefix + renderParams(width-lipgloss.Width(prefix), params...)
+	return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
 }
 
-// renders params, params[0] (params[1]=params[2] ....)
-func renderParams(paramsWidth int, params ...string) string {
+// renderParamList renders params, params[0] (params[1]=params[2] ....)
+func renderParamList(paramsWidth int, params ...string) string {
 	if len(params) == 0 {
 		return ""
 	}

internal/tui/components/chat/messages/tool.go πŸ”—

@@ -15,46 +15,57 @@ import (
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
+// ToolCallCmp defines the interface for tool call components in the chat interface.
+// It manages the display of tool execution including pending states, results, and errors.
 type ToolCallCmp interface {
-	util.Model
-	layout.Sizeable
-	layout.Focusable
-	GetToolCall() message.ToolCall
-	GetToolResult() message.ToolResult
-	SetToolResult(message.ToolResult)
-	SetToolCall(message.ToolCall)
-	SetCancelled()
-	ParentMessageId() string
-	Spinning() bool
-}
-
+	util.Model                         // Basic Bubble Tea model interface
+	layout.Sizeable                    // Width/height management
+	layout.Focusable                   // Focus state management
+	GetToolCall() message.ToolCall     // Access to tool call data
+	GetToolResult() message.ToolResult // Access to tool result data
+	SetToolResult(message.ToolResult)  // Update tool result
+	SetToolCall(message.ToolCall)      // Update tool call
+	SetCancelled()                     // Mark as cancelled
+	ParentMessageId() string           // Get parent message ID
+	Spinning() bool                    // Animation state for pending tools
+}
+
+// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
+// It handles rendering of tool execution states including pending, completed, and error states.
 type toolCallCmp struct {
-	width   int
-	focused bool
+	width   int  // Component width for text wrapping
+	focused bool // Focus state for border styling
 
-	parentMessageId string
-	call            message.ToolCall
-	result          message.ToolResult
-	cancelled       bool
+	// Tool call data and state
+	parentMessageId string             // ID of the message that initiated this tool call
+	call            message.ToolCall   // The tool call being executed
+	result          message.ToolResult // The result of the tool execution
+	cancelled       bool               // Whether the tool call was cancelled
 
-	spinning bool
-	anim     util.Model
+	// Animation state for pending tool calls
+	spinning bool       // Whether to show loading animation
+	anim     util.Model // Animation component for pending states
 }
 
+// ToolCallOption provides functional options for configuring tool call components
 type ToolCallOption func(*toolCallCmp)
 
+// WithToolCallCancelled marks the tool call as cancelled
 func WithToolCallCancelled() ToolCallOption {
 	return func(m *toolCallCmp) {
 		m.cancelled = true
 	}
 }
 
+// WithToolCallResult sets the initial tool result
 func WithToolCallResult(result message.ToolResult) ToolCallOption {
 	return func(m *toolCallCmp) {
 		m.result = result
 	}
 }
 
+// NewToolCallCmp creates a new tool call component with the given parent message ID,
+// tool call, and optional configuration
 func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
 	m := &toolCallCmp{
 		call:            tc,
@@ -67,6 +78,8 @@ func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCal
 	return m
 }
 
+// Init initializes the tool call component and starts animations if needed.
+// Returns a command to start the animation for pending tool calls.
 func (m *toolCallCmp) Init() tea.Cmd {
 	m.spinning = m.shouldSpin()
 	logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
@@ -76,6 +89,8 @@ func (m *toolCallCmp) Init() tea.Cmd {
 	return nil
 }
 
+// Update handles incoming messages and updates the component state.
+// Manages animation updates for pending tool calls.
 func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	logging.Debug("Tool call update", "msg", msg)
 	switch msg := msg.(type) {
@@ -89,6 +104,8 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
+// View renders the tool call component based on its current state.
+// Shows either a pending animation or the tool-specific rendered result.
 func (m *toolCallCmp) View() string {
 	box := m.style()
 
@@ -100,12 +117,14 @@ func (m *toolCallCmp) View() string {
 	return box.PaddingLeft(1).Render(r.Render(m))
 }
 
-// SetCancelled implements ToolCallCmp.
+// State management methods
+
+// SetCancelled marks the tool call as cancelled
 func (m *toolCallCmp) SetCancelled() {
 	m.cancelled = true
 }
 
-// SetToolCall implements ToolCallCmp.
+// SetToolCall updates the tool call data and stops spinning if finished
 func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
 	m.call = call
 	if m.call.Finished {
@@ -113,31 +132,36 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
 	}
 }
 
-// ParentMessageId implements ToolCallCmp.
+// ParentMessageId returns the ID of the message that initiated this tool call
 func (m *toolCallCmp) ParentMessageId() string {
 	return m.parentMessageId
 }
 
-// SetToolResult implements ToolCallCmp.
+// SetToolResult updates the tool result and stops the spinning animation
 func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
 	m.result = result
 	m.spinning = false
 }
 
-// GetToolCall implements ToolCallCmp.
+// GetToolCall returns the current tool call data
 func (m *toolCallCmp) GetToolCall() message.ToolCall {
 	return m.call
 }
 
-// GetToolResult implements ToolCallCmp.
+// GetToolResult returns the current tool result data
 func (m *toolCallCmp) GetToolResult() message.ToolResult {
 	return m.result
 }
 
+// Rendering methods
+
+// renderPending displays the tool name with a loading animation for pending tool calls
 func (m *toolCallCmp) renderPending() string {
 	return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
 }
 
+// style returns the lipgloss style for the tool call component.
+// Applies muted colors and focus-dependent border styles.
 func (m *toolCallCmp) style() lipgloss.Style {
 	t := theme.CurrentTheme()
 	borderStyle := lipgloss.NormalBorder()
@@ -151,10 +175,13 @@ func (m *toolCallCmp) style() lipgloss.Style {
 		BorderStyle(borderStyle)
 }
 
+// textWidth calculates the available width for text content,
+// accounting for borders and padding
 func (m *toolCallCmp) textWidth() int {
 	return m.width - 2 // take into account the border and PaddingLeft
 }
 
+// fit truncates content to fit within the specified width with ellipsis
 func (m *toolCallCmp) fit(content string, width int) string {
 	t := theme.CurrentTheme()
 	lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
@@ -162,30 +189,40 @@ func (m *toolCallCmp) fit(content string, width int) string {
 	return ansi.Truncate(content, width, dots)
 }
 
+// Focus management methods
+
+// Blur removes focus from the tool call component
 func (m *toolCallCmp) Blur() tea.Cmd {
 	m.focused = false
 	return nil
 }
 
+// Focus sets focus on the tool call component
 func (m *toolCallCmp) Focus() tea.Cmd {
 	m.focused = true
 	return nil
 }
 
-// IsFocused implements MessageModel.
+// IsFocused returns whether the tool call component is currently focused
 func (m *toolCallCmp) IsFocused() bool {
 	return m.focused
 }
 
+// Size management methods
+
+// GetSize returns the current dimensions of the tool call component
 func (m *toolCallCmp) GetSize() (int, int) {
 	return m.width, 0
 }
 
+// SetSize updates the width of the tool call component for text wrapping
 func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
 	m.width = width
 	return nil
 }
 
+// shouldSpin determines whether the tool call should show a loading animation.
+// Returns true if the tool call is not finished or if the result doesn't match the call ID.
 func (m *toolCallCmp) shouldSpin() bool {
 	if !m.call.Finished {
 		return true
@@ -195,6 +232,7 @@ func (m *toolCallCmp) shouldSpin() bool {
 	return false
 }
 
+// Spinning returns whether the tool call is currently showing a loading animation
 func (m *toolCallCmp) Spinning() bool {
 	return m.spinning
 }

internal/tui/components/core/list/keys.go πŸ”—

@@ -16,7 +16,7 @@ type KeyMap struct {
 	Submit key.Binding
 }
 
-func defaultKeymap() KeyMap {
+func DefaultKeymap() KeyMap {
 	return KeyMap{
 		Down: key.NewBinding(
 			key.WithKeys("down", "ctrl+j", "ctrl+n"),

internal/tui/components/core/list/list.go πŸ”—

@@ -3,7 +3,6 @@ package list
 import (
 	"slices"
 	"strings"
-	"sync"
 
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
@@ -14,89 +13,155 @@ import (
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
 
+// Constants for special index values and defaults
+const (
+	NoSelection    = -1 // Indicates no item is currently selected
+	NotRendered    = -1 // Indicates an item hasn't been rendered yet
+	NoFinalHeight  = -1 // Indicates final height hasn't been calculated
+	DefaultGapSize = 0  // Default spacing between list items
+)
+
+// ListModel defines the interface for a scrollable, selectable list component.
+// It combines the basic Model interface with sizing capabilities and list-specific operations.
 type ListModel interface {
 	util.Model
 	layout.Sizeable
-	SetItems([]util.Model) tea.Cmd
-	AppendItem(util.Model) tea.Cmd
-	PrependItem(util.Model) tea.Cmd
-	DeleteItem(int)
-	UpdateItem(int, util.Model)
-	ResetView()
-	Items() []util.Model
+	SetItems([]util.Model) tea.Cmd  // Replace all items in the list
+	AppendItem(util.Model) tea.Cmd  // Add an item to the end of the list
+	PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
+	DeleteItem(int)                 // Remove an item at the specified index
+	UpdateItem(int, util.Model)     // Replace an item at the specified index
+	ResetView()                     // Clear rendering cache and reset scroll position
+	Items() []util.Model            // Get all items in the list
 }
 
+// HasAnim interface identifies items that support animation.
+// Items implementing this interface will receive animation update messages.
 type HasAnim interface {
 	util.Model
-	Spinning() bool
+	Spinning() bool // Returns true if the item is currently animating
 }
 
+// renderedItem represents a cached rendered item with its position and content.
 type renderedItem struct {
-	lines  []string
-	start  int
-	height int
+	lines  []string // The rendered lines of text for this item
+	start  int      // Starting line position in the overall rendered content
+	height int      // Number of lines this item occupies
+}
+
+// renderState manages the rendering cache and state for the list.
+// It tracks which items have been rendered and their positions.
+type renderState struct {
+	items         map[int]renderedItem // Cache of rendered items by index
+	lines         []string             // All rendered lines concatenated
+	lastIndex     int                  // Index of the last rendered item
+	finalHeight   int                  // Total height when all items are rendered
+	needsRerender bool                 // Flag indicating if re-rendering is needed
+}
+
+// newRenderState creates a new render state with default values.
+func newRenderState() *renderState {
+	return &renderState{
+		items:         make(map[int]renderedItem),
+		lines:         []string{},
+		lastIndex:     NotRendered,
+		finalHeight:   NoFinalHeight,
+		needsRerender: true,
+	}
+}
+
+// reset clears all cached rendering data and resets state to initial values.
+func (rs *renderState) reset() {
+	rs.items = make(map[int]renderedItem)
+	rs.lines = []string{}
+	rs.lastIndex = NotRendered
+	rs.finalHeight = NoFinalHeight
+	rs.needsRerender = true
+}
+
+// viewState manages the visual display properties of the list.
+type viewState struct {
+	width, height int    // Dimensions of the list viewport
+	offset        int    // Current scroll offset in lines
+	reverse       bool   // Whether to render in reverse order (bottom-up)
+	content       string // The final rendered content to display
+}
+
+// selectionState manages which item is currently selected.
+type selectionState struct {
+	selectedIndex int // Index of the currently selected item, or NoSelection
+}
+
+// isValidIndex checks if the selected index is within the valid range of items.
+func (ss *selectionState) isValidIndex(itemCount int) bool {
+	return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
 }
+
+// model is the main implementation of the ListModel interface.
+// It coordinates between view state, render state, and selection state.
 type model struct {
-	width, height, offset int
-	finalHeight           int // this gets set when the last item is rendered to mark the max offset
-	reverse               bool
-	help                  help.Model
-	keymap                KeyMap
-	items                 []util.Model
-	renderedItems         *sync.Map // item index to rendered string
-	needsRerender         bool
-	renderedLines         []string
-	selectedItemInx       int
-	lastRenderedInx       int
-	content               string
-	gapSize               int
-	padding               []int
+	viewState      viewState      // Display and scrolling state
+	renderState    *renderState   // Rendering cache and state
+	selectionState selectionState // Item selection state
+	help           help.Model     // Help system for keyboard shortcuts
+	keymap         KeyMap         // Key bindings for navigation
+	items          []util.Model   // The actual list items
+	gapSize        int            // Number of empty lines between items
+	padding        []int          // Padding around the list content
 }
 
+// listOptions is a function type for configuring list options.
 type listOptions func(*model)
 
+// WithKeyMap sets custom key bindings for the list.
 func WithKeyMap(k KeyMap) listOptions {
 	return func(m *model) {
 		m.keymap = k
 	}
 }
 
+// WithReverse sets whether the list should render in reverse order (newest items at bottom).
 func WithReverse(reverse bool) listOptions {
 	return func(m *model) {
 		m.setReverse(reverse)
 	}
 }
 
+// WithGapSize sets the number of empty lines to insert between list items.
 func WithGapSize(gapSize int) listOptions {
 	return func(m *model) {
 		m.gapSize = gapSize
 	}
 }
 
+// WithPadding sets the padding around the list content.
+// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
+// 4 values = top/right/bottom/left.
 func WithPadding(padding ...int) listOptions {
 	return func(m *model) {
 		m.padding = padding
 	}
 }
 
+// WithItems sets the initial items for the list.
 func WithItems(items []util.Model) listOptions {
 	return func(m *model) {
 		m.items = items
 	}
 }
 
+// New creates a new list model with the specified options.
+// The list starts with no items selected and requires SetItems to be called
+// or items to be provided via WithItems option.
 func New(opts ...listOptions) ListModel {
 	m := &model{
-		help:            help.New(),
-		keymap:          defaultKeymap(),
-		items:           []util.Model{},
-		needsRerender:   true,
-		gapSize:         0,
-		padding:         []int{},
-		selectedItemInx: -1,
-		finalHeight:     -1,
-		lastRenderedInx: -1,
-		renderedItems:   new(sync.Map),
+		help:           help.New(),
+		keymap:         DefaultKeymap(),
+		items:          []util.Model{},
+		renderState:    newRenderState(),
+		gapSize:        DefaultGapSize,
+		padding:        []int{},
+		selectionState: selectionState{selectedIndex: NoSelection},
 	}
 	for _, opt := range opts {
 		opt(m)
@@ -104,591 +169,737 @@ func New(opts ...listOptions) ListModel {
 	return m
 }
 
-// Init implements List.
+// Init initializes the list component and sets up the initial items.
+// This is called automatically by the Bubble Tea framework.
 func (m *model) Init() tea.Cmd {
-	cmds := []tea.Cmd{
-		m.SetItems(m.items),
-	}
-	return tea.Batch(cmds...)
+	return m.SetItems(m.items)
 }
 
-// Update implements List.
+// Update handles incoming messages and updates the list state accordingly.
+// It processes keyboard input, animation messages, and forwards other messages
+// to the currently selected item.
 func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
-		switch {
-		case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
-			if m.reverse {
-				m.decreaseOffset(1)
-			} else {
-				m.increaseOffset(1)
-			}
-			return m, nil
-		case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
-			if m.reverse {
-				m.increaseOffset(1)
-			} else {
-				m.decreaseOffset(1)
-			}
-			return m, nil
-		case key.Matches(msg, m.keymap.DownOneItem):
-			m.downOneItem()
-			return m, nil
-		case key.Matches(msg, m.keymap.UpOneItem):
-			m.upOneItem()
-			return m, nil
-		case key.Matches(msg, m.keymap.HalfPageDown):
-			if m.reverse {
-				m.decreaseOffset(m.listHeight() / 2)
-			} else {
-				m.increaseOffset(m.listHeight() / 2)
-			}
-			return m, nil
-		case key.Matches(msg, m.keymap.HalfPageUp):
-			if m.reverse {
-				m.increaseOffset(m.listHeight() / 2)
-			} else {
-				m.decreaseOffset(m.listHeight() / 2)
-			}
-			return m, nil
-		case key.Matches(msg, m.keymap.Home):
-			m.goToTop()
-			return m, nil
-		case key.Matches(msg, m.keymap.End):
-			m.goToBottom()
-			return m, nil
-		}
+		return m.handleKeyPress(msg)
 	case anim.ColorCycleMsg, anim.StepCharsMsg:
-		for inx, item := range m.items {
-			if i, ok := item.(HasAnim); ok {
-				if i.Spinning() {
-					updated, cmd := i.Update(msg)
-					cmds = append(cmds, cmd)
-					m.UpdateItem(inx, updated.(util.Model))
-				}
+		return m.handleAnimationMsg(msg)
+	}
+
+	if m.selectionState.isValidIndex(len(m.items)) {
+		return m.updateSelectedItem(msg)
+	}
+
+	return m, nil
+}
+
+// handleKeyPress processes keyboard input for list navigation.
+// Supports scrolling, item selection, and navigation to top/bottom.
+func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	switch {
+	case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
+		m.scrollDown(1)
+	case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
+		m.scrollUp(1)
+	case key.Matches(msg, m.keymap.DownOneItem):
+		return m, m.selectNextItem()
+	case key.Matches(msg, m.keymap.UpOneItem):
+		return m, m.selectPreviousItem()
+	case key.Matches(msg, m.keymap.HalfPageDown):
+		m.scrollDown(m.listHeight() / 2)
+	case key.Matches(msg, m.keymap.HalfPageUp):
+		m.scrollUp(m.listHeight() / 2)
+	case key.Matches(msg, m.keymap.Home):
+		return m, m.goToTop()
+	case key.Matches(msg, m.keymap.End):
+		return m, m.goToBottom()
+	}
+	return m, nil
+}
+
+// handleAnimationMsg forwards animation messages to items that support animation.
+// Only items implementing HasAnim and currently spinning receive these messages.
+func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	for inx, item := range m.items {
+		if i, ok := item.(HasAnim); ok && i.Spinning() {
+			updated, cmd := i.Update(msg)
+			cmds = append(cmds, cmd)
+			if u, ok := updated.(util.Model); ok {
+				m.UpdateItem(inx, u)
 			}
 		}
-		return m, tea.Batch(cmds...)
 	}
-	if m.selectedItemInx > -1 {
-		u, cmd := m.items[m.selectedItemInx].Update(msg)
-		cmds = append(cmds, cmd)
-		m.UpdateItem(m.selectedItemInx, u.(util.Model))
-		return m, tea.Batch(cmds...)
+	return m, tea.Batch(cmds...)
+}
+
+// updateSelectedItem forwards messages to the currently selected item.
+// This allows the selected item to handle its own input and state changes.
+func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	u, cmd := m.items[m.selectionState.selectedIndex].Update(msg)
+	cmds = append(cmds, cmd)
+	if updated, ok := u.(util.Model); ok {
+		m.UpdateItem(m.selectionState.selectedIndex, updated)
+	}
+	return m, tea.Batch(cmds...)
+}
+
+// scrollDown scrolls the list down by the specified amount.
+// Direction is automatically adjusted based on reverse mode.
+func (m *model) scrollDown(amount int) {
+	if m.viewState.reverse {
+		m.decreaseOffset(amount)
+	} else {
+		m.increaseOffset(amount)
 	}
+}
 
-	return m, nil
+// scrollUp scrolls the list up by the specified amount.
+// Direction is automatically adjusted based on reverse mode.
+func (m *model) scrollUp(amount int) {
+	if m.viewState.reverse {
+		m.increaseOffset(amount)
+	} else {
+		m.decreaseOffset(amount)
+	}
 }
 
-// View implements List.
+// View renders the list to a string for display.
+// Returns empty string if the list has no dimensions.
+// Triggers re-rendering if needed before returning content.
 func (m *model) View() string {
-	if m.height == 0 || m.width == 0 {
+	if m.viewState.height == 0 || m.viewState.width == 0 {
 		return ""
 	}
-	if m.needsRerender {
+	if m.renderState.needsRerender {
 		m.renderVisible()
 	}
-	return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
+	return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content)
 }
 
-// Items implements ListModel.
+// Items returns a copy of all items in the list.
 func (m *model) Items() []util.Model {
 	return m.items
 }
 
-func (m *model) renderVisibleReverse() {
-	start := 0
-	cutoff := m.offset + m.listHeight()
-	items := m.items
-	if m.lastRenderedInx > -1 {
-		items = m.items[:m.lastRenderedInx]
-		start = len(m.renderedLines)
+// renderVisible determines which rendering strategy to use and triggers rendering.
+// Uses forward rendering for normal mode and reverse rendering for reverse mode.
+func (m *model) renderVisible() {
+	if m.viewState.reverse {
+		m.renderVisibleReverse()
 	} else {
-		// reveresed so that it starts at the end
-		m.lastRenderedInx = len(m.items)
-	}
-	realIndex := m.lastRenderedInx
-	for i := len(items) - 1; i >= 0; i-- {
-		realIndex--
-		var itemLines []string
-		cachedContent, ok := m.renderedItems.Load(realIndex)
-		if ok {
-			itemLines = cachedContent.(renderedItem).lines
-		} else {
-			itemLines = strings.Split(items[i].View(), "\n")
-			if m.gapSize > 0 {
-				for range m.gapSize {
-					itemLines = append(itemLines, "")
-				}
-			}
-			m.renderedItems.Store(realIndex, renderedItem{
-				lines:  itemLines,
-				start:  start,
-				height: len(itemLines),
-			})
-		}
+		m.renderVisibleForward()
+	}
+}
 
-		if realIndex == 0 {
-			m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
-		}
-		m.renderedLines = append(itemLines, m.renderedLines...)
-		m.lastRenderedInx = realIndex
-		// always render the next item
-		if start > cutoff {
-			break
-		}
-		start += len(itemLines)
+// renderVisibleForward renders items from top to bottom (normal mode).
+// Only renders items that are currently visible or near the viewport.
+func (m *model) renderVisibleForward() {
+	renderer := &forwardRenderer{
+		model:   m,
+		start:   0,
+		cutoff:  m.viewState.offset + m.listHeight(),
+		items:   m.items,
+		realIdx: m.renderState.lastIndex,
 	}
-	m.needsRerender = false
-	if m.finalHeight > -1 {
-		// make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
-		m.offset = min(m.offset, m.finalHeight)
+
+	if m.renderState.lastIndex > NotRendered {
+		renderer.items = m.items[m.renderState.lastIndex+1:]
+		renderer.start = len(m.renderState.lines)
 	}
-	maxHeight := min(m.listHeight(), len(m.renderedLines))
-	if m.offset < len(m.renderedLines) {
-		end := len(m.renderedLines) - m.offset
-		start := max(0, end-maxHeight)
-		m.content = strings.Join(m.renderedLines[start:end], "\n")
+
+	renderer.render()
+	m.finalizeRender()
+}
+
+// renderVisibleReverse renders items from bottom to top (reverse mode).
+// Used when new items should appear at the bottom (like chat messages).
+func (m *model) renderVisibleReverse() {
+	renderer := &reverseRenderer{
+		model:   m,
+		start:   0,
+		cutoff:  m.viewState.offset + m.listHeight(),
+		items:   m.items,
+		realIdx: m.renderState.lastIndex,
+	}
+
+	if m.renderState.lastIndex > NotRendered {
+		renderer.items = m.items[:m.renderState.lastIndex]
+		renderer.start = len(m.renderState.lines)
 	} else {
-		m.content = ""
+		m.renderState.lastIndex = len(m.items)
+		renderer.realIdx = len(m.items)
 	}
+
+	renderer.render()
+	m.finalizeRender()
 }
 
-func (m *model) renderVisible() {
-	if m.reverse {
-		m.renderVisibleReverse()
+// finalizeRender completes the rendering process by updating scroll bounds and content.
+func (m *model) finalizeRender() {
+	m.renderState.needsRerender = false
+	if m.renderState.finalHeight > NoFinalHeight {
+		m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
+	}
+	m.updateContent()
+}
+
+// updateContent extracts the visible portion of rendered content for display.
+// Handles both normal and reverse rendering modes.
+func (m *model) updateContent() {
+	maxHeight := min(m.listHeight(), len(m.renderState.lines))
+	if m.viewState.offset >= len(m.renderState.lines) {
+		m.viewState.content = ""
 		return
 	}
-	start := 0
-	cutoff := m.offset + m.listHeight()
-	items := m.items
-	if m.lastRenderedInx > -1 {
-		items = m.items[m.lastRenderedInx+1:]
-		start = len(m.renderedLines)
+
+	if m.viewState.reverse {
+		end := len(m.renderState.lines) - m.viewState.offset
+		start := max(0, end-maxHeight)
+		m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
+	} else {
+		endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
+		m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
 	}
+}
 
-	realIndex := m.lastRenderedInx
-	for _, item := range items {
-		realIndex++
+// forwardRenderer handles rendering items from top to bottom.
+// It builds up the rendered content incrementally, caching results for performance.
+type forwardRenderer struct {
+	model   *model       // Reference to the parent list model
+	start   int          // Current line position in the overall content
+	cutoff  int          // Line position where we can stop rendering
+	items   []util.Model // Items to render (may be a subset)
+	realIdx int          // Real index in the full item list
+}
 
-		var itemLines []string
-		cachedContent, ok := m.renderedItems.Load(realIndex)
-		if ok {
-			itemLines = cachedContent.(renderedItem).lines
-		} else {
-			itemLines = strings.Split(item.View(), "\n")
-			if m.gapSize > 0 {
-				for range m.gapSize {
-					itemLines = append(itemLines, "")
-				}
-			}
-			m.renderedItems.Store(realIndex, renderedItem{
-				lines:  itemLines,
-				start:  start,
-				height: len(itemLines),
-			})
+// render processes items in forward order, building up the rendered content.
+func (r *forwardRenderer) render() {
+	for _, item := range r.items {
+		r.realIdx++
+		if r.start > r.cutoff {
+			break
+		}
+
+		itemLines := r.getOrRenderItem(item)
+		if r.realIdx == len(r.model.items)-1 {
+			r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
 		}
-		// always render the next item
-		if start > cutoff {
+
+		r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
+		r.model.renderState.lastIndex = r.realIdx
+		r.start += len(itemLines)
+	}
+}
+
+// getOrRenderItem retrieves cached content or renders the item if not cached.
+func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
+	if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
+		return cachedContent.lines
+	}
+
+	itemLines := r.renderItemLines(item)
+	r.model.renderState.items[r.realIdx] = renderedItem{
+		lines:  itemLines,
+		start:  r.start,
+		height: len(itemLines),
+	}
+	return itemLines
+}
+
+// renderItemLines converts an item to its string representation with gaps.
+func (r *forwardRenderer) renderItemLines(item util.Model) []string {
+	return r.model.getItemLines(item)
+}
+
+// reverseRenderer handles rendering items from bottom to top.
+// Used in reverse mode where new items appear at the bottom.
+type reverseRenderer struct {
+	model   *model       // Reference to the parent list model
+	start   int          // Current line position in the overall content
+	cutoff  int          // Line position where we can stop rendering
+	items   []util.Model // Items to render (may be a subset)
+	realIdx int          // Real index in the full item list
+}
+
+// render processes items in reverse order, prepending to the rendered content.
+func (r *reverseRenderer) render() {
+	for i := len(r.items) - 1; i >= 0; i-- {
+		r.realIdx--
+		if r.start > r.cutoff {
 			break
 		}
 
-		if realIndex == len(m.items)-1 {
-			m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
+		itemLines := r.getOrRenderItem(r.items[i])
+		if r.realIdx == 0 {
+			r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
 		}
 
-		m.renderedLines = append(m.renderedLines, itemLines...)
-		m.lastRenderedInx = realIndex
-		start += len(itemLines)
+		r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
+		r.model.renderState.lastIndex = r.realIdx
+		r.start += len(itemLines)
+	}
+}
+
+// getOrRenderItem retrieves cached content or renders the item if not cached.
+func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
+	if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
+		return cachedContent.lines
+	}
+
+	itemLines := r.renderItemLines(item)
+	r.model.renderState.items[r.realIdx] = renderedItem{
+		lines:  itemLines,
+		start:  r.start,
+		height: len(itemLines),
+	}
+	return itemLines
+}
+
+// renderItemLines converts an item to its string representation with gaps.
+func (r *reverseRenderer) renderItemLines(item util.Model) []string {
+	return r.model.getItemLines(item)
+}
+
+// selectPreviousItem moves selection to the previous item in the list.
+// Handles focus management and ensures the selected item remains visible.
+func (m *model) selectPreviousItem() tea.Cmd {
+	if m.selectionState.selectedIndex <= 0 {
+		return nil
+	}
+
+	cmds := []tea.Cmd{m.blurSelected()}
+	m.selectionState.selectedIndex--
+	cmds = append(cmds, m.focusSelected())
+	m.ensureSelectedItemVisible()
+	return tea.Batch(cmds...)
+}
+
+// selectNextItem moves selection to the next item in the list.
+// Handles focus management and ensures the selected item remains visible.
+func (m *model) selectNextItem() tea.Cmd {
+	if m.selectionState.selectedIndex >= len(m.items)-1 || m.selectionState.selectedIndex < 0 {
+		return nil
 	}
-	m.needsRerender = false
-	maxHeight := min(m.listHeight(), len(m.renderedLines))
-	if m.finalHeight > -1 {
-		// make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
-		m.offset = min(m.offset, m.finalHeight)
+
+	cmds := []tea.Cmd{m.blurSelected()}
+	m.selectionState.selectedIndex++
+	cmds = append(cmds, m.focusSelected())
+	m.ensureSelectedItemVisible()
+	return tea.Batch(cmds...)
+}
+
+// ensureSelectedItemVisible scrolls the list to make the selected item visible.
+// Uses different strategies for forward and reverse rendering modes.
+func (m *model) ensureSelectedItemVisible() {
+	cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
+	if !ok {
+		m.renderState.needsRerender = true
+		return
 	}
-	if m.offset < len(m.renderedLines) {
-		m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
+
+	if m.viewState.reverse {
+		m.ensureVisibleReverse(cachedItem)
 	} else {
-		m.content = ""
+		m.ensureVisibleForward(cachedItem)
 	}
+	m.renderState.needsRerender = true
 }
 
-func (m *model) upOneItem() tea.Cmd {
-	var cmds []tea.Cmd
-	if m.selectedItemInx > 0 {
-		cmd := m.blurSelected()
-		cmds = append(cmds, cmd)
-		m.selectedItemInx--
-		cmd = m.focusSelected()
-		cmds = append(cmds, cmd)
-	}
-
-	cached, ok := m.renderedItems.Load(m.selectedItemInx)
-	if ok {
-		// already rendered
-		if !m.reverse {
-			cachedItem, _ := cached.(renderedItem)
-			// might not fit on the screen move the offset to the start of the item
-			if cachedItem.height >= m.listHeight() {
-				changeNeeded := m.offset - cachedItem.start
-				m.decreaseOffset(changeNeeded)
-			}
-			if cachedItem.start < m.offset {
-				changeNeeded := m.offset - cachedItem.start
-				m.decreaseOffset(changeNeeded)
-			}
+// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
+// Handles both large items (taller than viewport) and normal items.
+func (m *model) ensureVisibleForward(cachedItem renderedItem) {
+	if cachedItem.height >= m.listHeight() {
+		if m.selectionState.selectedIndex > 0 {
+			changeNeeded := m.viewState.offset - cachedItem.start
+			m.decreaseOffset(changeNeeded)
 		} else {
-			cachedItem, _ := cached.(renderedItem)
-			// might not fit on the screen move the offset to the start of the item
-			if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
-				changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
-				m.increaseOffset(changeNeeded)
-			}
+			changeNeeded := cachedItem.start - m.viewState.offset
+			m.increaseOffset(changeNeeded)
+		}
+		return
+	}
+
+	if cachedItem.start < m.viewState.offset {
+		changeNeeded := m.viewState.offset - cachedItem.start
+		m.decreaseOffset(changeNeeded)
+	} else {
+		end := cachedItem.start + cachedItem.height
+		if end > m.viewState.offset+m.listHeight() {
+			changeNeeded := end - (m.viewState.offset + m.listHeight())
+			m.increaseOffset(changeNeeded)
 		}
 	}
-	m.needsRerender = true
-	return tea.Batch(cmds...)
 }
 
-func (m *model) downOneItem() tea.Cmd {
-	var cmds []tea.Cmd
-	if m.selectedItemInx < len(m.items)-1 {
-		cmd := m.blurSelected()
-		cmds = append(cmds, cmd)
-		m.selectedItemInx++
-		cmd = m.focusSelected()
-		cmds = append(cmds, cmd)
-	}
-	cached, ok := m.renderedItems.Load(m.selectedItemInx)
-	if ok {
-		// already rendered
-		if !m.reverse {
-			cachedItem, _ := cached.(renderedItem)
-			// might not fit on the screen move the offset to the start of the item
-			if cachedItem.height >= m.listHeight() {
-				changeNeeded := cachedItem.start - m.offset
-				m.increaseOffset(changeNeeded)
-			} else {
-				end := cachedItem.start + cachedItem.height
-				if end > m.offset+m.listHeight() {
-					changeNeeded := end - (m.offset + m.listHeight())
-					m.increaseOffset(changeNeeded)
-				}
-			}
+// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
+// Handles both large items (taller than viewport) and normal items.
+func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
+	if cachedItem.height >= m.listHeight() {
+		if m.selectionState.selectedIndex < len(m.items)-1 {
+			changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
+			m.decreaseOffset(changeNeeded)
 		} else {
-			cachedItem, _ := cached.(renderedItem)
-			// might not fit on the screen move the offset to the start of the item
-			if cachedItem.height >= m.listHeight() {
-				changeNeeded := m.offset - (cachedItem.start + cachedItem.height - m.listHeight())
-				m.decreaseOffset(changeNeeded)
-			} else {
-				if cachedItem.start < m.offset {
-					changeNeeded := m.offset - cachedItem.start
-					m.decreaseOffset(changeNeeded)
-				}
-			}
+			changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
+			m.increaseOffset(changeNeeded)
 		}
+		return
 	}
 
-	m.needsRerender = true
-	return tea.Batch(cmds...)
+	if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
+		changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
+		m.increaseOffset(changeNeeded)
+	} else if cachedItem.start < m.viewState.offset {
+		changeNeeded := m.viewState.offset - cachedItem.start
+		m.decreaseOffset(changeNeeded)
+	}
 }
 
+// goToBottom switches to reverse mode and selects the last item.
+// Commonly used for chat-like interfaces where new content appears at the bottom.
 func (m *model) goToBottom() tea.Cmd {
-	if len(m.items) == 0 {
-		return nil
-	}
-	var cmds []tea.Cmd
-	m.reverse = true
-	cmd := m.blurSelected()
-	cmds = append(cmds, cmd)
-	m.selectedItemInx = len(m.items) - 1
-	cmd = m.focusSelected()
-	cmds = append(cmds, cmd)
+	cmds := []tea.Cmd{m.blurSelected()}
+	m.viewState.reverse = true
+	m.selectionState.selectedIndex = len(m.items) - 1
+	cmds = append(cmds, m.focusSelected())
 	m.ResetView()
 	return tea.Batch(cmds...)
 }
 
-func (m *model) ResetView() {
-	m.renderedItems.Clear()
-	m.renderedLines = []string{}
-	m.offset = 0
-	m.lastRenderedInx = -1
-	m.finalHeight = -1
-	m.needsRerender = true
-}
-
+// goToTop switches to forward mode and selects the first item.
+// Standard behavior for most list interfaces.
 func (m *model) goToTop() tea.Cmd {
-	if len(m.items) == 0 {
-		return nil
+	cmds := []tea.Cmd{m.blurSelected()}
+	m.viewState.reverse = false
+	if len(m.items) > 0 {
+		m.selectionState.selectedIndex = 0
 	}
-	var cmds []tea.Cmd
-	m.reverse = false
-	cmd := m.blurSelected()
-	cmds = append(cmds, cmd)
-	m.selectedItemInx = 0
-	cmd = m.focusSelected()
-	cmds = append(cmds, cmd)
+	cmds = append(cmds, m.focusSelected())
 	m.ResetView()
 	return tea.Batch(cmds...)
 }
 
+// ResetView clears all cached rendering data and resets scroll position.
+// Forces a complete re-render on the next View() call.
+func (m *model) ResetView() {
+	m.renderState.reset()
+	m.viewState.offset = 0
+}
+
+// focusSelected gives focus to the currently selected item if it supports focus.
+// Triggers a re-render of the item to show its focused state.
 func (m *model) focusSelected() tea.Cmd {
-	if m.selectedItemInx == -1 {
+	if !m.selectionState.isValidIndex(len(m.items)) {
 		return nil
 	}
-	if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+	if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
 		cmd := i.Focus()
-		m.rerenderItem(m.selectedItemInx)
+		m.rerenderItem(m.selectionState.selectedIndex)
 		return cmd
 	}
 	return nil
 }
 
+// blurSelected removes focus from the currently selected item if it supports focus.
+// Triggers a re-render of the item to show its unfocused state.
 func (m *model) blurSelected() tea.Cmd {
-	if m.selectedItemInx == -1 {
+	if !m.selectionState.isValidIndex(len(m.items)) {
 		return nil
 	}
-	if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+	if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
 		cmd := i.Blur()
-		m.rerenderItem(m.selectedItemInx)
+		m.rerenderItem(m.selectionState.selectedIndex)
 		return cmd
 	}
 	return nil
 }
 
+// rerenderItem updates the cached rendering of a specific item.
+// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
+// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
 func (m *model) rerenderItem(inx int) {
-	if inx < 0 || len(m.renderedLines) == 0 {
+	if inx < 0 || inx >= len(m.items) || len(m.renderState.lines) == 0 {
 		return
 	}
-	cached, ok := m.renderedItems.Load(inx)
-	cachedItem, _ := cached.(renderedItem)
+
+	cachedItem, ok := m.renderState.items[inx]
 	if !ok {
-		// No need to rerender
 		return
 	}
-	rerenderedItem := m.items[inx].View()
-	rerenderedLines := strings.Split(rerenderedItem, "\n")
-	if m.gapSize > 0 {
-		for range m.gapSize {
-			rerenderedLines = append(rerenderedLines, "")
-		}
-	}
-	// check if lines are the same
+
+	rerenderedLines := m.getItemLines(m.items[inx])
 	if slices.Equal(cachedItem.lines, rerenderedLines) {
-		// No changes
 		return
 	}
-	// check if the item is in the content
-	start := cachedItem.start
-	end := start + cachedItem.height
-	totalLines := len(m.renderedLines)
-	if m.reverse {
+
+	m.updateRenderedLines(cachedItem, rerenderedLines)
+	m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
+	m.updateCachedItem(inx, cachedItem, rerenderedLines)
+	m.renderState.needsRerender = true
+}
+
+// getItemLines converts an item to its rendered lines, including any gap spacing.
+func (m *model) getItemLines(item util.Model) []string {
+	itemLines := strings.Split(item.View(), "\n")
+	if m.gapSize > 0 {
+		gap := make([]string, m.gapSize)
+		itemLines = append(itemLines, gap...)
+	}
+	return itemLines
+}
+
+// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
+func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
+	start, end := m.getItemBounds(cachedItem)
+	totalLines := len(m.renderState.lines)
+
+	if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
+		m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
+		m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
+	}
+}
+
+// getItemBounds calculates the start and end line positions for an item.
+// Handles both forward and reverse rendering modes.
+func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
+	start = cachedItem.start
+	end = start + cachedItem.height
+
+	if m.viewState.reverse {
+		totalLines := len(m.renderState.lines)
 		end = totalLines - cachedItem.start
 		start = end - cachedItem.height
 	}
-	if start <= totalLines && end <= totalLines {
-		m.renderedLines = slices.Delete(m.renderedLines, start, end)
-		m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
+	return start, end
+}
+
+// updateItemPositions recalculates positions for items after the changed item.
+// This is necessary when an item's height changes, affecting subsequent items.
+func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
+	if cachedItem.height == newHeight {
+		return
+	}
+
+	if inx == len(m.items)-1 {
+		m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
+	}
+
+	currentStart := cachedItem.start + newHeight
+	if m.viewState.reverse {
+		m.updatePositionsReverse(inx, currentStart)
+	} else {
+		m.updatePositionsForward(inx, currentStart)
 	}
-	// TODO: if hight changed do something
-	if cachedItem.height != len(rerenderedLines) {
-		if inx == len(m.items)-1 {
-			m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
+}
+
+// updatePositionsForward updates positions for items after the changed item in forward mode.
+func (m *model) updatePositionsForward(inx int, currentStart int) {
+	for i := inx + 1; i < len(m.items); i++ {
+		if existing, ok := m.renderState.items[i]; ok {
+			existing.start = currentStart
+			currentStart += existing.height
+			m.renderState.items[i] = existing
+		} else {
+			break
 		}
+	}
+}
 
-		// update the start of the other cached items
-		currentStart := cachedItem.start + len(rerenderedLines)
-		if m.reverse {
-			for i := inx - 1; i < len(m.items); i-- {
-				if existing, ok := m.renderedItems.Load(i); ok {
-					cached := existing.(renderedItem)
-					cached.start = currentStart
-					currentStart += cached.height
-					m.renderedItems.Store(i, cached)
-				} else {
-					break
-				}
-			}
+// updatePositionsReverse updates positions for items before the changed item in reverse mode.
+func (m *model) updatePositionsReverse(inx int, currentStart int) {
+	for i := inx - 1; i >= 0; i-- {
+		if existing, ok := m.renderState.items[i]; ok {
+			existing.start = currentStart
+			currentStart += existing.height
+			m.renderState.items[i] = existing
 		} else {
-			for i := inx + 1; i < len(m.items); i++ {
-				if existing, ok := m.renderedItems.Load(i); ok {
-					cached := existing.(renderedItem)
-					cached.start = currentStart
-					currentStart += cached.height
-					m.renderedItems.Store(i, cached)
-				} else {
-					break
-				}
-			}
+			break
 		}
 	}
-	m.renderedItems.Store(inx, renderedItem{
-		lines:  rerenderedLines,
+}
+
+// updateCachedItem updates the cached rendering information for a specific item.
+func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
+	m.renderState.items[inx] = renderedItem{
+		lines:  newLines,
 		start:  cachedItem.start,
-		height: len(rerenderedLines),
-	})
-	m.needsRerender = true
+		height: len(newLines),
+	}
 }
 
+// increaseOffset scrolls the list down by increasing the offset.
+// Respects the final height limit to prevent scrolling past the end.
 func (m *model) increaseOffset(n int) {
-	if m.finalHeight > -1 {
-		if m.offset < m.finalHeight {
-			m.offset += n
-			if m.offset > m.finalHeight {
-				m.offset = m.finalHeight
+	if m.renderState.finalHeight > NoFinalHeight {
+		if m.viewState.offset < m.renderState.finalHeight {
+			m.viewState.offset += n
+			if m.viewState.offset > m.renderState.finalHeight {
+				m.viewState.offset = m.renderState.finalHeight
 			}
-			m.needsRerender = true
+			m.renderState.needsRerender = true
 		}
 	} else {
-		m.offset += n
-		m.needsRerender = true
+		m.viewState.offset += n
+		m.renderState.needsRerender = true
 	}
 }
 
+// decreaseOffset scrolls the list up by decreasing the offset.
+// Prevents scrolling above the beginning of the list.
 func (m *model) decreaseOffset(n int) {
-	if m.offset > 0 {
-		m.offset -= n
-		if m.offset < 0 {
-			m.offset = 0
+	if m.viewState.offset > 0 {
+		m.viewState.offset -= n
+		if m.viewState.offset < 0 {
+			m.viewState.offset = 0
 		}
-		m.needsRerender = true
+		m.renderState.needsRerender = true
 	}
 }
 
-// UpdateItem implements List.
+// UpdateItem replaces an item at the specified index with a new item.
+// Handles focus management and triggers re-rendering as needed.
 func (m *model) UpdateItem(inx int, item util.Model) {
+	if inx < 0 || inx >= len(m.items) {
+		return
+	}
 	m.items[inx] = item
-	if m.selectedItemInx == inx {
-		if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+	if m.selectionState.selectedIndex == inx {
+		if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
 			i.Focus()
 		}
 	}
 	m.setItemSize(inx)
 	m.rerenderItem(inx)
-	m.needsRerender = true
+	m.renderState.needsRerender = true
 }
 
-// GetSize implements List.
+// GetSize returns the current dimensions of the list.
 func (m *model) GetSize() (int, int) {
-	return m.width, m.height
+	return m.viewState.width, m.viewState.height
 }
 
-// SetSize implements List.
+// SetSize updates the list dimensions and triggers a complete re-render.
+// Also updates the size of all items that support sizing.
 func (m *model) SetSize(width int, height int) tea.Cmd {
-	if m.width == width && m.height == height {
+	if m.viewState.width == width && m.viewState.height == height {
 		return nil
 	}
-	if m.height != height {
-		m.finalHeight = -1
-		m.height = height
+	if m.viewState.height != height {
+		m.renderState.finalHeight = NoFinalHeight
+		m.viewState.height = height
 	}
-	m.width = width
+	m.viewState.width = width
 	m.ResetView()
 	return m.setAllItemsSize()
 }
 
+// getItemSize calculates the available width for items, accounting for padding.
 func (m *model) getItemSize() int {
-	width := m.width
-	if m.padding != nil {
-		if len(m.padding) == 1 {
-			width -= m.padding[0] * 2
-		} else if len(m.padding) == 2 || len(m.padding) == 3 {
-			width -= m.padding[1] * 2
-		} else if len(m.padding) == 4 {
-			width -= m.padding[1] + m.padding[3]
-		}
+	width := m.viewState.width
+	switch len(m.padding) {
+	case 1:
+		width -= m.padding[0] * 2
+	case 2, 3:
+		width -= m.padding[1] * 2
+	case 4:
+		width -= m.padding[1] + m.padding[3]
 	}
-	return width
+	return max(0, width)
 }
 
+// setItemSize updates the size of a specific item if it supports sizing.
 func (m *model) setItemSize(inx int) tea.Cmd {
+	if inx < 0 || inx >= len(m.items) {
+		return nil
+	}
 	if i, ok := m.items[inx].(layout.Sizeable); ok {
-		cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
-		return cmd
+		return i.SetSize(m.getItemSize(), 0)
 	}
 	return nil
 }
 
+// setAllItemsSize updates the size of all items that support sizing.
 func (m *model) setAllItemsSize() tea.Cmd {
 	var cmds []tea.Cmd
 	for i := range m.items {
-		cmd := m.setItemSize(i)
-		cmds = append(cmds, cmd)
+		if cmd := m.setItemSize(i); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	}
 	return tea.Batch(cmds...)
 }
 
+// listHeight calculates the available height for list content, accounting for padding.
 func (m *model) listHeight() int {
-	height := m.height
-	if m.padding != nil {
-		if len(m.padding) == 1 {
-			height -= m.padding[0] * 2
-		} else if len(m.padding) == 2 {
-			height -= m.padding[1] * 2
-		} else if len(m.padding) == 3 {
-			height -= m.padding[0] + m.padding[2]
-		} else if len(m.padding) == 4 {
-			height -= m.padding[0] + m.padding[2]
-		}
+	height := m.viewState.height
+	switch len(m.padding) {
+	case 1:
+		height -= m.padding[0] * 2
+	case 2:
+		height -= m.padding[0] * 2
+	case 3, 4:
+		height -= m.padding[0] + m.padding[2]
 	}
-	return height
+	return max(0, height)
 }
 
-// AppendItem implements List.
+// AppendItem adds a new item to the end of the list.
+// Automatically switches to reverse mode and scrolls to show the new item.
 func (m *model) AppendItem(item util.Model) tea.Cmd {
-	var cmds []tea.Cmd
-	cmd := item.Init()
-	cmds = append(cmds, cmd)
+	cmds := []tea.Cmd{
+		item.Init(),
+	}
 	m.items = append(m.items, item)
-	cmd = m.setItemSize(len(m.items) - 1)
-	cmds = append(cmds, cmd)
-	cmd = m.goToBottom()
-	cmds = append(cmds, cmd)
-	m.needsRerender = true
+	cmds = append(cmds, m.setItemSize(len(m.items)-1))
+	cmds = append(cmds, m.goToBottom())
+	m.renderState.needsRerender = true
 	return tea.Batch(cmds...)
 }
 
-// DeleteItem implements List.
+// DeleteItem removes an item at the specified index.
+// Adjusts selection if necessary and triggers a complete re-render.
 func (m *model) DeleteItem(i int) {
+	if i < 0 || i >= len(m.items) {
+		return
+	}
 	m.items = slices.Delete(m.items, i, i+1)
-	m.renderedItems.Delete(i)
-	if m.selectedItemInx == i {
-		m.selectedItemInx--
+	delete(m.renderState.items, i)
+
+	if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
+		m.selectionState.selectedIndex--
+	} else if m.selectionState.selectedIndex > i {
+		m.selectionState.selectedIndex--
 	}
+
 	m.ResetView()
-	m.needsRerender = true
+	m.renderState.needsRerender = true
 }
 
-// PrependItem implements List.
+// PrependItem adds a new item to the beginning of the list.
+// Adjusts cached positions and selection index, then switches to forward mode.
 func (m *model) PrependItem(item util.Model) tea.Cmd {
-	var cmds []tea.Cmd
-	cmd := item.Init()
-	cmds = append(cmds, cmd)
+	cmds := []tea.Cmd{item.Init()}
 	m.items = append([]util.Model{item}, m.items...)
-	// update the indices of the rendered items
-	newRenderedItems := make(map[int]renderedItem)
-	m.renderedItems.Range(func(key any, value any) bool {
-		keyInt := key.(int)
-		renderedItem := value.(renderedItem)
-		newKey := keyInt + 1
-		newRenderedItems[newKey] = renderedItem
-		return false
-	})
-	m.renderedItems.Clear()
-	for k, v := range newRenderedItems {
-		m.renderedItems.Store(k, v)
-	}
-	cmd = m.goToTop()
-	cmds = append(cmds, cmd)
-	cmd = m.setItemSize(0)
-	cmds = append(cmds, cmd)
-	m.needsRerender = true
+
+	// Shift all cached item indices by 1
+	newItems := make(map[int]renderedItem, len(m.renderState.items))
+	for k, v := range m.renderState.items {
+		newItems[k+1] = v
+	}
+	m.renderState.items = newItems
+
+	if m.selectionState.selectedIndex >= 0 {
+		m.selectionState.selectedIndex++
+	}
+
+	cmds = append(cmds, m.goToTop())
+	cmds = append(cmds, m.setItemSize(0))
+	m.renderState.needsRerender = true
 	return tea.Batch(cmds...)
 }
 
+// setReverse switches between forward and reverse rendering modes.
 func (m *model) setReverse(reverse bool) {
 	if reverse {
 		m.goToBottom()