on main help sign in
refactor and document
commit8c6e81791e84bbc53214fde8baa012f2ddb8a752 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 23 May 2025, 17:17 (1 year ago) parent2883256
10 files changed+1513−989
.editorconfig−18 OpenCode.md+22 internal/tui/components/chat/editor.go−4 internal/tui/components/chat/list.go+421−26 internal/tui/components/chat/list_v2.go−279 internal/tui/components/chat/messages/messages.go+53−18 internal/tui/components/chat/messages/renderer.go+283−164 internal/tui/components/chat/messages/tool.go+65−27 internal/tui/components/core/list/keys.go+1−1 internal/tui/components/core/list/list.go+668−452
.editorconfigdeleted−18
@@ -1,18 +0,0 @@
1−# https://editorconfig.org/
2−
3−root = true
4−
5−[*]
6−charset = utf-8
7−insert_final_newline = true
8−trim_trailing_whitespace = true
9−indent_style = space
10−indent_size = 2
11−
12−[*.go]
13−indent_style = tab
14−indent_size = 4
15−
16−[*.golden]
17−insert_final_newline = false
18−trim_trailing_whitespace = false
OpenCode.mdnew file+22
@@ -0,0 +1,22 @@
1+# OpenCode Development Guide
2+
3+## Build/Test/Lint Commands
4+
5+- **Build**: `go build ./...` or `go build .` (for main binary)
6+- **Test**: `task test` or `go test ./...`
7+- **Single test**: `go test ./internal/path/to/package -run TestName`
8+- **Lint**: `task lint` or `golangci-lint run`
9+- **Format**: `task fmt` or `gofumpt -w .`
10+
11+## Code Style Guidelines
12+
13+- **Imports**: Standard library first, then third-party, then internal packages (separated by blank lines)
14+- **Types**: Use `any` instead of `interface{}`, prefer concrete types over interfaces when possible
15+- **Naming**: Use camelCase for private, PascalCase for public, descriptive names (e.g., `messageListCmp`, `handleNewUserMessage`)
16+- **Constants**: Use `const` blocks with descriptive names (e.g., `NotFound = -1`)
17+- **Error handling**: Always check errors, use `require.NoError()` in tests, return errors up the stack
18+- **Documentation**: Add comments for all public types/methods, explain complex logic in private methods
19+- **Testing**: Use testify/assert and testify/require, table-driven tests with `t.Run()`, mark helpers with `t.Helper()`
20+- **File organization**: Group related functionality, extract helper methods for complex logic, use meaningful method names
21+- **TUI components**: Implement interfaces (util.Model, layout.Sizeable), document component purpose and behavior
22+- **Message handling**: Use pubsub events, handle different message roles (User/Assistant/Tool), manage tool calls separately
internal/tui/components/chat/editor.go−4
@@ -185,10 +185,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185185 return m, nil
186186 }
187187 }
188− if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
189− key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
190− return m, nil
191− }
192188 if key.Matches(msg, editorMaps.OpenEditor) {
193189 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
194190 return m, util.ReportWarn("Agent is working, please wait...")
internal/tui/components/chat/list.go+421−26
@@ -1,29 +1,424 @@
11package chat
22
3−import "github.com/charmbracelet/bubbles/v2/key"
4−
5−type MessageKeys struct {
6− PageDown key.Binding
7− PageUp key.Binding
8− HalfPageUp key.Binding
9− HalfPageDown key.Binding
10−}
11−
12−var messageKeys = MessageKeys{
13− PageDown: key.NewBinding(
14− key.WithKeys("pgdown"),
15− key.WithHelp("f/pgdn", "page down"),
16− ),
17− PageUp: key.NewBinding(
18− key.WithKeys("pgup"),
19− key.WithHelp("b/pgup", "page up"),
20− ),
21− HalfPageUp: key.NewBinding(
22− key.WithKeys("ctrl+u"),
23− key.WithHelp("ctrl+u", "½ page up"),
24− ),
25− HalfPageDown: key.NewBinding(
26− key.WithKeys("ctrl+d", "ctrl+d"),
27− key.WithHelp("ctrl+d", "½ page down"),
28− ),
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/lipgloss/v2"
10+ "github.com/opencode-ai/opencode/internal/app"
11+ "github.com/opencode-ai/opencode/internal/logging"
12+ "github.com/opencode-ai/opencode/internal/message"
13+ "github.com/opencode-ai/opencode/internal/pubsub"
14+ "github.com/opencode-ai/opencode/internal/session"
15+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
16+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
17+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
18+ "github.com/opencode-ai/opencode/internal/tui/layout"
19+ "github.com/opencode-ai/opencode/internal/tui/util"
20+)
21+
22+const (
23+ NotFound = -1
24+)
25+
26+// MessageListCmp represents a component that displays a list of chat messages
27+// with support for real-time updates and session management.
28+type MessageListCmp interface {
29+ util.Model
30+ layout.Sizeable
31+}
32+
33+// messageListCmp implements MessageListCmp, providing a virtualized list
34+// of chat messages with support for tool calls, real-time updates, and
35+// session switching.
36+type messageListCmp struct {
37+ app *app.App
38+ width, height int
39+ session session.Session
40+ listCmp list.ListModel
41+
42+ lastUserMessageTime int64
43+}
44+
45+// NewMessagesListCmp creates a new message list component with custom keybindings
46+// and reverse ordering (newest messages at bottom).
47+func NewMessagesListCmp(app *app.App) MessageListCmp {
48+ defaultKeymaps := list.DefaultKeymap()
49+ defaultKeymaps.NDown.SetEnabled(false)
50+ defaultKeymaps.NUp.SetEnabled(false)
51+ defaultKeymaps.Home = key.NewBinding(
52+ key.WithKeys("ctrl+g"),
53+ )
54+ defaultKeymaps.End = key.NewBinding(
55+ key.WithKeys("ctrl+G"),
56+ )
57+ return &messageListCmp{
58+ app: app,
59+ listCmp: list.New(
60+ list.WithGapSize(1),
61+ list.WithReverse(true),
62+ list.WithKeyMap(defaultKeymaps),
63+ ),
64+ }
65+}
66+
67+// Init initializes the component (no initialization needed).
68+func (m *messageListCmp) Init() tea.Cmd {
69+ return nil
70+}
71+
72+// Update handles incoming messages and updates the component state.
73+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74+ switch msg := msg.(type) {
75+ case dialog.ThemeChangedMsg:
76+ m.listCmp.ResetView()
77+ return m, nil
78+ case SessionSelectedMsg:
79+ if msg.ID != m.session.ID {
80+ cmd := m.SetSession(msg)
81+ return m, cmd
82+ }
83+ return m, nil
84+ case SessionClearedMsg:
85+ m.session = session.Session{}
86+ return m, m.listCmp.SetItems([]util.Model{})
87+
88+ case pubsub.Event[message.Message]:
89+ cmd := m.handleMessageEvent(msg)
90+ return m, cmd
91+ default:
92+ var cmds []tea.Cmd
93+ u, cmd := m.listCmp.Update(msg)
94+ m.listCmp = u.(list.ListModel)
95+ cmds = append(cmds, cmd)
96+ return m, tea.Batch(cmds...)
97+ }
98+}
99+
100+// View renders the message list or an initial screen if empty.
101+func (m *messageListCmp) View() string {
102+ if len(m.listCmp.Items()) == 0 {
103+ return initialScreen()
104+ }
105+ return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
106+}
107+
108+// handleChildSession handles messages from child sessions (agent tools).
109+// TODO: update the agent tool message with the changes
110+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
111+ // Implementation pending
112+}
113+
114+// handleMessageEvent processes different types of message events (created/updated).
115+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
116+ switch event.Type {
117+ case pubsub.CreatedEvent:
118+ if event.Payload.SessionID != m.session.ID {
119+ m.handleChildSession(event)
120+ return nil
121+ }
122+
123+ if m.messageExists(event.Payload.ID) {
124+ return nil
125+ }
126+
127+ return m.handleNewMessage(event.Payload)
128+ case pubsub.UpdatedEvent:
129+ return m.handleUpdateAssistantMessage(event.Payload)
130+ }
131+ return nil
132+}
133+
134+// messageExists checks if a message with the given ID already exists in the list.
135+func (m *messageListCmp) messageExists(messageID string) bool {
136+ items := m.listCmp.Items()
137+ // Search backwards as new messages are more likely to be at the end
138+ for i := len(items) - 1; i >= 0; i-- {
139+ if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
140+ return true
141+ }
142+ }
143+ return false
144+}
145+
146+// handleNewMessage routes new messages to appropriate handlers based on role.
147+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
148+ switch msg.Role {
149+ case message.User:
150+ return m.handleNewUserMessage(msg)
151+ case message.Assistant:
152+ return m.handleNewAssistantMessage(msg)
153+ case message.Tool:
154+ return m.handleToolMessage(msg)
155+ }
156+ return nil
157+}
158+
159+// handleNewUserMessage adds a new user message to the list and updates the timestamp.
160+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
161+ m.lastUserMessageTime = msg.CreatedAt
162+ return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
163+}
164+
165+// handleToolMessage updates existing tool calls with their results.
166+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
167+ items := m.listCmp.Items()
168+ for _, tr := range msg.ToolResults() {
169+ if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
170+ toolCall := items[toolCallIndex].(messages.ToolCallCmp)
171+ toolCall.SetToolResult(tr)
172+ m.listCmp.UpdateItem(toolCallIndex, toolCall)
173+ }
174+ }
175+ return nil
176+}
177+
178+// findToolCallByID searches for a tool call with the specified ID.
179+// Returns the index if found, NotFound otherwise.
180+func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
181+ // Search backwards as tool calls are more likely to be recent
182+ for i := len(items) - 1; i >= 0; i-- {
183+ if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
184+ return i
185+ }
186+ }
187+ return NotFound
188+}
189+
190+// handleUpdateAssistantMessage processes updates to assistant messages,
191+// managing both message content and associated tool calls.
192+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
193+ var cmds []tea.Cmd
194+ items := m.listCmp.Items()
195+
196+ // Find existing assistant message and tool calls for this message
197+ assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
198+
199+ logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantIndex, "toolCalls", existingToolCalls)
200+
201+ // Handle assistant message content
202+ if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
203+ cmds = append(cmds, cmd)
204+ }
205+
206+ // Handle tool calls
207+ if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
208+ cmds = append(cmds, cmd)
209+ }
210+
211+ return tea.Batch(cmds...)
212+}
213+
214+// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
215+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
216+ assistantIndex := NotFound
217+ toolCalls := make(map[int]messages.ToolCallCmp)
218+
219+ // Search backwards as messages are more likely to be at the end
220+ for i := len(items) - 1; i >= 0; i-- {
221+ item := items[i]
222+ if asMsg, ok := item.(messages.MessageCmp); ok {
223+ if asMsg.GetMessage().ID == messageID {
224+ assistantIndex = i
225+ }
226+ } else if tc, ok := item.(messages.ToolCallCmp); ok {
227+ if tc.ParentMessageId() == messageID {
228+ toolCalls[i] = tc
229+ }
230+ }
231+ }
232+
233+ return assistantIndex, toolCalls
234+}
235+
236+// updateAssistantMessageContent updates or removes the assistant message based on content.
237+func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
238+ if assistantIndex == NotFound {
239+ return nil
240+ }
241+
242+ shouldShowMessage := m.shouldShowAssistantMessage(msg)
243+ hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
244+
245+ if shouldShowMessage {
246+ m.listCmp.UpdateItem(
247+ assistantIndex,
248+ messages.NewMessageCmp(
249+ msg,
250+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
251+ ),
252+ )
253+ } else if hasToolCallsOnly {
254+ m.listCmp.DeleteItem(assistantIndex)
255+ }
256+
257+ return nil
258+}
259+
260+// shouldShowAssistantMessage determines if an assistant message should be displayed.
261+func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
262+ return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
263+}
264+
265+// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
266+func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
267+ var cmds []tea.Cmd
268+
269+ for _, tc := range msg.ToolCalls() {
270+ if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
271+ cmds = append(cmds, cmd)
272+ }
273+ }
274+
275+ return tea.Batch(cmds...)
276+}
277+
278+// updateOrAddToolCall updates an existing tool call or adds a new one.
279+func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
280+ // Try to find existing tool call
281+ for index, existingTC := range existingToolCalls {
282+ if tc.ID == existingTC.GetToolCall().ID {
283+ existingTC.SetToolCall(tc)
284+ m.listCmp.UpdateItem(index, existingTC)
285+ return nil
286+ }
287+ }
288+
289+ // Add new tool call if not found
290+ return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
291+}
292+
293+// handleNewAssistantMessage processes new assistant messages and their tool calls.
294+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
295+ var cmds []tea.Cmd
296+
297+ // Add assistant message if it should be displayed
298+ if m.shouldShowAssistantMessage(msg) {
299+ cmd := m.listCmp.AppendItem(
300+ messages.NewMessageCmp(
301+ msg,
302+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
303+ ),
304+ )
305+ cmds = append(cmds, cmd)
306+ }
307+
308+ // Add tool calls
309+ for _, tc := range msg.ToolCalls() {
310+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
311+ cmds = append(cmds, cmd)
312+ }
313+
314+ return tea.Batch(cmds...)
315+}
316+
317+// SetSession loads and displays messages for a new session.
318+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
319+ if m.session.ID == session.ID {
320+ return nil
321+ }
322+
323+ m.session = session
324+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
325+ if err != nil {
326+ return util.ReportError(err)
327+ }
328+
329+ if len(sessionMessages) == 0 {
330+ return m.listCmp.SetItems([]util.Model{})
331+ }
332+
333+ // Initialize with first message timestamp
334+ m.lastUserMessageTime = sessionMessages[0].CreatedAt
335+
336+ // Build tool result map for efficient lookup
337+ toolResultMap := m.buildToolResultMap(sessionMessages)
338+
339+ // Convert messages to UI components
340+ uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
341+
342+ return m.listCmp.SetItems(uiMessages)
343+}
344+
345+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
346+func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
347+ toolResultMap := make(map[string]message.ToolResult)
348+ for _, msg := range messages {
349+ for _, tr := range msg.ToolResults() {
350+ toolResultMap[tr.ToolCallID] = tr
351+ }
352+ }
353+ return toolResultMap
354+}
355+
356+// convertMessagesToUI converts database messages to UI components.
357+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
358+ uiMessages := make([]util.Model, 0)
359+
360+ for _, msg := range sessionMessages {
361+ switch msg.Role {
362+ case message.User:
363+ m.lastUserMessageTime = msg.CreatedAt
364+ uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
365+ case message.Assistant:
366+ uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
367+ }
368+ }
369+
370+ return uiMessages
371+}
372+
373+// convertAssistantMessage converts an assistant message and its tool calls to UI components.
374+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
375+ var uiMessages []util.Model
376+
377+ // Add assistant message if it should be displayed
378+ if m.shouldShowAssistantMessage(msg) {
379+ uiMessages = append(
380+ uiMessages,
381+ messages.NewMessageCmp(
382+ msg,
383+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
384+ ),
385+ )
386+ }
387+
388+ // Add tool calls with their results and status
389+ for _, tc := range msg.ToolCalls() {
390+ options := m.buildToolCallOptions(tc, msg, toolResultMap)
391+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
392+ }
393+
394+ return uiMessages
395+}
396+
397+// buildToolCallOptions creates options for tool call components based on results and status.
398+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
399+ var options []messages.ToolCallOption
400+
401+ // Add tool result if available
402+ if tr, ok := toolResultMap[tc.ID]; ok {
403+ options = append(options, messages.WithToolCallResult(tr))
404+ }
405+
406+ // Add cancelled status if applicable
407+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
408+ options = append(options, messages.WithToolCallCancelled())
409+ }
410+
411+ return options
412+}
413+
414+// GetSize returns the current width and height of the component.
415+func (m *messageListCmp) GetSize() (int, int) {
416+ return m.width, m.height
417+}
418+
419+// SetSize updates the component dimensions and propagates to the list component.
420+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
421+ m.width = width
422+ m.height = height - 1
423+ return m.listCmp.SetSize(width, height-1)
29424}
internal/tui/components/chat/list_v2.godeleted−279
@@ -1,279 +0,0 @@
1−package chat
2−
3−import (
4− "context"
5− "time"
6−
7− tea "github.com/charmbracelet/bubbletea/v2"
8− "github.com/charmbracelet/lipgloss/v2"
9− "github.com/opencode-ai/opencode/internal/app"
10− "github.com/opencode-ai/opencode/internal/logging"
11− "github.com/opencode-ai/opencode/internal/message"
12− "github.com/opencode-ai/opencode/internal/pubsub"
13− "github.com/opencode-ai/opencode/internal/session"
14− "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
15− "github.com/opencode-ai/opencode/internal/tui/components/core/list"
16− "github.com/opencode-ai/opencode/internal/tui/components/dialog"
17− "github.com/opencode-ai/opencode/internal/tui/layout"
18− "github.com/opencode-ai/opencode/internal/tui/util"
19−)
20−
21−type MessageListCmp interface {
22− util.Model
23− layout.Sizeable
24−}
25−
26−type messageListCmp struct {
27− app *app.App
28− width, height int
29− session session.Session
30− listCmp list.ListModel
31−
32− lastUserMessageTime int64
33−}
34−
35−func NewMessagesListCmp(app *app.App) MessageListCmp {
36− return &messageListCmp{
37− app: app,
38− listCmp: list.New(
39− list.WithGapSize(1),
40− list.WithReverse(true),
41− ),
42− }
43−}
44−
45−func (m *messageListCmp) Init() tea.Cmd {
46− return nil
47−}
48−
49−func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50− switch msg := msg.(type) {
51− case dialog.ThemeChangedMsg:
52− m.listCmp.ResetView()
53− return m, nil
54− case SessionSelectedMsg:
55− if msg.ID != m.session.ID {
56− cmd := m.SetSession(msg)
57− return m, cmd
58− }
59− return m, nil
60− case SessionClearedMsg:
61− m.session = session.Session{}
62− return m, m.listCmp.SetItems([]util.Model{})
63−
64− case pubsub.Event[message.Message]:
65− cmd := m.handleMessageEvent(msg)
66− return m, cmd
67− default:
68− var cmds []tea.Cmd
69− u, cmd := m.listCmp.Update(msg)
70− m.listCmp = u.(list.ListModel)
71− cmds = append(cmds, cmd)
72− return m, tea.Batch(cmds...)
73− }
74−}
75−
76−func (m *messageListCmp) View() string {
77− if len(m.listCmp.Items()) == 0 {
78− return initialScreen()
79− }
80− return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
81−}
82−
83−func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
84− // TODO: update the agent tool message with the changes
85−}
86−
87−func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
88− switch event.Type {
89− case pubsub.CreatedEvent:
90− if event.Payload.SessionID != m.session.ID {
91− m.handleChildSession(event)
92− }
93− messageExists := false
94− // more likely to be at the end of the list
95− items := m.listCmp.Items()
96− for i := len(items) - 1; i >= 0; i-- {
97− msg, ok := items[i].(messages.MessageCmp)
98− if ok && msg.GetMessage().ID == event.Payload.ID {
99− messageExists = true
100− break
101− }
102− }
103− if messageExists {
104− return nil
105− }
106− switch event.Payload.Role {
107− case message.User:
108− return m.handleNewUserMessage(event.Payload)
109− case message.Assistant:
110− return m.handleNewAssistantMessage(event.Payload)
111− case message.Tool:
112− return m.handleToolMessage(event.Payload)
113− }
114− case pubsub.UpdatedEvent:
115− return m.handleUpdateAssistantMessage(event.Payload)
116− }
117− return nil
118−}
119−
120−func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
121− m.lastUserMessageTime = msg.CreatedAt
122− return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
123−}
124−
125−func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
126− items := m.listCmp.Items()
127− for _, tr := range msg.ToolResults() {
128− for i := len(items) - 1; i >= 0; i-- {
129− message := items[i]
130− if toolCall, ok := message.(messages.ToolCallCmp); ok {
131− if toolCall.GetToolCall().ID == tr.ToolCallID {
132− toolCall.SetToolResult(tr)
133− m.listCmp.UpdateItem(
134− i,
135− toolCall,
136− )
137− break
138− }
139− }
140− }
141− }
142− return nil
143−}
144−
145−func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
146− var cmds []tea.Cmd
147− // Simple update the content
148− items := m.listCmp.Items()
149− assistantMessageInx := -1
150− toolCalls := map[int]messages.ToolCallCmp{}
151−
152− // we go backwards because the messages are most likely at the end of the list
153− for i := len(items) - 1; i >= 0; i-- {
154− message := items[i]
155− if asMsg, ok := message.(messages.MessageCmp); ok {
156− if asMsg.GetMessage().ID == msg.ID {
157− assistantMessageInx = i
158− }
159− } else if tc, ok := message.(messages.ToolCallCmp); ok {
160− if tc.ParentMessageId() == msg.ID {
161− toolCalls[i] = tc
162− }
163− }
164− }
165−
166− logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantMessageInx, "toolCalls", toolCalls)
167−
168− if assistantMessageInx > -1 && (len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()) {
169− m.listCmp.UpdateItem(
170− assistantMessageInx,
171− messages.NewMessageCmp(
172− msg,
173− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
174− ),
175− )
176− } else if assistantMessageInx > -1 && len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
177− m.listCmp.DeleteItem(assistantMessageInx)
178− }
179− for _, tc := range msg.ToolCalls() {
180− found := false
181− for inx, tcc := range toolCalls {
182− if tc.ID == tcc.GetToolCall().ID {
183− tcc.SetToolCall(tc)
184− m.listCmp.UpdateItem(
185− inx,
186− tcc,
187− )
188− found = true
189− break
190− }
191− }
192− if !found {
193− cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
194− cmds = append(cmds, cmd)
195− }
196− }
197−
198− return tea.Batch(cmds...)
199−}
200−
201−func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
202− var cmds []tea.Cmd
203− // Only add assistant messages if they don't have tool calls or there is some content
204− if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
205− cmd := m.listCmp.AppendItem(
206− messages.NewMessageCmp(
207− msg,
208− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
209− ),
210− )
211− cmds = append(cmds, cmd)
212− }
213− for _, tc := range msg.ToolCalls() {
214− cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
215− cmds = append(cmds, cmd)
216− }
217− return tea.Batch(cmds...)
218−}
219−
220−func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
221− if m.session.ID == session.ID {
222− return nil
223− }
224− m.session = session
225− sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
226− if err != nil {
227− return util.ReportError(err)
228− }
229− uiMessages := make([]util.Model, 0)
230− m.lastUserMessageTime = sessionMessages[0].CreatedAt
231− toolResultMap := make(map[string]message.ToolResult)
232− // first pass to get all tool results
233− for _, msg := range sessionMessages {
234− for _, tr := range msg.ToolResults() {
235− toolResultMap[tr.ToolCallID] = tr
236− }
237− }
238− for _, msg := range sessionMessages {
239− switch msg.Role {
240− case message.User:
241− m.lastUserMessageTime = msg.CreatedAt
242− uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
243− case message.Assistant:
244− // Only add assistant messages if they don't have tool calls or there is some content
245− if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
246− uiMessages = append(
247− uiMessages,
248− messages.NewMessageCmp(
249− msg,
250− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
251− ),
252− )
253− }
254− for _, tc := range msg.ToolCalls() {
255− options := []messages.ToolCallOption{}
256− if tr, ok := toolResultMap[tc.ID]; ok {
257− options = append(options, messages.WithToolCallResult(tr))
258− }
259− if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
260− options = append(options, messages.WithToolCallCancelled())
261− }
262− uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
263− }
264− }
265− }
266− return m.listCmp.SetItems(uiMessages)
267−}
268−
269−// GetSize implements MessageListCmp.
270−func (m *messageListCmp) GetSize() (int, int) {
271− return m.width, m.height
272−}
273−
274−// SetSize implements MessageListCmp.
275−func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
276− m.width = width
277− m.height = height - 1
278− return m.listCmp.SetSize(width, height-1)
279−}
internal/tui/components/chat/messages/messages.go+53−18
@@ -19,32 +19,42 @@ import (
1919 "github.com/opencode-ai/opencode/internal/tui/util"
2020)
2121
22+// MessageCmp defines the interface for message components in the chat interface.
23+// It combines standard UI model interfaces with message-specific functionality.
2224type MessageCmp interface {
23− util.Model
24− layout.Sizeable
25− layout.Focusable
26− GetMessage() message.Message
27− Spinning() bool
25+ util.Model // Basic Bubble Tea model interface
26+ layout.Sizeable // Width/height management
27+ layout.Focusable // Focus state management
28+ GetMessage() message.Message // Access to underlying message data
29+ Spinning() bool // Animation state for loading messages
2830}
2931
32+// messageCmp implements the MessageCmp interface for displaying chat messages.
33+// It handles rendering of user and assistant messages with proper styling,
34+// animations, and state management.
3035type messageCmp struct {
31− width int
32− focused bool
33−
34− // Used for agent and user messages
35− message message.Message
36− spinning bool
37− anim util.Model
38− lastUserMessageTime time.Time
36+ width int // Component width for text wrapping
37+ focused bool // Focus state for border styling
38+
39+ // Core message data and state
40+ message message.Message // The underlying message content
41+ spinning bool // Whether to show loading animation
42+ anim util.Model // Animation component for loading states
43+ lastUserMessageTime time.Time // Used for calculating response duration
3944}
45+
46+// MessageOption provides functional options for configuring message components
4047type MessageOption func(*messageCmp)
4148
49+// WithLastUserMessageTime sets the timestamp of the last user message
50+// for calculating assistant response duration
4251func WithLastUserMessageTime(t time.Time) MessageOption {
4352 return func(m *messageCmp) {
4453 m.lastUserMessageTime = t
4554 }
4655}
4756
57+// NewMessageCmp creates a new message component with the given message and options
4858func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
4959 m := &messageCmp{
5060 message: msg,
@@ -56,6 +66,8 @@ func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
5666 return m
5767}
5868
69+// Init initializes the message component and starts animations if needed.
70+// Returns a command to start the animation for spinning messages.
5971func (m *messageCmp) Init() tea.Cmd {
6072 m.spinning = m.shouldSpin()
6173 if m.spinning {
@@ -64,6 +76,8 @@ func (m *messageCmp) Init() tea.Cmd {
6476 return nil
6577}
6678
79+// Update handles incoming messages and updates the component state.
80+// Manages animation updates for spinning messages and stops animation when appropriate.
6781func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6882 switch msg := msg.(type) {
6983 case anim.ColorCycleMsg, anim.StepCharsMsg:
@@ -77,6 +91,8 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7791 return m, nil
7892}
7993
94+// View renders the message component based on its current state.
95+// Returns different views for spinning, user, and assistant messages.
8096func (m *messageCmp) View() string {
8197 if m.spinning {
8298 return m.style().PaddingLeft(1).Render(m.anim.View())
@@ -93,15 +109,19 @@ func (m *messageCmp) View() string {
93109 return "Unknown Message"
94110}
95111
96−// GetMessage implements MessageCmp.
112+// GetMessage returns the underlying message data
97113func (m *messageCmp) GetMessage() message.Message {
98114 return m.message
99115}
100116
117+// textWidth calculates the available width for text content,
118+// accounting for borders and padding
101119func (m *messageCmp) textWidth() int {
102120 return m.width - 1 // take into account the border
103121}
104122
123+// style returns the lipgloss style for the message component.
124+// Applies different border colors and styles based on message role and focus state.
105125func (msg *messageCmp) style() lipgloss.Style {
106126 t := theme.CurrentTheme()
107127 var borderColor color.Color
@@ -127,6 +147,8 @@ func (msg *messageCmp) style() lipgloss.Style {
127147 BorderStyle(borderStyle)
128148}
129149
150+// renderAssistantMessage renders assistant messages with optional footer information.
151+// Shows model name, response time, and finish reason when the message is complete.
130152func (m *messageCmp) renderAssistantMessage() string {
131153 parts := []string{
132154 m.markdownContent(),
@@ -156,6 +178,8 @@ func (m *messageCmp) renderAssistantMessage() string {
156178 return m.style().Render(joined)
157179}
158180
181+// renderUserMessage renders user messages with file attachments.
182+// Displays message content and any attached files with appropriate icons.
159183func (m *messageCmp) renderUserMessage() string {
160184 t := theme.CurrentTheme()
161185 parts := []string{
@@ -183,12 +207,15 @@ func (m *messageCmp) renderUserMessage() string {
183207 return m.style().Render(joined)
184208}
185209
210+// toMarkdown converts text content to rendered markdown using the configured renderer
186211func (m *messageCmp) toMarkdown(content string) string {
187212 r := styles.GetMarkdownRenderer(m.textWidth())
188213 rendered, _ := r.Render(content)
189214 return strings.TrimSuffix(rendered, "\n")
190215}
191216
217+// markdownContent processes the message content and handles special states.
218+// Returns appropriate content for thinking, finished, and error states.
192219func (m *messageCmp) markdownContent() string {
193220 content := m.message.Content().String()
194221 if m.message.Role == message.Assistant {
@@ -210,6 +237,8 @@ func (m *messageCmp) markdownContent() string {
210237 return m.toMarkdown(content)
211238}
212239
240+// shouldSpin determines whether the message should show a loading animation.
241+// Only assistant messages without content that aren't finished should spin.
213242func (m *messageCmp) shouldSpin() bool {
214243 if m.message.Role != message.Assistant {
215244 return false
@@ -225,33 +254,39 @@ func (m *messageCmp) shouldSpin() bool {
225254 return true
226255}
227256
228−// Blur implements MessageModel.
257+// Focus management methods
258+
259+// Blur removes focus from the message component
229260func (m *messageCmp) Blur() tea.Cmd {
230261 m.focused = false
231262 return nil
232263}
233264
234−// Focus implements MessageModel.
265+// Focus sets focus on the message component
235266func (m *messageCmp) Focus() tea.Cmd {
236267 m.focused = true
237268 return nil
238269}
239270
240−// IsFocused implements MessageModel.
271+// IsFocused returns whether the message component is currently focused
241272func (m *messageCmp) IsFocused() bool {
242273 return m.focused
243274}
244275
276+// Size management methods
277+
278+// GetSize returns the current dimensions of the message component
245279func (m *messageCmp) GetSize() (int, int) {
246280 return m.width, 0
247281}
248282
283+// SetSize updates the width of the message component for text wrapping
249284func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
250285 m.width = width
251286 return nil
252287}
253288
254−// Spinning implements MessageCmp.
289+// Spinning returns whether the message is currently showing a loading animation
255290func (m *messageCmp) Spinning() bool {
256291 return m.spinning
257292}
internal/tui/components/chat/messages/renderer.go+283−164
@@ -17,19 +17,26 @@ import (
1717 "github.com/opencode-ai/opencode/internal/tui/theme"
1818)
1919
20+// responseContextHeight limits the number of lines displayed in tool output
2021const responseContextHeight = 10
2122
23+// renderer defines the interface for tool-specific rendering implementations
2224type renderer interface {
2325 // Render returns the complete (already styled) tool‑call view, not
2426 // including the outer border.
2527 Render(v *toolCallCmp) string
2628}
2729
30+// rendererFactory creates new renderer instances
2831type rendererFactory func() renderer
2932
33+// renderRegistry manages the mapping of tool names to their renderers
3034type renderRegistry map[string]rendererFactory
3135
36+// register adds a new renderer factory to the registry
3237func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
38+
39+// lookup retrieves a renderer for the given tool name, falling back to generic renderer
3340func (rr renderRegistry) lookup(name string) renderer {
3441 if f, ok := rr[name]; ok {
3542 return f()
@@ -37,9 +44,67 @@ func (rr renderRegistry) lookup(name string) renderer {
3744 return genericRenderer{} // sensible fallback
3845}
3946
47+// registry holds all registered tool renderers
4048var registry = renderRegistry{}
4149
42−// Registger tool renderers
50+// baseRenderer provides common functionality for all tool renderers
51+type baseRenderer struct{}
52+
53+// paramBuilder helps construct parameter lists for tool headers
54+type paramBuilder struct {
55+ args []string
56+}
57+
58+// newParamBuilder creates a new parameter builder
59+func newParamBuilder() *paramBuilder {
60+ return &paramBuilder{args: make([]string, 0)}
61+}
62+
63+// addMain adds the main parameter (first argument)
64+func (pb *paramBuilder) addMain(value string) *paramBuilder {
65+ if value != "" {
66+ pb.args = append(pb.args, value)
67+ }
68+ return pb
69+}
70+
71+// addKeyValue adds a key-value pair parameter
72+func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
73+ if value != "" {
74+ pb.args = append(pb.args, key, value)
75+ }
76+ return pb
77+}
78+
79+// addFlag adds a boolean flag parameter
80+func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
81+ if value {
82+ pb.args = append(pb.args, key, "true")
83+ }
84+ return pb
85+}
86+
87+// build returns the final parameter list
88+func (pb *paramBuilder) build() []string {
89+ return pb.args
90+}
91+
92+// renderWithParams provides a common rendering pattern for tools with parameters
93+func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
94+ header := makeHeader(toolName, v.textWidth(), args...)
95+ if res, done := earlyState(header, v); done {
96+ return res
97+ }
98+ body := contentRenderer()
99+ return joinHeaderBody(header, body)
100+}
101+
102+// unmarshalParams safely unmarshals JSON parameters
103+func (br baseRenderer) unmarshalParams(input string, target any) error {
104+ return json.Unmarshal([]byte(input), target)
105+}
106+
107+// Register tool renderers
43108func init() {
44109 registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
45110 registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
@@ -58,304 +123,358 @@ func init() {
58123// Generic renderer
59124// -----------------------------------------------------------------------------
60125
61−type genericRenderer struct{}
126+// genericRenderer handles unknown tool types with basic parameter display
127+type genericRenderer struct {
128+ baseRenderer
129+}
62130
63−func (genericRenderer) Render(v *toolCallCmp) string {
64− header := makeHeader(prettifyToolName(v.call.Name), v.textWidth(), v.call.Input)
65− if res, done := earlyState(header, v); done {
66− return res
67− }
68− body := renderPlainContent(v, v.result.Content)
69− return joinHeaderBody(header, body)
131+// Render displays the tool call with its raw input and plain content output
132+func (gr genericRenderer) Render(v *toolCallCmp) string {
133+ return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
134+ return renderPlainContent(v, v.result.Content)
135+ })
70136}
71137
72138// -----------------------------------------------------------------------------
73139// Bash renderer
74140// -----------------------------------------------------------------------------
75141
76−type bashRenderer struct{}
77−
78−func (bashRenderer) Render(v *toolCallCmp) string {
79− var p tools.BashParams
80− _ = json.Unmarshal([]byte(v.call.Input), &p)
142+// bashRenderer handles bash command execution display
143+type bashRenderer struct {
144+ baseRenderer
145+}
81146
82− cmd := strings.ReplaceAll(p.Command, "\n", " ")
83− header := makeHeader("Bash", v.textWidth(), cmd)
84− if res, done := earlyState(header, v); done {
85− return res
147+// Render displays the bash command with sanitized newlines and plain output
148+func (br bashRenderer) Render(v *toolCallCmp) string {
149+ var params tools.BashParams
150+ if err := br.unmarshalParams(v.call.Input, &params); err != nil {
151+ return br.renderError(v, "Invalid bash parameters")
86152 }
87− body := renderPlainContent(v, v.result.Content)
88− return joinHeaderBody(header, body)
153+
154+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
155+ args := newParamBuilder().addMain(cmd).build()
156+
157+ return br.renderWithParams(v, "Bash", args, func() string {
158+ return renderPlainContent(v, v.result.Content)
159+ })
160+}
161+
162+// renderError provides consistent error rendering
163+func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
164+ header := makeHeader("Error", v.textWidth(), message)
165+ return joinHeaderBody(header, "")
89166}
90167
91168// -----------------------------------------------------------------------------
92169// View renderer
93170// -----------------------------------------------------------------------------
94171
95−type viewRenderer struct{}
172+// viewRenderer handles file viewing with syntax highlighting and line numbers
173+type viewRenderer struct {
174+ baseRenderer
175+}
96176
97−func (viewRenderer) Render(v *toolCallCmp) string {
177+// Render displays file content with optional limit and offset parameters
178+func (vr viewRenderer) Render(v *toolCallCmp) string {
98179 var params tools.ViewParams
99− _ = json.Unmarshal([]byte(v.call.Input), &params)
180+ if err := vr.unmarshalParams(v.call.Input, &params); err != nil {
181+ return vr.renderError(v, "Invalid view parameters")
182+ }
100183
101184 file := removeWorkingDirPrefix(params.FilePath)
102− args := []string{file}
103− if params.Limit != 0 {
104− args = append(args, "limit", fmt.Sprintf("%d", params.Limit))
105− }
106− if params.Offset != 0 {
107− args = append(args, "offset", fmt.Sprintf("%d", params.Offset))
108− }
185+ args := newParamBuilder().
186+ addMain(file).
187+ addKeyValue("limit", formatNonZero(params.Limit)).
188+ addKeyValue("offset", formatNonZero(params.Offset)).
189+ build()
190+
191+ return vr.renderWithParams(v, "View", args, func() string {
192+ var meta tools.ViewResponseMetadata
193+ if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
194+ return renderPlainContent(v, v.result.Content)
195+ }
196+ return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
197+ })
198+}
109199
110− header := makeHeader("View", v.textWidth(), args...)
111− if res, done := earlyState(header, v); done {
112− return res
200+// formatNonZero returns string representation of non-zero integers, empty string for zero
201+func formatNonZero(value int) string {
202+ if value == 0 {
203+ return ""
113204 }
114−
115− var meta tools.ViewResponseMetadata
116− _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
117−
118− body := renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
119− return joinHeaderBody(header, body)
205+ return fmt.Sprintf("%d", value)
120206}
121207
122208// -----------------------------------------------------------------------------
123209// Edit renderer
124210// -----------------------------------------------------------------------------
125211
126−type editRenderer struct{}
212+// editRenderer handles file editing with diff visualization
213+type editRenderer struct {
214+ baseRenderer
215+}
127216
128−func (editRenderer) Render(v *toolCallCmp) string {
217+// Render displays the edited file with a formatted diff of changes
218+func (er editRenderer) Render(v *toolCallCmp) string {
129219 var params tools.EditParams
130− _ = json.Unmarshal([]byte(v.call.Input), &params)
220+ if err := er.unmarshalParams(v.call.Input, &params); err != nil {
221+ return er.renderError(v, "Invalid edit parameters")
222+ }
131223
132224 file := removeWorkingDirPrefix(params.FilePath)
133− header := makeHeader("Edit", v.textWidth(), file)
134− if res, done := earlyState(header, v); done {
135− return res
136− }
225+ args := newParamBuilder().addMain(file).build()
137226
138− var meta tools.EditResponseMetadata
139− _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
227+ return er.renderWithParams(v, "Edit", args, func() string {
228+ var meta tools.EditResponseMetadata
229+ if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
230+ return renderPlainContent(v, v.result.Content)
231+ }
140232
141− trunc := truncateHeight(meta.Diff, responseContextHeight)
142− diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
143− return joinHeaderBody(header, diffView)
233+ trunc := truncateHeight(meta.Diff, responseContextHeight)
234+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
235+ return diffView
236+ })
144237}
145238
146239// -----------------------------------------------------------------------------
147240// Write renderer
148241// -----------------------------------------------------------------------------
149242
150−type writeRenderer struct{}
243+// writeRenderer handles file writing with syntax-highlighted content preview
244+type writeRenderer struct {
245+ baseRenderer
246+}
151247
152−func (writeRenderer) Render(v *toolCallCmp) string {
248+// Render displays the file being written with syntax highlighting
249+func (wr writeRenderer) Render(v *toolCallCmp) string {
153250 var params tools.WriteParams
154− _ = json.Unmarshal([]byte(v.call.Input), &params)
251+ if err := wr.unmarshalParams(v.call.Input, &params); err != nil {
252+ return wr.renderError(v, "Invalid write parameters")
253+ }
155254
156255 file := removeWorkingDirPrefix(params.FilePath)
157− header := makeHeader("Write", v.textWidth(), file)
158− if res, done := earlyState(header, v); done {
159− return res
160− }
256+ args := newParamBuilder().addMain(file).build()
161257
162− body := renderCodeContent(v, file, params.Content, 0)
163− return joinHeaderBody(header, body)
258+ return wr.renderWithParams(v, "Write", args, func() string {
259+ return renderCodeContent(v, file, params.Content, 0)
260+ })
164261}
165262
166263// -----------------------------------------------------------------------------
167264// Fetch renderer
168265// -----------------------------------------------------------------------------
169266
170−type fetchRenderer struct{}
267+// fetchRenderer handles URL fetching with format-specific content display
268+type fetchRenderer struct {
269+ baseRenderer
270+}
171271
172−func (fetchRenderer) Render(v *toolCallCmp) string {
272+// Render displays the fetched URL with format and timeout parameters
273+func (fr fetchRenderer) Render(v *toolCallCmp) string {
173274 var params tools.FetchParams
174− _ = json.Unmarshal([]byte(v.call.Input), &params)
175−
176− args := []string{params.URL}
177− if params.Format != "" {
178− args = append(args, "format", params.Format)
179− }
180− if params.Timeout != 0 {
181− args = append(args, "timeout", (time.Duration(params.Timeout) * time.Second).String())
275+ if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
276+ return fr.renderError(v, "Invalid fetch parameters")
182277 }
183278
184− header := makeHeader("Fetch", v.textWidth(), args...)
185− if res, done := earlyState(header, v); done {
186− return res
187− }
279+ args := newParamBuilder().
280+ addMain(params.URL).
281+ addKeyValue("format", params.Format).
282+ addKeyValue("timeout", formatTimeout(params.Timeout)).
283+ build()
188284
189− file := "fetch.md"
190− switch params.Format {
285+ return fr.renderWithParams(v, "Fetch", args, func() string {
286+ file := fr.getFileExtension(params.Format)
287+ return renderCodeContent(v, file, v.result.Content, 0)
288+ })
289+}
290+
291+// getFileExtension returns appropriate file extension for syntax highlighting
292+func (fr fetchRenderer) getFileExtension(format string) string {
293+ switch format {
191294 case "text":
192− file = "fetch.txt"
295+ return "fetch.txt"
193296 case "html":
194− file = "fetch.html"
297+ return "fetch.html"
298+ default:
299+ return "fetch.md"
195300 }
301+}
196302
197− body := renderCodeContent(v, file, v.result.Content, 0)
198− return joinHeaderBody(header, body)
303+// formatTimeout converts timeout seconds to duration string
304+func formatTimeout(timeout int) string {
305+ if timeout == 0 {
306+ return ""
307+ }
308+ return (time.Duration(timeout) * time.Second).String()
199309}
200310
201311// -----------------------------------------------------------------------------
202312// Glob renderer
203313// -----------------------------------------------------------------------------
204314
205−type globRenderer struct{}
315+// globRenderer handles file pattern matching with path filtering
316+type globRenderer struct {
317+ baseRenderer
318+}
206319
207−func (globRenderer) Render(v *toolCallCmp) string {
320+// Render displays the glob pattern with optional path parameter
321+func (gr globRenderer) Render(v *toolCallCmp) string {
208322 var params tools.GlobParams
209− _ = json.Unmarshal([]byte(v.call.Input), &params)
210−
211− args := []string{params.Pattern}
212− if params.Path != "" {
213− args = append(args, "path", params.Path)
323+ if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
324+ return gr.renderError(v, "Invalid glob parameters")
214325 }
215326
216− header := makeHeader("Glob", v.textWidth(), args...)
217− if res, done := earlyState(header, v); done {
218− return res
219− }
327+ args := newParamBuilder().
328+ addMain(params.Pattern).
329+ addKeyValue("path", params.Path).
330+ build()
220331
221− body := renderPlainContent(v, v.result.Content)
222− return joinHeaderBody(header, body)
332+ return gr.renderWithParams(v, "Glob", args, func() string {
333+ return renderPlainContent(v, v.result.Content)
334+ })
223335}
224336
225337// -----------------------------------------------------------------------------
226338// Grep renderer
227339// -----------------------------------------------------------------------------
228340
229−type grepRenderer struct{}
341+// grepRenderer handles content searching with pattern matching options
342+type grepRenderer struct {
343+ baseRenderer
344+}
230345
231−func (grepRenderer) Render(v *toolCallCmp) string {
346+// Render displays the search pattern with path, include, and literal text options
347+func (gr grepRenderer) Render(v *toolCallCmp) string {
232348 var params tools.GrepParams
233− _ = json.Unmarshal([]byte(v.call.Input), &params)
234−
235− args := []string{params.Pattern}
236− if params.Path != "" {
237− args = append(args, "path", params.Path)
238− }
239− if params.Include != "" {
240− args = append(args, "include", params.Include)
241− }
242− if params.LiteralText {
243− args = append(args, "literal", "true")
349+ if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
350+ return gr.renderError(v, "Invalid grep parameters")
244351 }
245352
246− header := makeHeader("Grep", v.textWidth(), args...)
247− if res, done := earlyState(header, v); done {
248− return res
249− }
353+ args := newParamBuilder().
354+ addMain(params.Pattern).
355+ addKeyValue("path", params.Path).
356+ addKeyValue("include", params.Include).
357+ addFlag("literal", params.LiteralText).
358+ build()
250359
251− body := renderPlainContent(v, v.result.Content)
252− return joinHeaderBody(header, body)
360+ return gr.renderWithParams(v, "Grep", args, func() string {
361+ return renderPlainContent(v, v.result.Content)
362+ })
253363}
254364
255365// -----------------------------------------------------------------------------
256366// LS renderer
257367// -----------------------------------------------------------------------------
258368
259−type lsRenderer struct{}
369+// lsRenderer handles directory listing with default path handling
370+type lsRenderer struct {
371+ baseRenderer
372+}
260373
261−func (lsRenderer) Render(v *toolCallCmp) string {
374+// Render displays the directory path, defaulting to current directory
375+func (lr lsRenderer) Render(v *toolCallCmp) string {
262376 var params tools.LSParams
263− _ = json.Unmarshal([]byte(v.call.Input), &params)
377+ if err := lr.unmarshalParams(v.call.Input, &params); err != nil {
378+ return lr.renderError(v, "Invalid ls parameters")
379+ }
264380
265381 path := params.Path
266382 if path == "" {
267383 path = "."
268384 }
269385
270− header := makeHeader("List", v.textWidth(), path)
271− if res, done := earlyState(header, v); done {
272− return res
273− }
386+ args := newParamBuilder().addMain(path).build()
274387
275− body := renderPlainContent(v, v.result.Content)
276− return joinHeaderBody(header, body)
388+ return lr.renderWithParams(v, "List", args, func() string {
389+ return renderPlainContent(v, v.result.Content)
390+ })
277391}
278392
279393// -----------------------------------------------------------------------------
280394// Sourcegraph renderer
281395// -----------------------------------------------------------------------------
282396
283−type sourcegraphRenderer struct{}
397+// sourcegraphRenderer handles code search with count and context options
398+type sourcegraphRenderer struct {
399+ baseRenderer
400+}
284401
285−func (sourcegraphRenderer) Render(v *toolCallCmp) string {
402+// Render displays the search query with optional count and context window parameters
403+func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
286404 var params tools.SourcegraphParams
287− _ = json.Unmarshal([]byte(v.call.Input), &params)
288−
289− args := []string{params.Query}
290− if params.Count != 0 {
291− args = append(args, "count", fmt.Sprintf("%d", params.Count))
292− }
293− if params.ContextWindow != 0 {
294− args = append(args, "context", fmt.Sprintf("%d", params.ContextWindow))
405+ if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
406+ return sr.renderError(v, "Invalid sourcegraph parameters")
295407 }
296408
297− header := makeHeader("Sourcegraph", v.textWidth(), args...)
298− if res, done := earlyState(header, v); done {
299− return res
300− }
409+ args := newParamBuilder().
410+ addMain(params.Query).
411+ addKeyValue("count", formatNonZero(params.Count)).
412+ addKeyValue("context", formatNonZero(params.ContextWindow)).
413+ build()
301414
302− body := renderPlainContent(v, v.result.Content)
303− return joinHeaderBody(header, body)
415+ return sr.renderWithParams(v, "Sourcegraph", args, func() string {
416+ return renderPlainContent(v, v.result.Content)
417+ })
304418}
305419
306420// -----------------------------------------------------------------------------
307421// Patch renderer
308422// -----------------------------------------------------------------------------
309423
310−type patchRenderer struct{}
424+// patchRenderer handles multi-file patches with change summaries
425+type patchRenderer struct {
426+ baseRenderer
427+}
311428
312−func (patchRenderer) Render(v *toolCallCmp) string {
429+// Render displays patch summary with file count and change statistics
430+func (pr patchRenderer) Render(v *toolCallCmp) string {
313431 var params tools.PatchParams
314− _ = json.Unmarshal([]byte(v.call.Input), &params)
315−
316− header := makeHeader("Patch", v.textWidth(), "multiple files")
317− if res, done := earlyState(header, v); done {
318− return res
432+ if err := pr.unmarshalParams(v.call.Input, &params); err != nil {
433+ return pr.renderError(v, "Invalid patch parameters")
319434 }
320435
321− var meta tools.PatchResponseMetadata
322− _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
436+ args := newParamBuilder().addMain("multiple files").build()
323437
324− // Format the result as a summary of changes
325− summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
326− len(meta.FilesChanged), meta.Additions, meta.Removals)
438+ return pr.renderWithParams(v, "Patch", args, func() string {
439+ var meta tools.PatchResponseMetadata
440+ if err := pr.unmarshalParams(v.result.Metadata, &meta); err != nil {
441+ return renderPlainContent(v, v.result.Content)
442+ }
327443
328− // List the changed files
329− filesList := strings.Join(meta.FilesChanged, "\n")
444+ summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
445+ len(meta.FilesChanged), meta.Additions, meta.Removals)
446+ filesList := strings.Join(meta.FilesChanged, "\n")
330447
331− body := renderPlainContent(v, summary+"\n\n"+filesList)
332− return joinHeaderBody(header, body)
448+ return renderPlainContent(v, summary+"\n\n"+filesList)
449+ })
333450}
334451
335452// -----------------------------------------------------------------------------
336453// Diagnostics renderer
337454// -----------------------------------------------------------------------------
338455
339−type diagnosticsRenderer struct{}
456+// diagnosticsRenderer handles project-wide diagnostic information
457+type diagnosticsRenderer struct {
458+ baseRenderer
459+}
340460
341−func (diagnosticsRenderer) Render(v *toolCallCmp) string {
342− header := makeHeader("Diagnostics", v.textWidth(), "project")
343− if res, done := earlyState(header, v); done {
344− return res
345− }
461+// Render displays project diagnostics with plain content formatting
462+func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
463+ args := newParamBuilder().addMain("project").build()
346464
347− body := renderPlainContent(v, v.result.Content)
348− return joinHeaderBody(header, body)
465+ return dr.renderWithParams(v, "Diagnostics", args, func() string {
466+ return renderPlainContent(v, v.result.Content)
467+ })
349468}
350469
351470// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
352471func makeHeader(tool string, width int, params ...string) string {
353472 prefix := tool + ": "
354− return prefix + renderParams(width-lipgloss.Width(prefix), params...)
473+ return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
355474}
356475
357−// renders params, params[0] (params[1]=params[2] ....)
358−func renderParams(paramsWidth int, params ...string) string {
476+// renderParamList renders params, params[0] (params[1]=params[2] ....)
477+func renderParamList(paramsWidth int, params ...string) string {
359478 if len(params) == 0 {
360479 return ""
361480 }
internal/tui/components/chat/messages/tool.go+65−27
@@ -15,46 +15,57 @@ import (
1515 "github.com/opencode-ai/opencode/internal/tui/util"
1616)
1717
18+// ToolCallCmp defines the interface for tool call components in the chat interface.
19+// It manages the display of tool execution including pending states, results, and errors.
1820type ToolCallCmp interface {
19− util.Model
20− layout.Sizeable
21− layout.Focusable
22− GetToolCall() message.ToolCall
23− GetToolResult() message.ToolResult
24− SetToolResult(message.ToolResult)
25− SetToolCall(message.ToolCall)
26− SetCancelled()
27− ParentMessageId() string
28− Spinning() bool
29−}
30−
21+ util.Model // Basic Bubble Tea model interface
22+ layout.Sizeable // Width/height management
23+ layout.Focusable // Focus state management
24+ GetToolCall() message.ToolCall // Access to tool call data
25+ GetToolResult() message.ToolResult // Access to tool result data
26+ SetToolResult(message.ToolResult) // Update tool result
27+ SetToolCall(message.ToolCall) // Update tool call
28+ SetCancelled() // Mark as cancelled
29+ ParentMessageId() string // Get parent message ID
30+ Spinning() bool // Animation state for pending tools
31+}
32+
33+// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
34+// It handles rendering of tool execution states including pending, completed, and error states.
3135type toolCallCmp struct {
32− width int
33− focused bool
36+ width int // Component width for text wrapping
37+ focused bool // Focus state for border styling
3438
35− parentMessageId string
36− call message.ToolCall
37− result message.ToolResult
38− cancelled bool
39+ // Tool call data and state
40+ parentMessageId string // ID of the message that initiated this tool call
41+ call message.ToolCall // The tool call being executed
42+ result message.ToolResult // The result of the tool execution
43+ cancelled bool // Whether the tool call was cancelled
3944
40− spinning bool
41− anim util.Model
45+ // Animation state for pending tool calls
46+ spinning bool // Whether to show loading animation
47+ anim util.Model // Animation component for pending states
4248}
4349
50+// ToolCallOption provides functional options for configuring tool call components
4451type ToolCallOption func(*toolCallCmp)
4552
53+// WithToolCallCancelled marks the tool call as cancelled
4654func WithToolCallCancelled() ToolCallOption {
4755 return func(m *toolCallCmp) {
4856 m.cancelled = true
4957 }
5058}
5159
60+// WithToolCallResult sets the initial tool result
5261func WithToolCallResult(result message.ToolResult) ToolCallOption {
5362 return func(m *toolCallCmp) {
5463 m.result = result
5564 }
5665}
5766
67+// NewToolCallCmp creates a new tool call component with the given parent message ID,
68+// tool call, and optional configuration
5869func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
5970 m := &toolCallCmp{
6071 call: tc,
@@ -67,6 +78,8 @@ func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCal
6778 return m
6879}
6980
81+// Init initializes the tool call component and starts animations if needed.
82+// Returns a command to start the animation for pending tool calls.
7083func (m *toolCallCmp) Init() tea.Cmd {
7184 m.spinning = m.shouldSpin()
7285 logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
@@ -76,6 +89,8 @@ func (m *toolCallCmp) Init() tea.Cmd {
7689 return nil
7790}
7891
92+// Update handles incoming messages and updates the component state.
93+// Manages animation updates for pending tool calls.
7994func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8095 logging.Debug("Tool call update", "msg", msg)
8196 switch msg := msg.(type) {
@@ -89,6 +104,8 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89104 return m, nil
90105}
91106
107+// View renders the tool call component based on its current state.
108+// Shows either a pending animation or the tool-specific rendered result.
92109func (m *toolCallCmp) View() string {
93110 box := m.style()
94111
@@ -100,12 +117,14 @@ func (m *toolCallCmp) View() string {
100117 return box.PaddingLeft(1).Render(r.Render(m))
101118}
102119
103−// SetCancelled implements ToolCallCmp.
120+// State management methods
121+
122+// SetCancelled marks the tool call as cancelled
104123func (m *toolCallCmp) SetCancelled() {
105124 m.cancelled = true
106125}
107126
108−// SetToolCall implements ToolCallCmp.
127+// SetToolCall updates the tool call data and stops spinning if finished
109128func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
110129 m.call = call
111130 if m.call.Finished {
@@ -113,31 +132,36 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
113132 }
114133}
115134
116−// ParentMessageId implements ToolCallCmp.
135+// ParentMessageId returns the ID of the message that initiated this tool call
117136func (m *toolCallCmp) ParentMessageId() string {
118137 return m.parentMessageId
119138}
120139
121−// SetToolResult implements ToolCallCmp.
140+// SetToolResult updates the tool result and stops the spinning animation
122141func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
123142 m.result = result
124143 m.spinning = false
125144}
126145
127−// GetToolCall implements ToolCallCmp.
146+// GetToolCall returns the current tool call data
128147func (m *toolCallCmp) GetToolCall() message.ToolCall {
129148 return m.call
130149}
131150
132−// GetToolResult implements ToolCallCmp.
151+// GetToolResult returns the current tool result data
133152func (m *toolCallCmp) GetToolResult() message.ToolResult {
134153 return m.result
135154}
136155
156+// Rendering methods
157+
158+// renderPending displays the tool name with a loading animation for pending tool calls
137159func (m *toolCallCmp) renderPending() string {
138160 return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
139161}
140162
163+// style returns the lipgloss style for the tool call component.
164+// Applies muted colors and focus-dependent border styles.
141165func (m *toolCallCmp) style() lipgloss.Style {
142166 t := theme.CurrentTheme()
143167 borderStyle := lipgloss.NormalBorder()
@@ -151,10 +175,13 @@ func (m *toolCallCmp) style() lipgloss.Style {
151175 BorderStyle(borderStyle)
152176}
153177
178+// textWidth calculates the available width for text content,
179+// accounting for borders and padding
154180func (m *toolCallCmp) textWidth() int {
155181 return m.width - 2 // take into account the border and PaddingLeft
156182}
157183
184+// fit truncates content to fit within the specified width with ellipsis
158185func (m *toolCallCmp) fit(content string, width int) string {
159186 t := theme.CurrentTheme()
160187 lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
@@ -162,30 +189,40 @@ func (m *toolCallCmp) fit(content string, width int) string {
162189 return ansi.Truncate(content, width, dots)
163190}
164191
192+// Focus management methods
193+
194+// Blur removes focus from the tool call component
165195func (m *toolCallCmp) Blur() tea.Cmd {
166196 m.focused = false
167197 return nil
168198}
169199
200+// Focus sets focus on the tool call component
170201func (m *toolCallCmp) Focus() tea.Cmd {
171202 m.focused = true
172203 return nil
173204}
174205
175−// IsFocused implements MessageModel.
206+// IsFocused returns whether the tool call component is currently focused
176207func (m *toolCallCmp) IsFocused() bool {
177208 return m.focused
178209}
179210
211+// Size management methods
212+
213+// GetSize returns the current dimensions of the tool call component
180214func (m *toolCallCmp) GetSize() (int, int) {
181215 return m.width, 0
182216}
183217
218+// SetSize updates the width of the tool call component for text wrapping
184219func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
185220 m.width = width
186221 return nil
187222}
188223
224+// shouldSpin determines whether the tool call should show a loading animation.
225+// Returns true if the tool call is not finished or if the result doesn't match the call ID.
189226func (m *toolCallCmp) shouldSpin() bool {
190227 if !m.call.Finished {
191228 return true
@@ -195,6 +232,7 @@ func (m *toolCallCmp) shouldSpin() bool {
195232 return false
196233}
197234
235+// Spinning returns whether the tool call is currently showing a loading animation
198236func (m *toolCallCmp) Spinning() bool {
199237 return m.spinning
200238}
internal/tui/components/core/list/keys.go+1−1
@@ -16,7 +16,7 @@ type KeyMap struct {
1616 Submit key.Binding
1717}
1818
19−func defaultKeymap() KeyMap {
19+func DefaultKeymap() KeyMap {
2020 return KeyMap{
2121 Down: key.NewBinding(
2222 key.WithKeys("down", "ctrl+j", "ctrl+n"),
internal/tui/components/core/list/list.go+668−452
@@ -3,7 +3,6 @@ package list
33import (
44 "slices"
55 "strings"
6− "sync"
76
87 "github.com/charmbracelet/bubbles/v2/help"
98 "github.com/charmbracelet/bubbles/v2/key"
@@ -14,89 +13,155 @@ import (
1413 "github.com/opencode-ai/opencode/internal/tui/util"
1514)
1615
16+// Constants for special index values and defaults
17+const (
18+ NoSelection = -1 // Indicates no item is currently selected
19+ NotRendered = -1 // Indicates an item hasn't been rendered yet
20+ NoFinalHeight = -1 // Indicates final height hasn't been calculated
21+ DefaultGapSize = 0 // Default spacing between list items
22+)
23+
24+// ListModel defines the interface for a scrollable, selectable list component.
25+// It combines the basic Model interface with sizing capabilities and list-specific operations.
1726type ListModel interface {
1827 util.Model
1928 layout.Sizeable
20− SetItems([]util.Model) tea.Cmd
21− AppendItem(util.Model) tea.Cmd
22− PrependItem(util.Model) tea.Cmd
23− DeleteItem(int)
24− UpdateItem(int, util.Model)
25− ResetView()
26− Items() []util.Model
29+ SetItems([]util.Model) tea.Cmd // Replace all items in the list
30+ AppendItem(util.Model) tea.Cmd // Add an item to the end of the list
31+ PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
32+ DeleteItem(int) // Remove an item at the specified index
33+ UpdateItem(int, util.Model) // Replace an item at the specified index
34+ ResetView() // Clear rendering cache and reset scroll position
35+ Items() []util.Model // Get all items in the list
2736}
2837
38+// HasAnim interface identifies items that support animation.
39+// Items implementing this interface will receive animation update messages.
2940type HasAnim interface {
3041 util.Model
31− Spinning() bool
42+ Spinning() bool // Returns true if the item is currently animating
3243}
3344
45+// renderedItem represents a cached rendered item with its position and content.
3446type renderedItem struct {
35− lines []string
36− start int
37− height int
47+ lines []string // The rendered lines of text for this item
48+ start int // Starting line position in the overall rendered content
49+ height int // Number of lines this item occupies
50+}
51+
52+// renderState manages the rendering cache and state for the list.
53+// It tracks which items have been rendered and their positions.
54+type renderState struct {
55+ items map[int]renderedItem // Cache of rendered items by index
56+ lines []string // All rendered lines concatenated
57+ lastIndex int // Index of the last rendered item
58+ finalHeight int // Total height when all items are rendered
59+ needsRerender bool // Flag indicating if re-rendering is needed
60+}
61+
62+// newRenderState creates a new render state with default values.
63+func newRenderState() *renderState {
64+ return &renderState{
65+ items: make(map[int]renderedItem),
66+ lines: []string{},
67+ lastIndex: NotRendered,
68+ finalHeight: NoFinalHeight,
69+ needsRerender: true,
70+ }
71+}
72+
73+// reset clears all cached rendering data and resets state to initial values.
74+func (rs *renderState) reset() {
75+ rs.items = make(map[int]renderedItem)
76+ rs.lines = []string{}
77+ rs.lastIndex = NotRendered
78+ rs.finalHeight = NoFinalHeight
79+ rs.needsRerender = true
80+}
81+
82+// viewState manages the visual display properties of the list.
83+type viewState struct {
84+ width, height int // Dimensions of the list viewport
85+ offset int // Current scroll offset in lines
86+ reverse bool // Whether to render in reverse order (bottom-up)
87+ content string // The final rendered content to display
88+}
89+
90+// selectionState manages which item is currently selected.
91+type selectionState struct {
92+ selectedIndex int // Index of the currently selected item, or NoSelection
93+}
94+
95+// isValidIndex checks if the selected index is within the valid range of items.
96+func (ss *selectionState) isValidIndex(itemCount int) bool {
97+ return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
3898}
99+
100+// model is the main implementation of the ListModel interface.
101+// It coordinates between view state, render state, and selection state.
39102type model struct {
40− width, height, offset int
41− finalHeight int // this gets set when the last item is rendered to mark the max offset
42− reverse bool
43− help help.Model
44− keymap KeyMap
45− items []util.Model
46− renderedItems *sync.Map // item index to rendered string
47− needsRerender bool
48− renderedLines []string
49− selectedItemInx int
50− lastRenderedInx int
51− content string
52− gapSize int
53− padding []int
103+ viewState viewState // Display and scrolling state
104+ renderState *renderState // Rendering cache and state
105+ selectionState selectionState // Item selection state
106+ help help.Model // Help system for keyboard shortcuts
107+ keymap KeyMap // Key bindings for navigation
108+ items []util.Model // The actual list items
109+ gapSize int // Number of empty lines between items
110+ padding []int // Padding around the list content
54111}
55112
113+// listOptions is a function type for configuring list options.
56114type listOptions func(*model)
57115
116+// WithKeyMap sets custom key bindings for the list.
58117func WithKeyMap(k KeyMap) listOptions {
59118 return func(m *model) {
60119 m.keymap = k
61120 }
62121}
63122
123+// WithReverse sets whether the list should render in reverse order (newest items at bottom).
64124func WithReverse(reverse bool) listOptions {
65125 return func(m *model) {
66126 m.setReverse(reverse)
67127 }
68128}
69129
130+// WithGapSize sets the number of empty lines to insert between list items.
70131func WithGapSize(gapSize int) listOptions {
71132 return func(m *model) {
72133 m.gapSize = gapSize
73134 }
74135}
75136
137+// WithPadding sets the padding around the list content.
138+// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
139+// 4 values = top/right/bottom/left.
76140func WithPadding(padding ...int) listOptions {
77141 return func(m *model) {
78142 m.padding = padding
79143 }
80144}
81145
146+// WithItems sets the initial items for the list.
82147func WithItems(items []util.Model) listOptions {
83148 return func(m *model) {
84149 m.items = items
85150 }
86151}
87152
153+// New creates a new list model with the specified options.
154+// The list starts with no items selected and requires SetItems to be called
155+// or items to be provided via WithItems option.
88156func New(opts ...listOptions) ListModel {
89157 m := &model{
90− help: help.New(),
91− keymap: defaultKeymap(),
92− items: []util.Model{},
93− needsRerender: true,
94− gapSize: 0,
95− padding: []int{},
96− selectedItemInx: -1,
97− finalHeight: -1,
98− lastRenderedInx: -1,
99− renderedItems: new(sync.Map),
158+ help: help.New(),
159+ keymap: DefaultKeymap(),
160+ items: []util.Model{},
161+ renderState: newRenderState(),
162+ gapSize: DefaultGapSize,
163+ padding: []int{},
164+ selectionState: selectionState{selectedIndex: NoSelection},
100165 }
101166 for _, opt := range opts {
102167 opt(m)
@@ -104,591 +169,737 @@ func New(opts ...listOptions) ListModel {
104169 return m
105170}
106171
107−// Init implements List.
172+// Init initializes the list component and sets up the initial items.
173+// This is called automatically by the Bubble Tea framework.
108174func (m *model) Init() tea.Cmd {
109− cmds := []tea.Cmd{
110− m.SetItems(m.items),
111− }
112− return tea.Batch(cmds...)
175+ return m.SetItems(m.items)
113176}
114177
115−// Update implements List.
178+// Update handles incoming messages and updates the list state accordingly.
179+// It processes keyboard input, animation messages, and forwards other messages
180+// to the currently selected item.
116181func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117− var cmds []tea.Cmd
118182 switch msg := msg.(type) {
119183 case tea.KeyPressMsg:
120− switch {
121− case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
122− if m.reverse {
123− m.decreaseOffset(1)
124− } else {
125− m.increaseOffset(1)
126− }
127− return m, nil
128− case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
129− if m.reverse {
130− m.increaseOffset(1)
131− } else {
132− m.decreaseOffset(1)
133− }
134− return m, nil
135− case key.Matches(msg, m.keymap.DownOneItem):
136− m.downOneItem()
137− return m, nil
138− case key.Matches(msg, m.keymap.UpOneItem):
139− m.upOneItem()
140− return m, nil
141− case key.Matches(msg, m.keymap.HalfPageDown):
142− if m.reverse {
143− m.decreaseOffset(m.listHeight() / 2)
144− } else {
145− m.increaseOffset(m.listHeight() / 2)
146− }
147− return m, nil
148− case key.Matches(msg, m.keymap.HalfPageUp):
149− if m.reverse {
150− m.increaseOffset(m.listHeight() / 2)
151− } else {
152− m.decreaseOffset(m.listHeight() / 2)
153− }
154− return m, nil
155− case key.Matches(msg, m.keymap.Home):
156− m.goToTop()
157− return m, nil
158− case key.Matches(msg, m.keymap.End):
159− m.goToBottom()
160− return m, nil
161− }
184+ return m.handleKeyPress(msg)
162185 case anim.ColorCycleMsg, anim.StepCharsMsg:
163− for inx, item := range m.items {
164− if i, ok := item.(HasAnim); ok {
165− if i.Spinning() {
166− updated, cmd := i.Update(msg)
167− cmds = append(cmds, cmd)
168− m.UpdateItem(inx, updated.(util.Model))
169− }
186+ return m.handleAnimationMsg(msg)
187+ }
188+
189+ if m.selectionState.isValidIndex(len(m.items)) {
190+ return m.updateSelectedItem(msg)
191+ }
192+
193+ return m, nil
194+}
195+
196+// handleKeyPress processes keyboard input for list navigation.
197+// Supports scrolling, item selection, and navigation to top/bottom.
198+func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
199+ switch {
200+ case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
201+ m.scrollDown(1)
202+ case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
203+ m.scrollUp(1)
204+ case key.Matches(msg, m.keymap.DownOneItem):
205+ return m, m.selectNextItem()
206+ case key.Matches(msg, m.keymap.UpOneItem):
207+ return m, m.selectPreviousItem()
208+ case key.Matches(msg, m.keymap.HalfPageDown):
209+ m.scrollDown(m.listHeight() / 2)
210+ case key.Matches(msg, m.keymap.HalfPageUp):
211+ m.scrollUp(m.listHeight() / 2)
212+ case key.Matches(msg, m.keymap.Home):
213+ return m, m.goToTop()
214+ case key.Matches(msg, m.keymap.End):
215+ return m, m.goToBottom()
216+ }
217+ return m, nil
218+}
219+
220+// handleAnimationMsg forwards animation messages to items that support animation.
221+// Only items implementing HasAnim and currently spinning receive these messages.
222+func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
223+ var cmds []tea.Cmd
224+ for inx, item := range m.items {
225+ if i, ok := item.(HasAnim); ok && i.Spinning() {
226+ updated, cmd := i.Update(msg)
227+ cmds = append(cmds, cmd)
228+ if u, ok := updated.(util.Model); ok {
229+ m.UpdateItem(inx, u)
170230 }
171231 }
172− return m, tea.Batch(cmds...)
173232 }
174− if m.selectedItemInx > -1 {
175− u, cmd := m.items[m.selectedItemInx].Update(msg)
176− cmds = append(cmds, cmd)
177− m.UpdateItem(m.selectedItemInx, u.(util.Model))
178− return m, tea.Batch(cmds...)
233+ return m, tea.Batch(cmds...)
234+}
235+
236+// updateSelectedItem forwards messages to the currently selected item.
237+// This allows the selected item to handle its own input and state changes.
238+func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
239+ var cmds []tea.Cmd
240+ u, cmd := m.items[m.selectionState.selectedIndex].Update(msg)
241+ cmds = append(cmds, cmd)
242+ if updated, ok := u.(util.Model); ok {
243+ m.UpdateItem(m.selectionState.selectedIndex, updated)
244+ }
245+ return m, tea.Batch(cmds...)
246+}
247+
248+// scrollDown scrolls the list down by the specified amount.
249+// Direction is automatically adjusted based on reverse mode.
250+func (m *model) scrollDown(amount int) {
251+ if m.viewState.reverse {
252+ m.decreaseOffset(amount)
253+ } else {
254+ m.increaseOffset(amount)
179255 }
256+}
180257
181− return m, nil
258+// scrollUp scrolls the list up by the specified amount.
259+// Direction is automatically adjusted based on reverse mode.
260+func (m *model) scrollUp(amount int) {
261+ if m.viewState.reverse {
262+ m.increaseOffset(amount)
263+ } else {
264+ m.decreaseOffset(amount)
265+ }
182266}
183267
184−// View implements List.
268+// View renders the list to a string for display.
269+// Returns empty string if the list has no dimensions.
270+// Triggers re-rendering if needed before returning content.
185271func (m *model) View() string {
186− if m.height == 0 || m.width == 0 {
272+ if m.viewState.height == 0 || m.viewState.width == 0 {
187273 return ""
188274 }
189− if m.needsRerender {
275+ if m.renderState.needsRerender {
190276 m.renderVisible()
191277 }
192− return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
278+ return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content)
193279}
194280
195−// Items implements ListModel.
281+// Items returns a copy of all items in the list.
196282func (m *model) Items() []util.Model {
197283 return m.items
198284}
199285
200−func (m *model) renderVisibleReverse() {
201− start := 0
202− cutoff := m.offset + m.listHeight()
203− items := m.items
204− if m.lastRenderedInx > -1 {
205− items = m.items[:m.lastRenderedInx]
206− start = len(m.renderedLines)
286+// renderVisible determines which rendering strategy to use and triggers rendering.
287+// Uses forward rendering for normal mode and reverse rendering for reverse mode.
288+func (m *model) renderVisible() {
289+ if m.viewState.reverse {
290+ m.renderVisibleReverse()
207291 } else {
208− // reveresed so that it starts at the end
209− m.lastRenderedInx = len(m.items)
210− }
211− realIndex := m.lastRenderedInx
212− for i := len(items) - 1; i >= 0; i-- {
213− realIndex--
214− var itemLines []string
215− cachedContent, ok := m.renderedItems.Load(realIndex)
216− if ok {
217− itemLines = cachedContent.(renderedItem).lines
218− } else {
219− itemLines = strings.Split(items[i].View(), "\n")
220− if m.gapSize > 0 {
221− for range m.gapSize {
222− itemLines = append(itemLines, "")
223− }
224− }
225− m.renderedItems.Store(realIndex, renderedItem{
226− lines: itemLines,
227− start: start,
228− height: len(itemLines),
229− })
230− }
292+ m.renderVisibleForward()
293+ }
294+}
231295
232− if realIndex == 0 {
233− m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
234− }
235− m.renderedLines = append(itemLines, m.renderedLines...)
236− m.lastRenderedInx = realIndex
237− // always render the next item
238− if start > cutoff {
239− break
240− }
241− start += len(itemLines)
296+// renderVisibleForward renders items from top to bottom (normal mode).
297+// Only renders items that are currently visible or near the viewport.
298+func (m *model) renderVisibleForward() {
299+ renderer := &forwardRenderer{
300+ model: m,
301+ start: 0,
302+ cutoff: m.viewState.offset + m.listHeight(),
303+ items: m.items,
304+ realIdx: m.renderState.lastIndex,
242305 }
243− m.needsRerender = false
244− if m.finalHeight > -1 {
245− // 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
246− m.offset = min(m.offset, m.finalHeight)
306+
307+ if m.renderState.lastIndex > NotRendered {
308+ renderer.items = m.items[m.renderState.lastIndex+1:]
309+ renderer.start = len(m.renderState.lines)
247310 }
248− maxHeight := min(m.listHeight(), len(m.renderedLines))
249− if m.offset < len(m.renderedLines) {
250− end := len(m.renderedLines) - m.offset
251− start := max(0, end-maxHeight)
252− m.content = strings.Join(m.renderedLines[start:end], "\n")
311+
312+ renderer.render()
313+ m.finalizeRender()
314+}
315+
316+// renderVisibleReverse renders items from bottom to top (reverse mode).
317+// Used when new items should appear at the bottom (like chat messages).
318+func (m *model) renderVisibleReverse() {
319+ renderer := &reverseRenderer{
320+ model: m,
321+ start: 0,
322+ cutoff: m.viewState.offset + m.listHeight(),
323+ items: m.items,
324+ realIdx: m.renderState.lastIndex,
325+ }
326+
327+ if m.renderState.lastIndex > NotRendered {
328+ renderer.items = m.items[:m.renderState.lastIndex]
329+ renderer.start = len(m.renderState.lines)
253330 } else {
254− m.content = ""
331+ m.renderState.lastIndex = len(m.items)
332+ renderer.realIdx = len(m.items)
255333 }
334+
335+ renderer.render()
336+ m.finalizeRender()
256337}
257338
258−func (m *model) renderVisible() {
259− if m.reverse {
260− m.renderVisibleReverse()
339+// finalizeRender completes the rendering process by updating scroll bounds and content.
340+func (m *model) finalizeRender() {
341+ m.renderState.needsRerender = false
342+ if m.renderState.finalHeight > NoFinalHeight {
343+ m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
344+ }
345+ m.updateContent()
346+}
347+
348+// updateContent extracts the visible portion of rendered content for display.
349+// Handles both normal and reverse rendering modes.
350+func (m *model) updateContent() {
351+ maxHeight := min(m.listHeight(), len(m.renderState.lines))
352+ if m.viewState.offset >= len(m.renderState.lines) {
353+ m.viewState.content = ""
261354 return
262355 }
263− start := 0
264− cutoff := m.offset + m.listHeight()
265− items := m.items
266− if m.lastRenderedInx > -1 {
267− items = m.items[m.lastRenderedInx+1:]
268− start = len(m.renderedLines)
356+
357+ if m.viewState.reverse {
358+ end := len(m.renderState.lines) - m.viewState.offset
359+ start := max(0, end-maxHeight)
360+ m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
361+ } else {
362+ endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
363+ m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
269364 }
365+}
270366
271− realIndex := m.lastRenderedInx
272− for _, item := range items {
273− realIndex++
367+// forwardRenderer handles rendering items from top to bottom.
368+// It builds up the rendered content incrementally, caching results for performance.
369+type forwardRenderer struct {
370+ model *model // Reference to the parent list model
371+ start int // Current line position in the overall content
372+ cutoff int // Line position where we can stop rendering
373+ items []util.Model // Items to render (may be a subset)
374+ realIdx int // Real index in the full item list
375+}
274376
275− var itemLines []string
276− cachedContent, ok := m.renderedItems.Load(realIndex)
277− if ok {
278− itemLines = cachedContent.(renderedItem).lines
279− } else {
280− itemLines = strings.Split(item.View(), "\n")
281− if m.gapSize > 0 {
282− for range m.gapSize {
283− itemLines = append(itemLines, "")
284− }
285− }
286− m.renderedItems.Store(realIndex, renderedItem{
287− lines: itemLines,
288− start: start,
289− height: len(itemLines),
290− })
377+// render processes items in forward order, building up the rendered content.
378+func (r *forwardRenderer) render() {
379+ for _, item := range r.items {
380+ r.realIdx++
381+ if r.start > r.cutoff {
382+ break
383+ }
384+
385+ itemLines := r.getOrRenderItem(item)
386+ if r.realIdx == len(r.model.items)-1 {
387+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
291388 }
292− // always render the next item
293− if start > cutoff {
389+
390+ r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
391+ r.model.renderState.lastIndex = r.realIdx
392+ r.start += len(itemLines)
393+ }
394+}
395+
396+// getOrRenderItem retrieves cached content or renders the item if not cached.
397+func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
398+ if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
399+ return cachedContent.lines
400+ }
401+
402+ itemLines := r.renderItemLines(item)
403+ r.model.renderState.items[r.realIdx] = renderedItem{
404+ lines: itemLines,
405+ start: r.start,
406+ height: len(itemLines),
407+ }
408+ return itemLines
409+}
410+
411+// renderItemLines converts an item to its string representation with gaps.
412+func (r *forwardRenderer) renderItemLines(item util.Model) []string {
413+ return r.model.getItemLines(item)
414+}
415+
416+// reverseRenderer handles rendering items from bottom to top.
417+// Used in reverse mode where new items appear at the bottom.
418+type reverseRenderer struct {
419+ model *model // Reference to the parent list model
420+ start int // Current line position in the overall content
421+ cutoff int // Line position where we can stop rendering
422+ items []util.Model // Items to render (may be a subset)
423+ realIdx int // Real index in the full item list
424+}
425+
426+// render processes items in reverse order, prepending to the rendered content.
427+func (r *reverseRenderer) render() {
428+ for i := len(r.items) - 1; i >= 0; i-- {
429+ r.realIdx--
430+ if r.start > r.cutoff {
294431 break
295432 }
296433
297− if realIndex == len(m.items)-1 {
298− m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
434+ itemLines := r.getOrRenderItem(r.items[i])
435+ if r.realIdx == 0 {
436+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
299437 }
300438
301− m.renderedLines = append(m.renderedLines, itemLines...)
302− m.lastRenderedInx = realIndex
303− start += len(itemLines)
439+ r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
440+ r.model.renderState.lastIndex = r.realIdx
441+ r.start += len(itemLines)
442+ }
443+}
444+
445+// getOrRenderItem retrieves cached content or renders the item if not cached.
446+func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
447+ if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
448+ return cachedContent.lines
449+ }
450+
451+ itemLines := r.renderItemLines(item)
452+ r.model.renderState.items[r.realIdx] = renderedItem{
453+ lines: itemLines,
454+ start: r.start,
455+ height: len(itemLines),
456+ }
457+ return itemLines
458+}
459+
460+// renderItemLines converts an item to its string representation with gaps.
461+func (r *reverseRenderer) renderItemLines(item util.Model) []string {
462+ return r.model.getItemLines(item)
463+}
464+
465+// selectPreviousItem moves selection to the previous item in the list.
466+// Handles focus management and ensures the selected item remains visible.
467+func (m *model) selectPreviousItem() tea.Cmd {
468+ if m.selectionState.selectedIndex <= 0 {
469+ return nil
470+ }
471+
472+ cmds := []tea.Cmd{m.blurSelected()}
473+ m.selectionState.selectedIndex--
474+ cmds = append(cmds, m.focusSelected())
475+ m.ensureSelectedItemVisible()
476+ return tea.Batch(cmds...)
477+}
478+
479+// selectNextItem moves selection to the next item in the list.
480+// Handles focus management and ensures the selected item remains visible.
481+func (m *model) selectNextItem() tea.Cmd {
482+ if m.selectionState.selectedIndex >= len(m.items)-1 || m.selectionState.selectedIndex < 0 {
483+ return nil
304484 }
305− m.needsRerender = false
306− maxHeight := min(m.listHeight(), len(m.renderedLines))
307− if m.finalHeight > -1 {
308− // 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
309− m.offset = min(m.offset, m.finalHeight)
485+
486+ cmds := []tea.Cmd{m.blurSelected()}
487+ m.selectionState.selectedIndex++
488+ cmds = append(cmds, m.focusSelected())
489+ m.ensureSelectedItemVisible()
490+ return tea.Batch(cmds...)
491+}
492+
493+// ensureSelectedItemVisible scrolls the list to make the selected item visible.
494+// Uses different strategies for forward and reverse rendering modes.
495+func (m *model) ensureSelectedItemVisible() {
496+ cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
497+ if !ok {
498+ m.renderState.needsRerender = true
499+ return
310500 }
311− if m.offset < len(m.renderedLines) {
312− m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
501+
502+ if m.viewState.reverse {
503+ m.ensureVisibleReverse(cachedItem)
313504 } else {
314− m.content = ""
505+ m.ensureVisibleForward(cachedItem)
315506 }
507+ m.renderState.needsRerender = true
316508}
317509
318−func (m *model) upOneItem() tea.Cmd {
319− var cmds []tea.Cmd
320− if m.selectedItemInx > 0 {
321− cmd := m.blurSelected()
322− cmds = append(cmds, cmd)
323− m.selectedItemInx--
324− cmd = m.focusSelected()
325− cmds = append(cmds, cmd)
326− }
327−
328− cached, ok := m.renderedItems.Load(m.selectedItemInx)
329− if ok {
330− // already rendered
331− if !m.reverse {
332− cachedItem, _ := cached.(renderedItem)
333− // might not fit on the screen move the offset to the start of the item
334− if cachedItem.height >= m.listHeight() {
335− changeNeeded := m.offset - cachedItem.start
336− m.decreaseOffset(changeNeeded)
337− }
338− if cachedItem.start < m.offset {
339− changeNeeded := m.offset - cachedItem.start
340− m.decreaseOffset(changeNeeded)
341− }
510+// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
511+// Handles both large items (taller than viewport) and normal items.
512+func (m *model) ensureVisibleForward(cachedItem renderedItem) {
513+ if cachedItem.height >= m.listHeight() {
514+ if m.selectionState.selectedIndex > 0 {
515+ changeNeeded := m.viewState.offset - cachedItem.start
516+ m.decreaseOffset(changeNeeded)
342517 } else {
343− cachedItem, _ := cached.(renderedItem)
344− // might not fit on the screen move the offset to the start of the item
345− if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
346− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
347− m.increaseOffset(changeNeeded)
348− }
518+ changeNeeded := cachedItem.start - m.viewState.offset
519+ m.increaseOffset(changeNeeded)
520+ }
521+ return
522+ }
523+
524+ if cachedItem.start < m.viewState.offset {
525+ changeNeeded := m.viewState.offset - cachedItem.start
526+ m.decreaseOffset(changeNeeded)
527+ } else {
528+ end := cachedItem.start + cachedItem.height
529+ if end > m.viewState.offset+m.listHeight() {
530+ changeNeeded := end - (m.viewState.offset + m.listHeight())
531+ m.increaseOffset(changeNeeded)
349532 }
350533 }
351− m.needsRerender = true
352− return tea.Batch(cmds...)
353534}
354535
355−func (m *model) downOneItem() tea.Cmd {
356− var cmds []tea.Cmd
357− if m.selectedItemInx < len(m.items)-1 {
358− cmd := m.blurSelected()
359− cmds = append(cmds, cmd)
360− m.selectedItemInx++
361− cmd = m.focusSelected()
362− cmds = append(cmds, cmd)
363− }
364− cached, ok := m.renderedItems.Load(m.selectedItemInx)
365− if ok {
366− // already rendered
367− if !m.reverse {
368− cachedItem, _ := cached.(renderedItem)
369− // might not fit on the screen move the offset to the start of the item
370− if cachedItem.height >= m.listHeight() {
371− changeNeeded := cachedItem.start - m.offset
372− m.increaseOffset(changeNeeded)
373− } else {
374− end := cachedItem.start + cachedItem.height
375− if end > m.offset+m.listHeight() {
376− changeNeeded := end - (m.offset + m.listHeight())
377− m.increaseOffset(changeNeeded)
378− }
379− }
536+// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
537+// Handles both large items (taller than viewport) and normal items.
538+func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
539+ if cachedItem.height >= m.listHeight() {
540+ if m.selectionState.selectedIndex < len(m.items)-1 {
541+ changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
542+ m.decreaseOffset(changeNeeded)
380543 } else {
381− cachedItem, _ := cached.(renderedItem)
382− // might not fit on the screen move the offset to the start of the item
383− if cachedItem.height >= m.listHeight() {
384− changeNeeded := m.offset - (cachedItem.start + cachedItem.height - m.listHeight())
385− m.decreaseOffset(changeNeeded)
386− } else {
387− if cachedItem.start < m.offset {
388− changeNeeded := m.offset - cachedItem.start
389− m.decreaseOffset(changeNeeded)
390− }
391− }
544+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
545+ m.increaseOffset(changeNeeded)
392546 }
547+ return
393548 }
394549
395− m.needsRerender = true
396− return tea.Batch(cmds...)
550+ if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
551+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
552+ m.increaseOffset(changeNeeded)
553+ } else if cachedItem.start < m.viewState.offset {
554+ changeNeeded := m.viewState.offset - cachedItem.start
555+ m.decreaseOffset(changeNeeded)
556+ }
397557}
398558
559+// goToBottom switches to reverse mode and selects the last item.
560+// Commonly used for chat-like interfaces where new content appears at the bottom.
399561func (m *model) goToBottom() tea.Cmd {
400− if len(m.items) == 0 {
401− return nil
402− }
403− var cmds []tea.Cmd
404− m.reverse = true
405− cmd := m.blurSelected()
406− cmds = append(cmds, cmd)
407− m.selectedItemInx = len(m.items) - 1
408− cmd = m.focusSelected()
409− cmds = append(cmds, cmd)
562+ cmds := []tea.Cmd{m.blurSelected()}
563+ m.viewState.reverse = true
564+ m.selectionState.selectedIndex = len(m.items) - 1
565+ cmds = append(cmds, m.focusSelected())
410566 m.ResetView()
411567 return tea.Batch(cmds...)
412568}
413569
414−func (m *model) ResetView() {
415− m.renderedItems.Clear()
416− m.renderedLines = []string{}
417− m.offset = 0
418− m.lastRenderedInx = -1
419− m.finalHeight = -1
420− m.needsRerender = true
421−}
422−
570+// goToTop switches to forward mode and selects the first item.
571+// Standard behavior for most list interfaces.
423572func (m *model) goToTop() tea.Cmd {
424− if len(m.items) == 0 {
425− return nil
573+ cmds := []tea.Cmd{m.blurSelected()}
574+ m.viewState.reverse = false
575+ if len(m.items) > 0 {
576+ m.selectionState.selectedIndex = 0
426577 }
427− var cmds []tea.Cmd
428− m.reverse = false
429− cmd := m.blurSelected()
430− cmds = append(cmds, cmd)
431− m.selectedItemInx = 0
432− cmd = m.focusSelected()
433− cmds = append(cmds, cmd)
578+ cmds = append(cmds, m.focusSelected())
434579 m.ResetView()
435580 return tea.Batch(cmds...)
436581}
437582
583+// ResetView clears all cached rendering data and resets scroll position.
584+// Forces a complete re-render on the next View() call.
585+func (m *model) ResetView() {
586+ m.renderState.reset()
587+ m.viewState.offset = 0
588+}
589+
590+// focusSelected gives focus to the currently selected item if it supports focus.
591+// Triggers a re-render of the item to show its focused state.
438592func (m *model) focusSelected() tea.Cmd {
439− if m.selectedItemInx == -1 {
593+ if !m.selectionState.isValidIndex(len(m.items)) {
440594 return nil
441595 }
442− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
596+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
443597 cmd := i.Focus()
444− m.rerenderItem(m.selectedItemInx)
598+ m.rerenderItem(m.selectionState.selectedIndex)
445599 return cmd
446600 }
447601 return nil
448602}
449603
604+// blurSelected removes focus from the currently selected item if it supports focus.
605+// Triggers a re-render of the item to show its unfocused state.
450606func (m *model) blurSelected() tea.Cmd {
451− if m.selectedItemInx == -1 {
607+ if !m.selectionState.isValidIndex(len(m.items)) {
452608 return nil
453609 }
454− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
610+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
455611 cmd := i.Blur()
456− m.rerenderItem(m.selectedItemInx)
612+ m.rerenderItem(m.selectionState.selectedIndex)
457613 return cmd
458614 }
459615 return nil
460616}
461617
618+// rerenderItem updates the cached rendering of a specific item.
619+// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
620+// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
462621func (m *model) rerenderItem(inx int) {
463− if inx < 0 || len(m.renderedLines) == 0 {
622+ if inx < 0 || inx >= len(m.items) || len(m.renderState.lines) == 0 {
464623 return
465624 }
466− cached, ok := m.renderedItems.Load(inx)
467− cachedItem, _ := cached.(renderedItem)
625+
626+ cachedItem, ok := m.renderState.items[inx]
468627 if !ok {
469− // No need to rerender
470628 return
471629 }
472− rerenderedItem := m.items[inx].View()
473− rerenderedLines := strings.Split(rerenderedItem, "\n")
474− if m.gapSize > 0 {
475− for range m.gapSize {
476− rerenderedLines = append(rerenderedLines, "")
477− }
478− }
479− // check if lines are the same
630+
631+ rerenderedLines := m.getItemLines(m.items[inx])
480632 if slices.Equal(cachedItem.lines, rerenderedLines) {
481− // No changes
482633 return
483634 }
484− // check if the item is in the content
485− start := cachedItem.start
486− end := start + cachedItem.height
487− totalLines := len(m.renderedLines)
488− if m.reverse {
635+
636+ m.updateRenderedLines(cachedItem, rerenderedLines)
637+ m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
638+ m.updateCachedItem(inx, cachedItem, rerenderedLines)
639+ m.renderState.needsRerender = true
640+}
641+
642+// getItemLines converts an item to its rendered lines, including any gap spacing.
643+func (m *model) getItemLines(item util.Model) []string {
644+ itemLines := strings.Split(item.View(), "\n")
645+ if m.gapSize > 0 {
646+ gap := make([]string, m.gapSize)
647+ itemLines = append(itemLines, gap...)
648+ }
649+ return itemLines
650+}
651+
652+// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
653+func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
654+ start, end := m.getItemBounds(cachedItem)
655+ totalLines := len(m.renderState.lines)
656+
657+ if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
658+ m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
659+ m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
660+ }
661+}
662+
663+// getItemBounds calculates the start and end line positions for an item.
664+// Handles both forward and reverse rendering modes.
665+func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
666+ start = cachedItem.start
667+ end = start + cachedItem.height
668+
669+ if m.viewState.reverse {
670+ totalLines := len(m.renderState.lines)
489671 end = totalLines - cachedItem.start
490672 start = end - cachedItem.height
491673 }
492− if start <= totalLines && end <= totalLines {
493− m.renderedLines = slices.Delete(m.renderedLines, start, end)
494− m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
674+ return start, end
675+}
676+
677+// updateItemPositions recalculates positions for items after the changed item.
678+// This is necessary when an item's height changes, affecting subsequent items.
679+func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
680+ if cachedItem.height == newHeight {
681+ return
682+ }
683+
684+ if inx == len(m.items)-1 {
685+ m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
686+ }
687+
688+ currentStart := cachedItem.start + newHeight
689+ if m.viewState.reverse {
690+ m.updatePositionsReverse(inx, currentStart)
691+ } else {
692+ m.updatePositionsForward(inx, currentStart)
495693 }
496− // TODO: if hight changed do something
497− if cachedItem.height != len(rerenderedLines) {
498− if inx == len(m.items)-1 {
499− m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
694+}
695+
696+// updatePositionsForward updates positions for items after the changed item in forward mode.
697+func (m *model) updatePositionsForward(inx int, currentStart int) {
698+ for i := inx + 1; i < len(m.items); i++ {
699+ if existing, ok := m.renderState.items[i]; ok {
700+ existing.start = currentStart
701+ currentStart += existing.height
702+ m.renderState.items[i] = existing
703+ } else {
704+ break
500705 }
706+ }
707+}
501708
502− // update the start of the other cached items
503− currentStart := cachedItem.start + len(rerenderedLines)
504− if m.reverse {
505− for i := inx - 1; i < len(m.items); i-- {
506− if existing, ok := m.renderedItems.Load(i); ok {
507− cached := existing.(renderedItem)
508− cached.start = currentStart
509− currentStart += cached.height
510− m.renderedItems.Store(i, cached)
511− } else {
512− break
513− }
514− }
709+// updatePositionsReverse updates positions for items before the changed item in reverse mode.
710+func (m *model) updatePositionsReverse(inx int, currentStart int) {
711+ for i := inx - 1; i >= 0; i-- {
712+ if existing, ok := m.renderState.items[i]; ok {
713+ existing.start = currentStart
714+ currentStart += existing.height
715+ m.renderState.items[i] = existing
515716 } else {
516− for i := inx + 1; i < len(m.items); i++ {
517− if existing, ok := m.renderedItems.Load(i); ok {
518− cached := existing.(renderedItem)
519− cached.start = currentStart
520− currentStart += cached.height
521− m.renderedItems.Store(i, cached)
522− } else {
523− break
524− }
525− }
717+ break
526718 }
527719 }
528− m.renderedItems.Store(inx, renderedItem{
529− lines: rerenderedLines,
720+}
721+
722+// updateCachedItem updates the cached rendering information for a specific item.
723+func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
724+ m.renderState.items[inx] = renderedItem{
725+ lines: newLines,
530726 start: cachedItem.start,
531− height: len(rerenderedLines),
532− })
533− m.needsRerender = true
727+ height: len(newLines),
728+ }
534729}
535730
731+// increaseOffset scrolls the list down by increasing the offset.
732+// Respects the final height limit to prevent scrolling past the end.
536733func (m *model) increaseOffset(n int) {
537− if m.finalHeight > -1 {
538− if m.offset < m.finalHeight {
539− m.offset += n
540− if m.offset > m.finalHeight {
541− m.offset = m.finalHeight
734+ if m.renderState.finalHeight > NoFinalHeight {
735+ if m.viewState.offset < m.renderState.finalHeight {
736+ m.viewState.offset += n
737+ if m.viewState.offset > m.renderState.finalHeight {
738+ m.viewState.offset = m.renderState.finalHeight
542739 }
543− m.needsRerender = true
740+ m.renderState.needsRerender = true
544741 }
545742 } else {
546− m.offset += n
547− m.needsRerender = true
743+ m.viewState.offset += n
744+ m.renderState.needsRerender = true
548745 }
549746}
550747
748+// decreaseOffset scrolls the list up by decreasing the offset.
749+// Prevents scrolling above the beginning of the list.
551750func (m *model) decreaseOffset(n int) {
552− if m.offset > 0 {
553− m.offset -= n
554− if m.offset < 0 {
555− m.offset = 0
751+ if m.viewState.offset > 0 {
752+ m.viewState.offset -= n
753+ if m.viewState.offset < 0 {
754+ m.viewState.offset = 0
556755 }
557− m.needsRerender = true
756+ m.renderState.needsRerender = true
558757 }
559758}
560759
561−// UpdateItem implements List.
760+// UpdateItem replaces an item at the specified index with a new item.
761+// Handles focus management and triggers re-rendering as needed.
562762func (m *model) UpdateItem(inx int, item util.Model) {
763+ if inx < 0 || inx >= len(m.items) {
764+ return
765+ }
563766 m.items[inx] = item
564− if m.selectedItemInx == inx {
565− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
767+ if m.selectionState.selectedIndex == inx {
768+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
566769 i.Focus()
567770 }
568771 }
569772 m.setItemSize(inx)
570773 m.rerenderItem(inx)
571− m.needsRerender = true
774+ m.renderState.needsRerender = true
572775}
573776
574−// GetSize implements List.
777+// GetSize returns the current dimensions of the list.
575778func (m *model) GetSize() (int, int) {
576− return m.width, m.height
779+ return m.viewState.width, m.viewState.height
577780}
578781
579−// SetSize implements List.
782+// SetSize updates the list dimensions and triggers a complete re-render.
783+// Also updates the size of all items that support sizing.
580784func (m *model) SetSize(width int, height int) tea.Cmd {
581− if m.width == width && m.height == height {
785+ if m.viewState.width == width && m.viewState.height == height {
582786 return nil
583787 }
584− if m.height != height {
585− m.finalHeight = -1
586− m.height = height
788+ if m.viewState.height != height {
789+ m.renderState.finalHeight = NoFinalHeight
790+ m.viewState.height = height
587791 }
588− m.width = width
792+ m.viewState.width = width
589793 m.ResetView()
590794 return m.setAllItemsSize()
591795}
592796
797+// getItemSize calculates the available width for items, accounting for padding.
593798func (m *model) getItemSize() int {
594− width := m.width
595− if m.padding != nil {
596− if len(m.padding) == 1 {
597− width -= m.padding[0] * 2
598− } else if len(m.padding) == 2 || len(m.padding) == 3 {
599− width -= m.padding[1] * 2
600− } else if len(m.padding) == 4 {
601− width -= m.padding[1] + m.padding[3]
602− }
799+ width := m.viewState.width
800+ switch len(m.padding) {
801+ case 1:
802+ width -= m.padding[0] * 2
803+ case 2, 3:
804+ width -= m.padding[1] * 2
805+ case 4:
806+ width -= m.padding[1] + m.padding[3]
603807 }
604− return width
808+ return max(0, width)
605809}
606810
811+// setItemSize updates the size of a specific item if it supports sizing.
607812func (m *model) setItemSize(inx int) tea.Cmd {
813+ if inx < 0 || inx >= len(m.items) {
814+ return nil
815+ }
608816 if i, ok := m.items[inx].(layout.Sizeable); ok {
609− cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
610− return cmd
817+ return i.SetSize(m.getItemSize(), 0)
611818 }
612819 return nil
613820}
614821
822+// setAllItemsSize updates the size of all items that support sizing.
615823func (m *model) setAllItemsSize() tea.Cmd {
616824 var cmds []tea.Cmd
617825 for i := range m.items {
618− cmd := m.setItemSize(i)
619− cmds = append(cmds, cmd)
826+ if cmd := m.setItemSize(i); cmd != nil {
827+ cmds = append(cmds, cmd)
828+ }
620829 }
621830 return tea.Batch(cmds...)
622831}
623832
833+// listHeight calculates the available height for list content, accounting for padding.
624834func (m *model) listHeight() int {
625− height := m.height
626− if m.padding != nil {
627− if len(m.padding) == 1 {
628− height -= m.padding[0] * 2
629− } else if len(m.padding) == 2 {
630− height -= m.padding[1] * 2
631− } else if len(m.padding) == 3 {
632− height -= m.padding[0] + m.padding[2]
633− } else if len(m.padding) == 4 {
634− height -= m.padding[0] + m.padding[2]
635− }
835+ height := m.viewState.height
836+ switch len(m.padding) {
837+ case 1:
838+ height -= m.padding[0] * 2
839+ case 2:
840+ height -= m.padding[0] * 2
841+ case 3, 4:
842+ height -= m.padding[0] + m.padding[2]
636843 }
637− return height
844+ return max(0, height)
638845}
639846
640−// AppendItem implements List.
847+// AppendItem adds a new item to the end of the list.
848+// Automatically switches to reverse mode and scrolls to show the new item.
641849func (m *model) AppendItem(item util.Model) tea.Cmd {
642− var cmds []tea.Cmd
643− cmd := item.Init()
644− cmds = append(cmds, cmd)
850+ cmds := []tea.Cmd{
851+ item.Init(),
852+ }
645853 m.items = append(m.items, item)
646− cmd = m.setItemSize(len(m.items) - 1)
647− cmds = append(cmds, cmd)
648− cmd = m.goToBottom()
649− cmds = append(cmds, cmd)
650− m.needsRerender = true
854+ cmds = append(cmds, m.setItemSize(len(m.items)-1))
855+ cmds = append(cmds, m.goToBottom())
856+ m.renderState.needsRerender = true
651857 return tea.Batch(cmds...)
652858}
653859
654−// DeleteItem implements List.
860+// DeleteItem removes an item at the specified index.
861+// Adjusts selection if necessary and triggers a complete re-render.
655862func (m *model) DeleteItem(i int) {
863+ if i < 0 || i >= len(m.items) {
864+ return
865+ }
656866 m.items = slices.Delete(m.items, i, i+1)
657− m.renderedItems.Delete(i)
658− if m.selectedItemInx == i {
659− m.selectedItemInx--
867+ delete(m.renderState.items, i)
868+
869+ if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
870+ m.selectionState.selectedIndex--
871+ } else if m.selectionState.selectedIndex > i {
872+ m.selectionState.selectedIndex--
660873 }
874+
661875 m.ResetView()
662− m.needsRerender = true
876+ m.renderState.needsRerender = true
663877}
664878
665−// PrependItem implements List.
879+// PrependItem adds a new item to the beginning of the list.
880+// Adjusts cached positions and selection index, then switches to forward mode.
666881func (m *model) PrependItem(item util.Model) tea.Cmd {
667− var cmds []tea.Cmd
668− cmd := item.Init()
669− cmds = append(cmds, cmd)
882+ cmds := []tea.Cmd{item.Init()}
670883 m.items = append([]util.Model{item}, m.items...)
671− // update the indices of the rendered items
672− newRenderedItems := make(map[int]renderedItem)
673− m.renderedItems.Range(func(key any, value any) bool {
674− keyInt := key.(int)
675− renderedItem := value.(renderedItem)
676− newKey := keyInt + 1
677− newRenderedItems[newKey] = renderedItem
678− return false
679− })
680− m.renderedItems.Clear()
681− for k, v := range newRenderedItems {
682− m.renderedItems.Store(k, v)
683− }
684− cmd = m.goToTop()
685− cmds = append(cmds, cmd)
686− cmd = m.setItemSize(0)
687− cmds = append(cmds, cmd)
688− m.needsRerender = true
884+
885+ // Shift all cached item indices by 1
886+ newItems := make(map[int]renderedItem, len(m.renderState.items))
887+ for k, v := range m.renderState.items {
888+ newItems[k+1] = v
889+ }
890+ m.renderState.items = newItems
891+
892+ if m.selectionState.selectedIndex >= 0 {
893+ m.selectionState.selectedIndex++
894+ }
895+
896+ cmds = append(cmds, m.goToTop())
897+ cmds = append(cmds, m.setItemSize(0))
898+ m.renderState.needsRerender = true
689899 return tea.Batch(cmds...)
690900}
691901
902+// setReverse switches between forward and reverse rendering modes.
692903func (m *model) setReverse(reverse bool) {
693904 if reverse {
694905 m.goToBottom()
@@ -697,24 +908,29 @@ func (m *model) setReverse(reverse bool) {
697908 }
698909}
699910
700−// SetItems implements List.
911+// SetItems replaces all items in the list with a new set.
912+// Initializes all items, sets their sizes, and establishes initial selection.
701913func (m *model) SetItems(items []util.Model) tea.Cmd {
702914 m.items = items
703− var cmds []tea.Cmd
704− cmd := m.setAllItemsSize()
705− cmds = append(cmds, cmd)
915+ cmds := []tea.Cmd{m.setAllItemsSize()}
916+
706917 for _, item := range m.items {
707918 cmds = append(cmds, item.Init())
708919 }
709− if m.reverse {
710− m.selectedItemInx = len(m.items) - 1
711− cmd := m.focusSelected()
712− cmds = append(cmds, cmd)
920+
921+ if len(m.items) > 0 {
922+ if m.viewState.reverse {
923+ m.selectionState.selectedIndex = len(m.items) - 1
924+ } else {
925+ m.selectionState.selectedIndex = 0
926+ }
927+ if cmd := m.focusSelected(); cmd != nil {
928+ cmds = append(cmds, cmd)
929+ }
713930 } else {
714− m.selectedItemInx = 0
715− cmd := m.focusSelected()
716− cmds = append(cmds, cmd)
931+ m.selectionState.selectedIndex = NoSelection
717932 }
933+
718934 m.ResetView()
719935 return tea.Batch(cmds...)
720936}