Detailed changes
@@ -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
@@ -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
@@ -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...")
@@ -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)
}
@@ -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)
-}
@@ -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
}
@@ -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 "<Tool>: param (key=value)" and truncates as needed.
func makeHeader(tool string, width int, params ...string) string {
prefix := tool + ": "
- return prefix + renderParams(width-lipgloss.Width(prefix), params...)
+ return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
}
-// renders params, params[0] (params[1]=params[2] ....)
-func renderParams(paramsWidth int, params ...string) string {
+// renderParamList renders params, params[0] (params[1]=params[2] ....)
+func renderParamList(paramsWidth int, params ...string) string {
if len(params) == 0 {
return ""
}
@@ -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
}
@@ -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"),
@@ -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()