From 8c6e81791e84bbc53214fde8baa012f2ddb8a752 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 23 May 2025 17:17:39 +0200 Subject: [PATCH] refactor and document --- .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 ---- .../tui/components/chat/messages/messages.go | 71 +- .../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 | 1120 ++++++++++------- 10 files changed, 1513 insertions(+), 989 deletions(-) delete mode 100644 .editorconfig create mode 100644 OpenCode.md delete mode 100644 internal/tui/components/chat/list_v2.go diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 0407ebbcbf7483988728000ce19928a0ffc3cdf6..0000000000000000000000000000000000000000 --- a/.editorconfig +++ /dev/null @@ -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 diff --git a/OpenCode.md b/OpenCode.md new file mode 100644 index 0000000000000000000000000000000000000000..f55de8ccd00bc58596ad01e1c4b3549e9e82bf93 --- /dev/null +++ b/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 diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index b6c67a426d2cb6a58f3fe50e55c011c1953086fc..339b6dd23bad94849dcbf0d2df7aab341a16d295 100644 --- a/internal/tui/components/chat/editor.go +++ b/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...") diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index d8a266d3a6bb8da61453d31da2810b56d65d07f4..b9b43590361a43627f33880bc97d4e7c65badfa1 100644 --- a/internal/tui/components/chat/list.go +++ b/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) } diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go deleted file mode 100644 index 52efd9b0b818a45ec1c045f0024cb35633a192e5..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/list_v2.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index ede75252a0723674c3af6d98774438cd20fbf80f..714f79b3a46b2147bc51c115e0ac6b9ee4482f4d 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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 } diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 28360830538ae717d0cb14761e04b72407fddc1a..b8a7b834543f33be69336eb3bef00493ed95d84c 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/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 ¶mBuilder{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, ¶ms); 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), ¶ms) + if err := vr.unmarshalParams(v.call.Input, ¶ms); 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), ¶ms) + if err := er.unmarshalParams(v.call.Input, ¶ms); 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), ¶ms) + if err := wr.unmarshalParams(v.call.Input, ¶ms); 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), ¶ms) - - 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, ¶ms); 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), ¶ms) - - args := []string{params.Pattern} - if params.Path != "" { - args = append(args, "path", params.Path) + if err := gr.unmarshalParams(v.call.Input, ¶ms); 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), ¶ms) - - 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, ¶ms); 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), ¶ms) + if err := lr.unmarshalParams(v.call.Input, ¶ms); 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), ¶ms) - - 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, ¶ms); 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), ¶ms) - - header := makeHeader("Patch", v.textWidth(), "multiple files") - if res, done := earlyState(header, v); done { - return res + if err := pr.unmarshalParams(v.call.Input, ¶ms); 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 ": 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 "" } diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index d51f659262b83ff6a7ec6cc70c3c4eda189fe987..bee94d67795fb288fe6e062f68b57155ed4ccbb5 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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 } diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go index 1c26ef26764bb09d1e7219ccc8f7cb4fe29b0b80..db1eeafc973f85218b36750c79d337bf9ed41e02 100644 --- a/internal/tui/components/core/list/keys.go +++ b/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"), diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 628b6835f83ec17d0a853889043290c7244a5006..4bcb167a5062942d83d938073e4356f2d834c02e 100644 --- a/internal/tui/components/core/list/list.go +++ b/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() @@ -697,24 +908,29 @@ func (m *model) setReverse(reverse bool) { } } -// SetItems implements List. +// SetItems replaces all items in the list with a new set. +// Initializes all items, sets their sizes, and establishes initial selection. func (m *model) SetItems(items []util.Model) tea.Cmd { m.items = items - var cmds []tea.Cmd - cmd := m.setAllItemsSize() - cmds = append(cmds, cmd) + cmds := []tea.Cmd{m.setAllItemsSize()} + for _, item := range m.items { cmds = append(cmds, item.Init()) } - if m.reverse { - m.selectedItemInx = len(m.items) - 1 - cmd := m.focusSelected() - cmds = append(cmds, cmd) + + if len(m.items) > 0 { + if m.viewState.reverse { + m.selectionState.selectedIndex = len(m.items) - 1 + } else { + m.selectionState.selectedIndex = 0 + } + if cmd := m.focusSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } else { - m.selectedItemInx = 0 - cmd := m.focusSelected() - cmds = append(cmds, cmd) + m.selectionState.selectedIndex = NoSelection } + m.ResetView() return tea.Batch(cmds...) }