on main help sign in
refactor and document
8c6e817Kujtim Hoxha
.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) {
185 return m, nil
185 return m, nil
186 }
186 }
187 }
187 }
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− }
192 if key.Matches(msg, editorMaps.OpenEditor) {
188 if key.Matches(msg, editorMaps.OpenEditor) {
193 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
189 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
194 return m, util.ReportWarn("Agent is working, please wait...")
190 return m, util.ReportWarn("Agent is working, please wait...")
internal/tui/components/chat/list.go+421−26
@@ -1,29 +1,424 @@
1package chat
1package chat
2
2
3+import (
4+ "context"
5+ "time"
6+
3−import "github.com/charmbracelet/bubbles/v2/key"
7+ "github.com/charmbracelet/bubbles/v2/key"
4−
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.
5−type MessageKeys struct {
36+type messageListCmp struct {
6− PageDown key.Binding
37+ app *app.App
7− PageUp key.Binding
38+ width, height int
8− HalfPageUp key.Binding
39+ session session.Session
9− HalfPageDown key.Binding
40+ listCmp list.ListModel
41+
42+ lastUserMessageTime int64
10−}
43+}
11−
44+
12−var messageKeys = MessageKeys{
45+// NewMessagesListCmp creates a new message list component with custom keybindings
13− PageDown: key.NewBinding(
46+// and reverse ordering (newest messages at bottom).
14− key.WithKeys("pgdown"),
47+func NewMessagesListCmp(app *app.App) MessageListCmp {
15− key.WithHelp("f/pgdn", "page down"),
48+ defaultKeymaps := list.DefaultKeymap()
16− ),
49+ defaultKeymaps.NDown.SetEnabled(false)
50+ defaultKeymaps.NUp.SetEnabled(false)
17− PageUp: key.NewBinding(
51+ defaultKeymaps.Home = key.NewBinding(
18− key.WithKeys("pgup"),
52+ key.WithKeys("ctrl+g"),
19− key.WithHelp("b/pgup", "page up"),
20− ),
53+ )
21− HalfPageUp: key.NewBinding(
54+ defaultKeymaps.End = key.NewBinding(
22− key.WithKeys("ctrl+u"),
55+ key.WithKeys("ctrl+G"),
23− key.WithHelp("ctrl+u", "½ page up"),
56+ )
24− ),
57+ return &messageListCmp{
25− HalfPageDown: key.NewBinding(
58+ app: app,
26− key.WithKeys("ctrl+d", "ctrl+d"),
59+ listCmp: list.New(
27− key.WithHelp("ctrl+d", "½ page down"),
60+ list.WithGapSize(1),
28− ),
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)
29}
424}
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 (
19 "github.com/opencode-ai/opencode/internal/tui/util"
19 "github.com/opencode-ai/opencode/internal/tui/util"
20)
20)
21
21
22+// MessageCmp defines the interface for message components in the chat interface.
23+// It combines standard UI model interfaces with message-specific functionality.
22type MessageCmp interface {
24type MessageCmp interface {
23− util.Model
25+ util.Model // Basic Bubble Tea model interface
24− layout.Sizeable
26+ layout.Sizeable // Width/height management
25− layout.Focusable
27+ layout.Focusable // Focus state management
26− GetMessage() message.Message
28+ GetMessage() message.Message // Access to underlying message data
27− Spinning() bool
29+ Spinning() bool // Animation state for loading messages
28}
30}
29
31
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.
30type messageCmp struct {
35type messageCmp struct {
31− width int
36+ width int // Component width for text wrapping
32− focused bool
37+ focused bool // Focus state for border styling
33−
38+
34− // Used for agent and user messages
39+ // Core message data and state
35− message message.Message
40+ message message.Message // The underlying message content
36− spinning bool
41+ spinning bool // Whether to show loading animation
37− anim util.Model
42+ anim util.Model // Animation component for loading states
38− lastUserMessageTime time.Time
43+ lastUserMessageTime time.Time // Used for calculating response duration
39}
44}
45+
46+// MessageOption provides functional options for configuring message components
40type MessageOption func(*messageCmp)
47type MessageOption func(*messageCmp)
41
48
49+// WithLastUserMessageTime sets the timestamp of the last user message
50+// for calculating assistant response duration
42func WithLastUserMessageTime(t time.Time) MessageOption {
51func WithLastUserMessageTime(t time.Time) MessageOption {
43 return func(m *messageCmp) {
52 return func(m *messageCmp) {
44 m.lastUserMessageTime = t
53 m.lastUserMessageTime = t
45 }
54 }
46}
55}
47
56
57+// NewMessageCmp creates a new message component with the given message and options
48func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
58func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
49 m := &messageCmp{
59 m := &messageCmp{
50 message: msg,
60 message: msg,
@@ -56,6 +66,8 @@ func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
56 return m
66 return m
57}
67}
58
68
69+// Init initializes the message component and starts animations if needed.
70+// Returns a command to start the animation for spinning messages.
59func (m *messageCmp) Init() tea.Cmd {
71func (m *messageCmp) Init() tea.Cmd {
60 m.spinning = m.shouldSpin()
72 m.spinning = m.shouldSpin()
61 if m.spinning {
73 if m.spinning {
@@ -64,6 +76,8 @@ func (m *messageCmp) Init() tea.Cmd {
64 return nil
76 return nil
65}
77}
66
78
79+// Update handles incoming messages and updates the component state.
80+// Manages animation updates for spinning messages and stops animation when appropriate.
67func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68 switch msg := msg.(type) {
82 switch msg := msg.(type) {
69 case anim.ColorCycleMsg, anim.StepCharsMsg:
83 case anim.ColorCycleMsg, anim.StepCharsMsg:
@@ -77,6 +91,8 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77 return m, nil
91 return m, nil
78}
92}
79
93
94+// View renders the message component based on its current state.
95+// Returns different views for spinning, user, and assistant messages.
80func (m *messageCmp) View() string {
96func (m *messageCmp) View() string {
81 if m.spinning {
97 if m.spinning {
82 return m.style().PaddingLeft(1).Render(m.anim.View())
98 return m.style().PaddingLeft(1).Render(m.anim.View())
@@ -93,15 +109,19 @@ func (m *messageCmp) View() string {
93 return "Unknown Message"
109 return "Unknown Message"
94}
110}
95
111
96−// GetMessage implements MessageCmp.
112+// GetMessage returns the underlying message data
97func (m *messageCmp) GetMessage() message.Message {
113func (m *messageCmp) GetMessage() message.Message {
98 return m.message
114 return m.message
99}
115}
100
116
117+// textWidth calculates the available width for text content,
118+// accounting for borders and padding
101func (m *messageCmp) textWidth() int {
119func (m *messageCmp) textWidth() int {
102 return m.width - 1 // take into account the border
120 return m.width - 1 // take into account the border
103}
121}
104
122
123+// style returns the lipgloss style for the message component.
124+// Applies different border colors and styles based on message role and focus state.
105func (msg *messageCmp) style() lipgloss.Style {
125func (msg *messageCmp) style() lipgloss.Style {
106 t := theme.CurrentTheme()
126 t := theme.CurrentTheme()
107 var borderColor color.Color
127 var borderColor color.Color
@@ -127,6 +147,8 @@ func (msg *messageCmp) style() lipgloss.Style {
127 BorderStyle(borderStyle)
147 BorderStyle(borderStyle)
128}
148}
129
149
150+// renderAssistantMessage renders assistant messages with optional footer information.
151+// Shows model name, response time, and finish reason when the message is complete.
130func (m *messageCmp) renderAssistantMessage() string {
152func (m *messageCmp) renderAssistantMessage() string {
131 parts := []string{
153 parts := []string{
132 m.markdownContent(),
154 m.markdownContent(),
@@ -156,6 +178,8 @@ func (m *messageCmp) renderAssistantMessage() string {
156 return m.style().Render(joined)
178 return m.style().Render(joined)
157}
179}
158
180
181+// renderUserMessage renders user messages with file attachments.
182+// Displays message content and any attached files with appropriate icons.
159func (m *messageCmp) renderUserMessage() string {
183func (m *messageCmp) renderUserMessage() string {
160 t := theme.CurrentTheme()
184 t := theme.CurrentTheme()
161 parts := []string{
185 parts := []string{
@@ -183,12 +207,15 @@ func (m *messageCmp) renderUserMessage() string {
183 return m.style().Render(joined)
207 return m.style().Render(joined)
184}
208}
185
209
210+// toMarkdown converts text content to rendered markdown using the configured renderer
186func (m *messageCmp) toMarkdown(content string) string {
211func (m *messageCmp) toMarkdown(content string) string {
187 r := styles.GetMarkdownRenderer(m.textWidth())
212 r := styles.GetMarkdownRenderer(m.textWidth())
188 rendered, _ := r.Render(content)
213 rendered, _ := r.Render(content)
189 return strings.TrimSuffix(rendered, "\n")
214 return strings.TrimSuffix(rendered, "\n")
190}
215}
191
216
217+// markdownContent processes the message content and handles special states.
218+// Returns appropriate content for thinking, finished, and error states.
192func (m *messageCmp) markdownContent() string {
219func (m *messageCmp) markdownContent() string {
193 content := m.message.Content().String()
220 content := m.message.Content().String()
194 if m.message.Role == message.Assistant {
221 if m.message.Role == message.Assistant {
@@ -210,6 +237,8 @@ func (m *messageCmp) markdownContent() string {
210 return m.toMarkdown(content)
237 return m.toMarkdown(content)
211}
238}
212
239
240+// shouldSpin determines whether the message should show a loading animation.
241+// Only assistant messages without content that aren't finished should spin.
213func (m *messageCmp) shouldSpin() bool {
242func (m *messageCmp) shouldSpin() bool {
214 if m.message.Role != message.Assistant {
243 if m.message.Role != message.Assistant {
215 return false
244 return false
@@ -225,33 +254,39 @@ func (m *messageCmp) shouldSpin() bool {
225 return true
254 return true
226}
255}
227
256
228−// Blur implements MessageModel.
257+// Focus management methods
258+
259+// Blur removes focus from the message component
229func (m *messageCmp) Blur() tea.Cmd {
260func (m *messageCmp) Blur() tea.Cmd {
230 m.focused = false
261 m.focused = false
231 return nil
262 return nil
232}
263}
233
264
234−// Focus implements MessageModel.
265+// Focus sets focus on the message component
235func (m *messageCmp) Focus() tea.Cmd {
266func (m *messageCmp) Focus() tea.Cmd {
236 m.focused = true
267 m.focused = true
237 return nil
268 return nil
238}
269}
239
270
240−// IsFocused implements MessageModel.
271+// IsFocused returns whether the message component is currently focused
241func (m *messageCmp) IsFocused() bool {
272func (m *messageCmp) IsFocused() bool {
242 return m.focused
273 return m.focused
243}
274}
244
275
276+// Size management methods
277+
278+// GetSize returns the current dimensions of the message component
245func (m *messageCmp) GetSize() (int, int) {
279func (m *messageCmp) GetSize() (int, int) {
246 return m.width, 0
280 return m.width, 0
247}
281}
248
282
283+// SetSize updates the width of the message component for text wrapping
249func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
284func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
250 m.width = width
285 m.width = width
251 return nil
286 return nil
252}
287}
253
288
254−// Spinning implements MessageCmp.
289+// Spinning returns whether the message is currently showing a loading animation
255func (m *messageCmp) Spinning() bool {
290func (m *messageCmp) Spinning() bool {
256 return m.spinning
291 return m.spinning
257}
292}
internal/tui/components/chat/messages/renderer.go+283−164
@@ -17,19 +17,26 @@ import (
17 "github.com/opencode-ai/opencode/internal/tui/theme"
17 "github.com/opencode-ai/opencode/internal/tui/theme"
18)
18)
19
19
20+// responseContextHeight limits the number of lines displayed in tool output
20const responseContextHeight = 10
21const responseContextHeight = 10
21
22
23+// renderer defines the interface for tool-specific rendering implementations
22type renderer interface {
24type renderer interface {
23 // Render returns the complete (already styled) tool‑call view, not
25 // Render returns the complete (already styled) tool‑call view, not
24 // including the outer border.
26 // including the outer border.
25 Render(v *toolCallCmp) string
27 Render(v *toolCallCmp) string
26}
28}
27
29
30+// rendererFactory creates new renderer instances
28type rendererFactory func() renderer
31type rendererFactory func() renderer
29
32
33+// renderRegistry manages the mapping of tool names to their renderers
30type renderRegistry map[string]rendererFactory
34type renderRegistry map[string]rendererFactory
31
35
36+// register adds a new renderer factory to the registry
32func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
37func (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
33func (rr renderRegistry) lookup(name string) renderer {
40func (rr renderRegistry) lookup(name string) renderer {
34 if f, ok := rr[name]; ok {
41 if f, ok := rr[name]; ok {
35 return f()
42 return f()
@@ -37,9 +44,67 @@ func (rr renderRegistry) lookup(name string) renderer {
37 return genericRenderer{} // sensible fallback
44 return genericRenderer{} // sensible fallback
38}
45}
39
46
47+// registry holds all registered tool renderers
40var registry = renderRegistry{}
48var registry = renderRegistry{}
41
49
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+
42−// Registger tool renderers
107+// Register tool renderers
43func init() {
108func init() {
44 registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
109 registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
45 registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
110 registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
@@ -58,304 +123,358 @@ func init() {
58// Generic renderer
123// Generic renderer
59// -----------------------------------------------------------------------------
124// -----------------------------------------------------------------------------
60
125
126+// genericRenderer handles unknown tool types with basic parameter display
61−type genericRenderer struct{}
127+type genericRenderer struct {
128+ baseRenderer
129+}
62
130
131+// Render displays the tool call with its raw input and plain content output
63−func (genericRenderer) Render(v *toolCallCmp) string {
132+func (gr genericRenderer) Render(v *toolCallCmp) string {
64− header := makeHeader(prettifyToolName(v.call.Name), v.textWidth(), v.call.Input)
133+ return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
65− if res, done := earlyState(header, v); done {
66− return res
67− }
68− body := renderPlainContent(v, v.result.Content)
134+ return renderPlainContent(v, v.result.Content)
69− return joinHeaderBody(header, body)
135+ })
70}
136}
71
137
72// -----------------------------------------------------------------------------
138// -----------------------------------------------------------------------------
73// Bash renderer
139// Bash renderer
74// -----------------------------------------------------------------------------
140// -----------------------------------------------------------------------------
75
141
142+// bashRenderer handles bash command execution display
76−type bashRenderer struct{}
143+type bashRenderer struct {
77−
144+ baseRenderer
78−func (bashRenderer) Render(v *toolCallCmp) string {
145+}
79− var p tools.BashParams
80− _ = json.Unmarshal([]byte(v.call.Input), &p)
81
146
82− cmd := strings.ReplaceAll(p.Command, "\n", " ")
147+// Render displays the bash command with sanitized newlines and plain output
83− header := makeHeader("Bash", v.textWidth(), cmd)
148+func (br bashRenderer) Render(v *toolCallCmp) string {
84− if res, done := earlyState(header, v); done {
149+ var params tools.BashParams
85− return res
150+ if err := br.unmarshalParams(v.call.Input, &params); err != nil {
151+ return br.renderError(v, "Invalid bash parameters")
86 }
152 }
153+
154+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
155+ args := newParamBuilder().addMain(cmd).build()
156+
157+ return br.renderWithParams(v, "Bash", args, func() string {
87− body := renderPlainContent(v, v.result.Content)
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)
88− return joinHeaderBody(header, body)
165+ return joinHeaderBody(header, "")
89}
166}
90
167
91// -----------------------------------------------------------------------------
168// -----------------------------------------------------------------------------
92// View renderer
169// View renderer
93// -----------------------------------------------------------------------------
170// -----------------------------------------------------------------------------
94
171
172+// viewRenderer handles file viewing with syntax highlighting and line numbers
95−type viewRenderer struct{}
173+type viewRenderer struct {
174+ baseRenderer
175+}
96
176
177+// Render displays file content with optional limit and offset parameters
97−func (viewRenderer) Render(v *toolCallCmp) string {
178+func (vr viewRenderer) Render(v *toolCallCmp) string {
98 var params tools.ViewParams
179 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+ }
100
183
101 file := removeWorkingDirPrefix(params.FilePath)
184 file := removeWorkingDirPrefix(params.FilePath)
102− args := []string{file}
185+ args := newParamBuilder().
103− if params.Limit != 0 {
186+ addMain(file).
104− args = append(args, "limit", fmt.Sprintf("%d", params.Limit))
187+ addKeyValue("limit", formatNonZero(params.Limit)).
105− }
188+ addKeyValue("offset", formatNonZero(params.Offset)).
106− if params.Offset != 0 {
189+ build()
107− args = append(args, "offset", fmt.Sprintf("%d", params.Offset))
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)
108− }
197+ })
198+}
109
199
110− header := makeHeader("View", v.textWidth(), args...)
200+// formatNonZero returns string representation of non-zero integers, empty string for zero
111− if res, done := earlyState(header, v); done {
201+func formatNonZero(value int) string {
202+ if value == 0 {
112− return res
203+ return ""
113 }
204 }
114−
205+ return fmt.Sprintf("%d", value)
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)
120}
206}
121
207
122// -----------------------------------------------------------------------------
208// -----------------------------------------------------------------------------
123// Edit renderer
209// Edit renderer
124// -----------------------------------------------------------------------------
210// -----------------------------------------------------------------------------
125
211
212+// editRenderer handles file editing with diff visualization
126−type editRenderer struct{}
213+type editRenderer struct {
214+ baseRenderer
215+}
127
216
217+// Render displays the edited file with a formatted diff of changes
128−func (editRenderer) Render(v *toolCallCmp) string {
218+func (er editRenderer) Render(v *toolCallCmp) string {
129 var params tools.EditParams
219 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+ }
131
223
132 file := removeWorkingDirPrefix(params.FilePath)
224 file := removeWorkingDirPrefix(params.FilePath)
133− header := makeHeader("Edit", v.textWidth(), file)
225+ args := newParamBuilder().addMain(file).build()
134− if res, done := earlyState(header, v); done {
135− return res
136− }
137
226
227+ return er.renderWithParams(v, "Edit", args, func() string {
138− var meta tools.EditResponseMetadata
228+ var meta tools.EditResponseMetadata
139− _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
229+ if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
230+ return renderPlainContent(v, v.result.Content)
231+ }
140
232
141− trunc := truncateHeight(meta.Diff, responseContextHeight)
233+ trunc := truncateHeight(meta.Diff, responseContextHeight)
142− diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
234+ diffView, _ := diff.FormatDiff(trunc, diff.WithTotalWidth(v.textWidth()))
143− return joinHeaderBody(header, diffView)
235+ return diffView
236+ })
144}
237}
145
238
146// -----------------------------------------------------------------------------
239// -----------------------------------------------------------------------------
147// Write renderer
240// Write renderer
148// -----------------------------------------------------------------------------
241// -----------------------------------------------------------------------------
149
242
243+// writeRenderer handles file writing with syntax-highlighted content preview
150−type writeRenderer struct{}
244+type writeRenderer struct {
245+ baseRenderer
246+}
151
247
248+// Render displays the file being written with syntax highlighting
152−func (writeRenderer) Render(v *toolCallCmp) string {
249+func (wr writeRenderer) Render(v *toolCallCmp) string {
153 var params tools.WriteParams
250 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+ }
155
254
156 file := removeWorkingDirPrefix(params.FilePath)
255 file := removeWorkingDirPrefix(params.FilePath)
157− header := makeHeader("Write", v.textWidth(), file)
256+ args := newParamBuilder().addMain(file).build()
158− if res, done := earlyState(header, v); done {
159− return res
160− }
161
257
258+ return wr.renderWithParams(v, "Write", args, func() string {
162− body := renderCodeContent(v, file, params.Content, 0)
259+ return renderCodeContent(v, file, params.Content, 0)
163− return joinHeaderBody(header, body)
260+ })
164}
261}
165
262
166// -----------------------------------------------------------------------------
263// -----------------------------------------------------------------------------
167// Fetch renderer
264// Fetch renderer
168// -----------------------------------------------------------------------------
265// -----------------------------------------------------------------------------
169
266
267+// fetchRenderer handles URL fetching with format-specific content display
170−type fetchRenderer struct{}
268+type fetchRenderer struct {
269+ baseRenderer
270+}
171
271
272+// Render displays the fetched URL with format and timeout parameters
172−func (fetchRenderer) Render(v *toolCallCmp) string {
273+func (fr fetchRenderer) Render(v *toolCallCmp) string {
173 var params tools.FetchParams
274 var params tools.FetchParams
174− _ = json.Unmarshal([]byte(v.call.Input), &params)
275+ if err := fr.unmarshalParams(v.call.Input, &params); err != nil {
175−
276+ return fr.renderError(v, "Invalid fetch parameters")
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())
182 }
277 }
183
278
184− header := makeHeader("Fetch", v.textWidth(), args...)
279+ args := newParamBuilder().
185− if res, done := earlyState(header, v); done {
280+ addMain(params.URL).
186− return res
281+ addKeyValue("format", params.Format).
187− }
282+ addKeyValue("timeout", formatTimeout(params.Timeout)).
283+ build()
188
284
189− file := "fetch.md"
285+ return fr.renderWithParams(v, "Fetch", args, func() string {
190− switch params.Format {
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 {
191 case "text":
294 case "text":
192− file = "fetch.txt"
295+ return "fetch.txt"
193 case "html":
296 case "html":
194− file = "fetch.html"
297+ return "fetch.html"
298+ default:
299+ return "fetch.md"
195 }
300 }
301+}
196
302
197− body := renderCodeContent(v, file, v.result.Content, 0)
303+// formatTimeout converts timeout seconds to duration string
198− return joinHeaderBody(header, body)
304+func formatTimeout(timeout int) string {
305+ if timeout == 0 {
306+ return ""
307+ }
308+ return (time.Duration(timeout) * time.Second).String()
199}
309}
200
310
201// -----------------------------------------------------------------------------
311// -----------------------------------------------------------------------------
202// Glob renderer
312// Glob renderer
203// -----------------------------------------------------------------------------
313// -----------------------------------------------------------------------------
204
314
315+// globRenderer handles file pattern matching with path filtering
205−type globRenderer struct{}
316+type globRenderer struct {
317+ baseRenderer
318+}
206
319
320+// Render displays the glob pattern with optional path parameter
207−func (globRenderer) Render(v *toolCallCmp) string {
321+func (gr globRenderer) Render(v *toolCallCmp) string {
208 var params tools.GlobParams
322 var params tools.GlobParams
209− _ = json.Unmarshal([]byte(v.call.Input), &params)
323+ if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
210−
324+ return gr.renderError(v, "Invalid glob parameters")
211− args := []string{params.Pattern}
212− if params.Path != "" {
213− args = append(args, "path", params.Path)
214 }
325 }
215
326
216− header := makeHeader("Glob", v.textWidth(), args...)
327+ args := newParamBuilder().
217− if res, done := earlyState(header, v); done {
328+ addMain(params.Pattern).
218− return res
329+ addKeyValue("path", params.Path).
219− }
330+ build()
220
331
332+ return gr.renderWithParams(v, "Glob", args, func() string {
221− body := renderPlainContent(v, v.result.Content)
333+ return renderPlainContent(v, v.result.Content)
222− return joinHeaderBody(header, body)
334+ })
223}
335}
224
336
225// -----------------------------------------------------------------------------
337// -----------------------------------------------------------------------------
226// Grep renderer
338// Grep renderer
227// -----------------------------------------------------------------------------
339// -----------------------------------------------------------------------------
228
340
341+// grepRenderer handles content searching with pattern matching options
229−type grepRenderer struct{}
342+type grepRenderer struct {
343+ baseRenderer
344+}
230
345
346+// Render displays the search pattern with path, include, and literal text options
231−func (grepRenderer) Render(v *toolCallCmp) string {
347+func (gr grepRenderer) Render(v *toolCallCmp) string {
232 var params tools.GrepParams
348 var params tools.GrepParams
233− _ = json.Unmarshal([]byte(v.call.Input), &params)
349+ if err := gr.unmarshalParams(v.call.Input, &params); err != nil {
234−
350+ return gr.renderError(v, "Invalid grep parameters")
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")
244 }
351 }
245
352
246− header := makeHeader("Grep", v.textWidth(), args...)
353+ args := newParamBuilder().
247− if res, done := earlyState(header, v); done {
354+ addMain(params.Pattern).
248− return res
355+ addKeyValue("path", params.Path).
249− }
356+ addKeyValue("include", params.Include).
357+ addFlag("literal", params.LiteralText).
358+ build()
250
359
360+ return gr.renderWithParams(v, "Grep", args, func() string {
251− body := renderPlainContent(v, v.result.Content)
361+ return renderPlainContent(v, v.result.Content)
252− return joinHeaderBody(header, body)
362+ })
253}
363}
254
364
255// -----------------------------------------------------------------------------
365// -----------------------------------------------------------------------------
256// LS renderer
366// LS renderer
257// -----------------------------------------------------------------------------
367// -----------------------------------------------------------------------------
258
368
369+// lsRenderer handles directory listing with default path handling
259−type lsRenderer struct{}
370+type lsRenderer struct {
371+ baseRenderer
372+}
260
373
374+// Render displays the directory path, defaulting to current directory
261−func (lsRenderer) Render(v *toolCallCmp) string {
375+func (lr lsRenderer) Render(v *toolCallCmp) string {
262 var params tools.LSParams
376 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+ }
264
380
265 path := params.Path
381 path := params.Path
266 if path == "" {
382 if path == "" {
267 path = "."
383 path = "."
268 }
384 }
269
385
270− header := makeHeader("List", v.textWidth(), path)
386+ args := newParamBuilder().addMain(path).build()
271− if res, done := earlyState(header, v); done {
272− return res
273− }
274
387
388+ return lr.renderWithParams(v, "List", args, func() string {
275− body := renderPlainContent(v, v.result.Content)
389+ return renderPlainContent(v, v.result.Content)
276− return joinHeaderBody(header, body)
390+ })
277}
391}
278
392
279// -----------------------------------------------------------------------------
393// -----------------------------------------------------------------------------
280// Sourcegraph renderer
394// Sourcegraph renderer
281// -----------------------------------------------------------------------------
395// -----------------------------------------------------------------------------
282
396
397+// sourcegraphRenderer handles code search with count and context options
283−type sourcegraphRenderer struct{}
398+type sourcegraphRenderer struct {
399+ baseRenderer
400+}
284
401
402+// Render displays the search query with optional count and context window parameters
285−func (sourcegraphRenderer) Render(v *toolCallCmp) string {
403+func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
286 var params tools.SourcegraphParams
404 var params tools.SourcegraphParams
287− _ = json.Unmarshal([]byte(v.call.Input), &params)
405+ if err := sr.unmarshalParams(v.call.Input, &params); err != nil {
288−
406+ return sr.renderError(v, "Invalid sourcegraph parameters")
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))
295 }
407 }
296
408
297− header := makeHeader("Sourcegraph", v.textWidth(), args...)
409+ args := newParamBuilder().
298− if res, done := earlyState(header, v); done {
410+ addMain(params.Query).
299− return res
411+ addKeyValue("count", formatNonZero(params.Count)).
300− }
412+ addKeyValue("context", formatNonZero(params.ContextWindow)).
413+ build()
301
414
415+ return sr.renderWithParams(v, "Sourcegraph", args, func() string {
302− body := renderPlainContent(v, v.result.Content)
416+ return renderPlainContent(v, v.result.Content)
303− return joinHeaderBody(header, body)
417+ })
304}
418}
305
419
306// -----------------------------------------------------------------------------
420// -----------------------------------------------------------------------------
307// Patch renderer
421// Patch renderer
308// -----------------------------------------------------------------------------
422// -----------------------------------------------------------------------------
309
423
424+// patchRenderer handles multi-file patches with change summaries
310−type patchRenderer struct{}
425+type patchRenderer struct {
426+ baseRenderer
427+}
311
428
429+// Render displays patch summary with file count and change statistics
312−func (patchRenderer) Render(v *toolCallCmp) string {
430+func (pr patchRenderer) Render(v *toolCallCmp) string {
313 var params tools.PatchParams
431 var params tools.PatchParams
314− _ = json.Unmarshal([]byte(v.call.Input), &params)
432+ if err := pr.unmarshalParams(v.call.Input, &params); err != nil {
315−
433+ return pr.renderError(v, "Invalid patch parameters")
316− header := makeHeader("Patch", v.textWidth(), "multiple files")
317− if res, done := earlyState(header, v); done {
318− return res
319 }
434 }
320
435
321− var meta tools.PatchResponseMetadata
436+ args := newParamBuilder().addMain("multiple files").build()
322− _ = json.Unmarshal([]byte(v.result.Metadata), &meta)
323
437
324− // Format the result as a summary of changes
438+ return pr.renderWithParams(v, "Patch", args, func() string {
325− summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
439+ var meta tools.PatchResponseMetadata
326− len(meta.FilesChanged), meta.Additions, meta.Removals)
440+ if err := pr.unmarshalParams(v.result.Metadata, &meta); err != nil {
441+ return renderPlainContent(v, v.result.Content)
442+ }
327
443
328− // List the changed files
444+ summary := fmt.Sprintf("Changed %d files (%d+ %d-)",
445+ len(meta.FilesChanged), meta.Additions, meta.Removals)
329− filesList := strings.Join(meta.FilesChanged, "\n")
446+ filesList := strings.Join(meta.FilesChanged, "\n")
330
447
331− body := renderPlainContent(v, summary+"\n\n"+filesList)
448+ return renderPlainContent(v, summary+"\n\n"+filesList)
332− return joinHeaderBody(header, body)
449+ })
333}
450}
334
451
335// -----------------------------------------------------------------------------
452// -----------------------------------------------------------------------------
336// Diagnostics renderer
453// Diagnostics renderer
337// -----------------------------------------------------------------------------
454// -----------------------------------------------------------------------------
338
455
456+// diagnosticsRenderer handles project-wide diagnostic information
339−type diagnosticsRenderer struct{}
457+type diagnosticsRenderer struct {
458+ baseRenderer
459+}
340
460
461+// Render displays project diagnostics with plain content formatting
341−func (diagnosticsRenderer) Render(v *toolCallCmp) string {
462+func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
342− header := makeHeader("Diagnostics", v.textWidth(), "project")
463+ args := newParamBuilder().addMain("project").build()
343− if res, done := earlyState(header, v); done {
344− return res
345− }
346
464
465+ return dr.renderWithParams(v, "Diagnostics", args, func() string {
347− body := renderPlainContent(v, v.result.Content)
466+ return renderPlainContent(v, v.result.Content)
348− return joinHeaderBody(header, body)
467+ })
349}
468}
350
469
351// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
470// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
352func makeHeader(tool string, width int, params ...string) string {
471func makeHeader(tool string, width int, params ...string) string {
353 prefix := tool + ": "
472 prefix := tool + ": "
354− return prefix + renderParams(width-lipgloss.Width(prefix), params...)
473+ return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
355}
474}
356
475
357−// renders params, params[0] (params[1]=params[2] ....)
476+// renderParamList renders params, params[0] (params[1]=params[2] ....)
358−func renderParams(paramsWidth int, params ...string) string {
477+func renderParamList(paramsWidth int, params ...string) string {
359 if len(params) == 0 {
478 if len(params) == 0 {
360 return ""
479 return ""
361 }
480 }
internal/tui/components/chat/messages/tool.go+65−27
@@ -15,46 +15,57 @@ import (
15 "github.com/opencode-ai/opencode/internal/tui/util"
15 "github.com/opencode-ai/opencode/internal/tui/util"
16)
16)
17
17
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.
18type ToolCallCmp interface {
20type ToolCallCmp interface {
19− util.Model
21+ util.Model // Basic Bubble Tea model interface
20− layout.Sizeable
22+ layout.Sizeable // Width/height management
21− layout.Focusable
23+ layout.Focusable // Focus state management
22− GetToolCall() message.ToolCall
24+ GetToolCall() message.ToolCall // Access to tool call data
23− GetToolResult() message.ToolResult
25+ GetToolResult() message.ToolResult // Access to tool result data
24− SetToolResult(message.ToolResult)
26+ SetToolResult(message.ToolResult) // Update tool result
25− SetToolCall(message.ToolCall)
27+ SetToolCall(message.ToolCall) // Update tool call
26− SetCancelled()
28+ SetCancelled() // Mark as cancelled
27− ParentMessageId() string
29+ ParentMessageId() string // Get parent message ID
28− Spinning() bool
30+ Spinning() bool // Animation state for pending tools
29−}
31+}
30−
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.
31type toolCallCmp struct {
35type toolCallCmp struct {
32− width int
36+ width int // Component width for text wrapping
33− focused bool
37+ focused bool // Focus state for border styling
34
38
39+ // Tool call data and state
35− parentMessageId string
40+ parentMessageId string // ID of the message that initiated this tool call
36− call message.ToolCall
41+ call message.ToolCall // The tool call being executed
37− result message.ToolResult
42+ result message.ToolResult // The result of the tool execution
38− cancelled bool
43+ cancelled bool // Whether the tool call was cancelled
39
44
45+ // Animation state for pending tool calls
40− spinning bool
46+ spinning bool // Whether to show loading animation
41− anim util.Model
47+ anim util.Model // Animation component for pending states
42}
48}
43
49
50+// ToolCallOption provides functional options for configuring tool call components
44type ToolCallOption func(*toolCallCmp)
51type ToolCallOption func(*toolCallCmp)
45
52
53+// WithToolCallCancelled marks the tool call as cancelled
46func WithToolCallCancelled() ToolCallOption {
54func WithToolCallCancelled() ToolCallOption {
47 return func(m *toolCallCmp) {
55 return func(m *toolCallCmp) {
48 m.cancelled = true
56 m.cancelled = true
49 }
57 }
50}
58}
51
59
60+// WithToolCallResult sets the initial tool result
52func WithToolCallResult(result message.ToolResult) ToolCallOption {
61func WithToolCallResult(result message.ToolResult) ToolCallOption {
53 return func(m *toolCallCmp) {
62 return func(m *toolCallCmp) {
54 m.result = result
63 m.result = result
55 }
64 }
56}
65}
57
66
67+// NewToolCallCmp creates a new tool call component with the given parent message ID,
68+// tool call, and optional configuration
58func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
69func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
59 m := &toolCallCmp{
70 m := &toolCallCmp{
60 call: tc,
71 call: tc,
@@ -67,6 +78,8 @@ func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCal
67 return m
78 return m
68}
79}
69
80
81+// Init initializes the tool call component and starts animations if needed.
82+// Returns a command to start the animation for pending tool calls.
70func (m *toolCallCmp) Init() tea.Cmd {
83func (m *toolCallCmp) Init() tea.Cmd {
71 m.spinning = m.shouldSpin()
84 m.spinning = m.shouldSpin()
72 logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
85 logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
@@ -76,6 +89,8 @@ func (m *toolCallCmp) Init() tea.Cmd {
76 return nil
89 return nil
77}
90}
78
91
92+// Update handles incoming messages and updates the component state.
93+// Manages animation updates for pending tool calls.
79func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80 logging.Debug("Tool call update", "msg", msg)
95 logging.Debug("Tool call update", "msg", msg)
81 switch msg := msg.(type) {
96 switch msg := msg.(type) {
@@ -89,6 +104,8 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89 return m, nil
104 return m, nil
90}
105}
91
106
107+// View renders the tool call component based on its current state.
108+// Shows either a pending animation or the tool-specific rendered result.
92func (m *toolCallCmp) View() string {
109func (m *toolCallCmp) View() string {
93 box := m.style()
110 box := m.style()
94
111
@@ -100,12 +117,14 @@ func (m *toolCallCmp) View() string {
100 return box.PaddingLeft(1).Render(r.Render(m))
117 return box.PaddingLeft(1).Render(r.Render(m))
101}
118}
102
119
103−// SetCancelled implements ToolCallCmp.
120+// State management methods
121+
122+// SetCancelled marks the tool call as cancelled
104func (m *toolCallCmp) SetCancelled() {
123func (m *toolCallCmp) SetCancelled() {
105 m.cancelled = true
124 m.cancelled = true
106}
125}
107
126
108−// SetToolCall implements ToolCallCmp.
127+// SetToolCall updates the tool call data and stops spinning if finished
109func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
128func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
110 m.call = call
129 m.call = call
111 if m.call.Finished {
130 if m.call.Finished {
@@ -113,31 +132,36 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
113 }
132 }
114}
133}
115
134
116−// ParentMessageId implements ToolCallCmp.
135+// ParentMessageId returns the ID of the message that initiated this tool call
117func (m *toolCallCmp) ParentMessageId() string {
136func (m *toolCallCmp) ParentMessageId() string {
118 return m.parentMessageId
137 return m.parentMessageId
119}
138}
120
139
121−// SetToolResult implements ToolCallCmp.
140+// SetToolResult updates the tool result and stops the spinning animation
122func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
141func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
123 m.result = result
142 m.result = result
124 m.spinning = false
143 m.spinning = false
125}
144}
126
145
127−// GetToolCall implements ToolCallCmp.
146+// GetToolCall returns the current tool call data
128func (m *toolCallCmp) GetToolCall() message.ToolCall {
147func (m *toolCallCmp) GetToolCall() message.ToolCall {
129 return m.call
148 return m.call
130}
149}
131
150
132−// GetToolResult implements ToolCallCmp.
151+// GetToolResult returns the current tool result data
133func (m *toolCallCmp) GetToolResult() message.ToolResult {
152func (m *toolCallCmp) GetToolResult() message.ToolResult {
134 return m.result
153 return m.result
135}
154}
136
155
156+// Rendering methods
157+
158+// renderPending displays the tool name with a loading animation for pending tool calls
137func (m *toolCallCmp) renderPending() string {
159func (m *toolCallCmp) renderPending() string {
138 return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
160 return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
139}
161}
140
162
163+// style returns the lipgloss style for the tool call component.
164+// Applies muted colors and focus-dependent border styles.
141func (m *toolCallCmp) style() lipgloss.Style {
165func (m *toolCallCmp) style() lipgloss.Style {
142 t := theme.CurrentTheme()
166 t := theme.CurrentTheme()
143 borderStyle := lipgloss.NormalBorder()
167 borderStyle := lipgloss.NormalBorder()
@@ -151,10 +175,13 @@ func (m *toolCallCmp) style() lipgloss.Style {
151 BorderStyle(borderStyle)
175 BorderStyle(borderStyle)
152}
176}
153
177
178+// textWidth calculates the available width for text content,
179+// accounting for borders and padding
154func (m *toolCallCmp) textWidth() int {
180func (m *toolCallCmp) textWidth() int {
155 return m.width - 2 // take into account the border and PaddingLeft
181 return m.width - 2 // take into account the border and PaddingLeft
156}
182}
157
183
184+// fit truncates content to fit within the specified width with ellipsis
158func (m *toolCallCmp) fit(content string, width int) string {
185func (m *toolCallCmp) fit(content string, width int) string {
159 t := theme.CurrentTheme()
186 t := theme.CurrentTheme()
160 lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
187 lineStyle := lipgloss.NewStyle().Background(t.BackgroundSecondary()).Foreground(t.TextMuted())
@@ -162,30 +189,40 @@ func (m *toolCallCmp) fit(content string, width int) string {
162 return ansi.Truncate(content, width, dots)
189 return ansi.Truncate(content, width, dots)
163}
190}
164
191
192+// Focus management methods
193+
194+// Blur removes focus from the tool call component
165func (m *toolCallCmp) Blur() tea.Cmd {
195func (m *toolCallCmp) Blur() tea.Cmd {
166 m.focused = false
196 m.focused = false
167 return nil
197 return nil
168}
198}
169
199
200+// Focus sets focus on the tool call component
170func (m *toolCallCmp) Focus() tea.Cmd {
201func (m *toolCallCmp) Focus() tea.Cmd {
171 m.focused = true
202 m.focused = true
172 return nil
203 return nil
173}
204}
174
205
175−// IsFocused implements MessageModel.
206+// IsFocused returns whether the tool call component is currently focused
176func (m *toolCallCmp) IsFocused() bool {
207func (m *toolCallCmp) IsFocused() bool {
177 return m.focused
208 return m.focused
178}
209}
179
210
211+// Size management methods
212+
213+// GetSize returns the current dimensions of the tool call component
180func (m *toolCallCmp) GetSize() (int, int) {
214func (m *toolCallCmp) GetSize() (int, int) {
181 return m.width, 0
215 return m.width, 0
182}
216}
183
217
218+// SetSize updates the width of the tool call component for text wrapping
184func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
219func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
185 m.width = width
220 m.width = width
186 return nil
221 return nil
187}
222}
188
223
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.
189func (m *toolCallCmp) shouldSpin() bool {
226func (m *toolCallCmp) shouldSpin() bool {
190 if !m.call.Finished {
227 if !m.call.Finished {
191 return true
228 return true
@@ -195,6 +232,7 @@ func (m *toolCallCmp) shouldSpin() bool {
195 return false
232 return false
196}
233}
197
234
235+// Spinning returns whether the tool call is currently showing a loading animation
198func (m *toolCallCmp) Spinning() bool {
236func (m *toolCallCmp) Spinning() bool {
199 return m.spinning
237 return m.spinning
200}
238}
internal/tui/components/core/list/keys.go+1−1
@@ -16,7 +16,7 @@ type KeyMap struct {
16 Submit key.Binding
16 Submit key.Binding
17}
17}
18
18
19−func defaultKeymap() KeyMap {
19+func DefaultKeymap() KeyMap {
20 return KeyMap{
20 return KeyMap{
21 Down: key.NewBinding(
21 Down: key.NewBinding(
22 key.WithKeys("down", "ctrl+j", "ctrl+n"),
22 key.WithKeys("down", "ctrl+j", "ctrl+n"),
internal/tui/components/core/list/list.go+668−452
@@ -3,7 +3,6 @@ package list
3import (
3import (
4 "slices"
4 "slices"
5 "strings"
5 "strings"
6− "sync"
7
6
8 "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/help"
9 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
@@ -14,89 +13,155 @@ import (
14 "github.com/opencode-ai/opencode/internal/tui/util"
13 "github.com/opencode-ai/opencode/internal/tui/util"
15)
14)
16
15
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.
17type ListModel interface {
26type ListModel interface {
18 util.Model
27 util.Model
19 layout.Sizeable
28 layout.Sizeable
20− SetItems([]util.Model) tea.Cmd
29+ SetItems([]util.Model) tea.Cmd // Replace all items in the list
21− AppendItem(util.Model) tea.Cmd
30+ AppendItem(util.Model) tea.Cmd // Add an item to the end of the list
22− PrependItem(util.Model) tea.Cmd
31+ PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
23− DeleteItem(int)
32+ DeleteItem(int) // Remove an item at the specified index
24− UpdateItem(int, util.Model)
33+ UpdateItem(int, util.Model) // Replace an item at the specified index
25− ResetView()
34+ ResetView() // Clear rendering cache and reset scroll position
26− Items() []util.Model
35+ Items() []util.Model // Get all items in the list
27}
36}
28
37
38+// HasAnim interface identifies items that support animation.
39+// Items implementing this interface will receive animation update messages.
29type HasAnim interface {
40type HasAnim interface {
30 util.Model
41 util.Model
31− Spinning() bool
42+ Spinning() bool // Returns true if the item is currently animating
32}
43}
33
44
45+// renderedItem represents a cached rendered item with its position and content.
34type renderedItem struct {
46type renderedItem struct {
35− lines []string
47+ lines []string // The rendered lines of text for this item
36− start int
48+ start int // Starting line position in the overall rendered content
37− height int
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
38}
98}
99+
100+// model is the main implementation of the ListModel interface.
101+// It coordinates between view state, render state, and selection state.
39type model struct {
102type model struct {
40− width, height, offset int
103+ viewState viewState // Display and scrolling state
41− finalHeight int // this gets set when the last item is rendered to mark the max offset
104+ renderState *renderState // Rendering cache and state
42− reverse bool
105+ selectionState selectionState // Item selection state
43− help help.Model
106+ help help.Model // Help system for keyboard shortcuts
44− keymap KeyMap
107+ keymap KeyMap // Key bindings for navigation
45− items []util.Model
108+ items []util.Model // The actual list items
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
109+ gapSize int // Number of empty lines between items
53− padding []int
110+ padding []int // Padding around the list content
54}
111}
55
112
113+// listOptions is a function type for configuring list options.
56type listOptions func(*model)
114type listOptions func(*model)
57
115
116+// WithKeyMap sets custom key bindings for the list.
58func WithKeyMap(k KeyMap) listOptions {
117func WithKeyMap(k KeyMap) listOptions {
59 return func(m *model) {
118 return func(m *model) {
60 m.keymap = k
119 m.keymap = k
61 }
120 }
62}
121}
63
122
123+// WithReverse sets whether the list should render in reverse order (newest items at bottom).
64func WithReverse(reverse bool) listOptions {
124func WithReverse(reverse bool) listOptions {
65 return func(m *model) {
125 return func(m *model) {
66 m.setReverse(reverse)
126 m.setReverse(reverse)
67 }
127 }
68}
128}
69
129
130+// WithGapSize sets the number of empty lines to insert between list items.
70func WithGapSize(gapSize int) listOptions {
131func WithGapSize(gapSize int) listOptions {
71 return func(m *model) {
132 return func(m *model) {
72 m.gapSize = gapSize
133 m.gapSize = gapSize
73 }
134 }
74}
135}
75
136
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.
76func WithPadding(padding ...int) listOptions {
140func WithPadding(padding ...int) listOptions {
77 return func(m *model) {
141 return func(m *model) {
78 m.padding = padding
142 m.padding = padding
79 }
143 }
80}
144}
81
145
146+// WithItems sets the initial items for the list.
82func WithItems(items []util.Model) listOptions {
147func WithItems(items []util.Model) listOptions {
83 return func(m *model) {
148 return func(m *model) {
84 m.items = items
149 m.items = items
85 }
150 }
86}
151}
87
152
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.
88func New(opts ...listOptions) ListModel {
156func New(opts ...listOptions) ListModel {
89 m := &model{
157 m := &model{
90− help: help.New(),
158+ help: help.New(),
91− keymap: defaultKeymap(),
159+ keymap: DefaultKeymap(),
92− items: []util.Model{},
160+ items: []util.Model{},
93− needsRerender: true,
161+ renderState: newRenderState(),
94− gapSize: 0,
162+ gapSize: DefaultGapSize,
95− padding: []int{},
163+ padding: []int{},
96− selectedItemInx: -1,
164+ selectionState: selectionState{selectedIndex: NoSelection},
97− finalHeight: -1,
98− lastRenderedInx: -1,
99− renderedItems: new(sync.Map),
100 }
165 }
101 for _, opt := range opts {
166 for _, opt := range opts {
102 opt(m)
167 opt(m)
@@ -104,591 +169,737 @@ func New(opts ...listOptions) ListModel {
104 return m
169 return m
105}
170}
106
171
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.
108func (m *model) Init() tea.Cmd {
174func (m *model) Init() tea.Cmd {
109− cmds := []tea.Cmd{
110− m.SetItems(m.items),
175+ return m.SetItems(m.items)
111− }
112− return tea.Batch(cmds...)
113}
176}
114
177
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.
116func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117− var cmds []tea.Cmd
118 switch msg := msg.(type) {
182 switch msg := msg.(type) {
119 case tea.KeyPressMsg:
183 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
184+ return m.handleKeyPress(msg)
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− }
162 case anim.ColorCycleMsg, anim.StepCharsMsg:
185 case anim.ColorCycleMsg, anim.StepCharsMsg:
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
163− for inx, item := range m.items {
224+ for inx, item := range m.items {
164− if i, ok := item.(HasAnim); ok {
225+ if i, ok := item.(HasAnim); ok && i.Spinning() {
165− if i.Spinning() {
166− updated, cmd := i.Update(msg)
226+ updated, cmd := i.Update(msg)
167− cmds = append(cmds, cmd)
227+ cmds = append(cmds, cmd)
228+ if u, ok := updated.(util.Model); ok {
168− m.UpdateItem(inx, updated.(util.Model))
229+ m.UpdateItem(inx, u)
169− }
170 }
230 }
171 }
231 }
172− return m, tea.Batch(cmds...)
173 }
232 }
174− if m.selectedItemInx > -1 {
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
175− u, cmd := m.items[m.selectedItemInx].Update(msg)
240+ u, cmd := m.items[m.selectionState.selectedIndex].Update(msg)
176− cmds = append(cmds, cmd)
241+ cmds = append(cmds, cmd)
177− m.UpdateItem(m.selectedItemInx, u.(util.Model))
242+ if updated, ok := u.(util.Model); ok {
243+ m.UpdateItem(m.selectionState.selectedIndex, updated)
244+ }
178− return m, tea.Batch(cmds...)
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)
179 }
255 }
256+}
180
257
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+ }
182}
266}
183
267
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.
185func (m *model) View() string {
271func (m *model) View() string {
186− if m.height == 0 || m.width == 0 {
272+ if m.viewState.height == 0 || m.viewState.width == 0 {
187 return ""
273 return ""
188 }
274 }
189− if m.needsRerender {
275+ if m.renderState.needsRerender {
190 m.renderVisible()
276 m.renderVisible()
191 }
277 }
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)
193}
279}
194
280
195−// Items implements ListModel.
281+// Items returns a copy of all items in the list.
196func (m *model) Items() []util.Model {
282func (m *model) Items() []util.Model {
197 return m.items
283 return m.items
198}
284}
199
285
200−func (m *model) renderVisibleReverse() {
286+// renderVisible determines which rendering strategy to use and triggers rendering.
201− start := 0
287+// Uses forward rendering for normal mode and reverse rendering for reverse mode.
202− cutoff := m.offset + m.listHeight()
288+func (m *model) renderVisible() {
203− items := m.items
289+ if m.viewState.reverse {
204− if m.lastRenderedInx > -1 {
290+ m.renderVisibleReverse()
205− items = m.items[:m.lastRenderedInx]
206− start = len(m.renderedLines)
207 } else {
291 } else {
208− // reveresed so that it starts at the end
292+ m.renderVisibleForward()
209− m.lastRenderedInx = len(m.items)
210− }
293+ }
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− }
294+}
231
295
232− if realIndex == 0 {
296+// renderVisibleForward renders items from top to bottom (normal mode).
233− m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
297+// Only renders items that are currently visible or near the viewport.
234− }
298+func (m *model) renderVisibleForward() {
235− m.renderedLines = append(itemLines, m.renderedLines...)
299+ renderer := &forwardRenderer{
236− m.lastRenderedInx = realIndex
300+ model: m,
237− // always render the next item
301+ start: 0,
238− if start > cutoff {
302+ cutoff: m.viewState.offset + m.listHeight(),
239− break
303+ items: m.items,
240− }
304+ realIdx: m.renderState.lastIndex,
241− start += len(itemLines)
242 }
305 }
243− m.needsRerender = false
306+
244− if m.finalHeight > -1 {
307+ if m.renderState.lastIndex > NotRendered {
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
308+ renderer.items = m.items[m.renderState.lastIndex+1:]
246− m.offset = min(m.offset, m.finalHeight)
309+ renderer.start = len(m.renderState.lines)
247 }
310 }
248− maxHeight := min(m.listHeight(), len(m.renderedLines))
311+
249− if m.offset < len(m.renderedLines) {
312+ renderer.render()
250− end := len(m.renderedLines) - m.offset
313+ m.finalizeRender()
251− start := max(0, end-maxHeight)
314+}
252− m.content = strings.Join(m.renderedLines[start:end], "\n")
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)
253 } else {
330 } else {
254− m.content = ""
331+ m.renderState.lastIndex = len(m.items)
332+ renderer.realIdx = len(m.items)
255 }
333 }
334+
335+ renderer.render()
336+ m.finalizeRender()
256}
337}
257
338
339+// finalizeRender completes the rendering process by updating scroll bounds and content.
258−func (m *model) renderVisible() {
340+func (m *model) finalizeRender() {
259− if m.reverse {
341+ m.renderState.needsRerender = false
260− m.renderVisibleReverse()
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 = ""
261 return
354 return
262 }
355 }
263− start := 0
356+
264− cutoff := m.offset + m.listHeight()
357+ if m.viewState.reverse {
265− items := m.items
358+ end := len(m.renderState.lines) - m.viewState.offset
266− if m.lastRenderedInx > -1 {
359+ start := max(0, end-maxHeight)
267− items = m.items[m.lastRenderedInx+1:]
360+ m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
268− start = len(m.renderedLines)
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")
269 }
364 }
365+}
270
366
271− realIndex := m.lastRenderedInx
367+// forwardRenderer handles rendering items from top to bottom.
272− for _, item := range items {
368+// It builds up the rendered content incrementally, caching results for performance.
273− realIndex++
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+}
274
376
275− var itemLines []string
377+// render processes items in forward order, building up the rendered content.
276− cachedContent, ok := m.renderedItems.Load(realIndex)
378+func (r *forwardRenderer) render() {
277− if ok {
379+ for _, item := range r.items {
278− itemLines = cachedContent.(renderedItem).lines
380+ r.realIdx++
279− } else {
381+ if r.start > r.cutoff {
280− itemLines = strings.Split(item.View(), "\n")
382+ break
281− if m.gapSize > 0 {
383+ }
282− for range m.gapSize {
384+
283− itemLines = append(itemLines, "")
385+ itemLines := r.getOrRenderItem(item)
284− }
386+ if r.realIdx == len(r.model.items)-1 {
285− }
387+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
286− m.renderedItems.Store(realIndex, renderedItem{
287− lines: itemLines,
288− start: start,
289− height: len(itemLines),
290− })
291 }
388 }
292− // always render the next item
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--
293− if start > cutoff {
430+ if r.start > r.cutoff {
294 break
431 break
295 }
432 }
296
433
297− if realIndex == len(m.items)-1 {
434+ itemLines := r.getOrRenderItem(r.items[i])
435+ if r.realIdx == 0 {
298− m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
436+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
299 }
437 }
300
438
301− m.renderedLines = append(m.renderedLines, itemLines...)
439+ r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
302− m.lastRenderedInx = realIndex
440+ r.model.renderState.lastIndex = r.realIdx
303− start += len(itemLines)
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
304 }
484 }
305− m.needsRerender = false
485+
306− maxHeight := min(m.listHeight(), len(m.renderedLines))
486+ cmds := []tea.Cmd{m.blurSelected()}
307− if m.finalHeight > -1 {
487+ m.selectionState.selectedIndex++
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
488+ cmds = append(cmds, m.focusSelected())
309− m.offset = min(m.offset, m.finalHeight)
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
310 }
500 }
311− if m.offset < len(m.renderedLines) {
501+
312− m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
502+ if m.viewState.reverse {
503+ m.ensureVisibleReverse(cachedItem)
313 } else {
504 } else {
314− m.content = ""
505+ m.ensureVisibleForward(cachedItem)
315 }
506 }
507+ m.renderState.needsRerender = true
316}
508}
317
509
318−func (m *model) upOneItem() tea.Cmd {
510+// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
319− var cmds []tea.Cmd
511+// Handles both large items (taller than viewport) and normal items.
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)
512+func (m *model) ensureVisibleForward(cachedItem renderedItem) {
333− // might not fit on the screen move the offset to the start of the item
334− if cachedItem.height >= m.listHeight() {
513+ if cachedItem.height >= m.listHeight() {
335− changeNeeded := m.offset - cachedItem.start
514+ if m.selectionState.selectedIndex > 0 {
336− m.decreaseOffset(changeNeeded)
337− }
338− if cachedItem.start < m.offset {
339− changeNeeded := m.offset - cachedItem.start
515+ changeNeeded := m.viewState.offset - cachedItem.start
340− m.decreaseOffset(changeNeeded)
516+ m.decreaseOffset(changeNeeded)
341− }
342 } else {
517 } else {
343− cachedItem, _ := cached.(renderedItem)
518+ changeNeeded := cachedItem.start - m.viewState.offset
344− // might not fit on the screen move the offset to the start of the item
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 {
345− if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
528+ end := cachedItem.start + cachedItem.height
529+ if end > m.viewState.offset+m.listHeight() {
346− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
530+ changeNeeded := end - (m.viewState.offset + m.listHeight())
347− m.increaseOffset(changeNeeded)
531+ m.increaseOffset(changeNeeded)
348− }
349 }
532 }
350 }
533 }
351− m.needsRerender = true
352− return tea.Batch(cmds...)
353}
534}
354
535
355−func (m *model) downOneItem() tea.Cmd {
536+// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
356− var cmds []tea.Cmd
537+// Handles both large items (taller than viewport) and normal items.
357− if m.selectedItemInx < len(m.items)-1 {
538+func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
358− cmd := m.blurSelected()
539+ if cachedItem.height >= m.listHeight() {
359− cmds = append(cmds, cmd)
540+ if m.selectionState.selectedIndex < len(m.items)-1 {
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
541+ changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
375− if end > m.offset+m.listHeight() {
376− changeNeeded := end - (m.offset + m.listHeight())
377− m.increaseOffset(changeNeeded)
542+ m.decreaseOffset(changeNeeded)
378− }
379− }
380 } else {
543 } 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())
544+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
385− m.decreaseOffset(changeNeeded)
386− } else {
387− if cachedItem.start < m.offset {
388− changeNeeded := m.offset - cachedItem.start
389− m.decreaseOffset(changeNeeded)
545+ m.increaseOffset(changeNeeded)
390− }
391− }
392 }
546 }
547+ return
393 }
548 }
394
549
395− m.needsRerender = true
550+ if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
396− return tea.Batch(cmds...)
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+ }
397}
557}
398
558
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.
399func (m *model) goToBottom() tea.Cmd {
561func (m *model) goToBottom() tea.Cmd {
400− if len(m.items) == 0 {
562+ cmds := []tea.Cmd{m.blurSelected()}
401− return nil
402− }
403− var cmds []tea.Cmd
404− m.reverse = true
563+ m.viewState.reverse = true
405− cmd := m.blurSelected()
564+ m.selectionState.selectedIndex = len(m.items) - 1
406− cmds = append(cmds, cmd)
407− m.selectedItemInx = len(m.items) - 1
408− cmd = m.focusSelected()
565+ cmds = append(cmds, m.focusSelected())
409− cmds = append(cmds, cmd)
410 m.ResetView()
566 m.ResetView()
411 return tea.Batch(cmds...)
567 return tea.Batch(cmds...)
412}
568}
413
569
414−func (m *model) ResetView() {
570+// goToTop switches to forward mode and selects the first item.
415− m.renderedItems.Clear()
571+// Standard behavior for most list interfaces.
416− m.renderedLines = []string{}
417− m.offset = 0
418− m.lastRenderedInx = -1
419− m.finalHeight = -1
420− m.needsRerender = true
421−}
422−
423func (m *model) goToTop() tea.Cmd {
572func (m *model) goToTop() tea.Cmd {
573+ cmds := []tea.Cmd{m.blurSelected()}
574+ m.viewState.reverse = false
424− if len(m.items) == 0 {
575+ if len(m.items) > 0 {
425− return nil
576+ m.selectionState.selectedIndex = 0
426 }
577 }
427− var cmds []tea.Cmd
428− m.reverse = false
429− cmd := m.blurSelected()
430− cmds = append(cmds, cmd)
578+ cmds = append(cmds, m.focusSelected())
431− m.selectedItemInx = 0
432− cmd = m.focusSelected()
433− cmds = append(cmds, cmd)
434 m.ResetView()
579 m.ResetView()
435 return tea.Batch(cmds...)
580 return tea.Batch(cmds...)
436}
581}
437
582
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.
438func (m *model) focusSelected() tea.Cmd {
592func (m *model) focusSelected() tea.Cmd {
439− if m.selectedItemInx == -1 {
593+ if !m.selectionState.isValidIndex(len(m.items)) {
440 return nil
594 return nil
441 }
595 }
442− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
596+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
443 cmd := i.Focus()
597 cmd := i.Focus()
444− m.rerenderItem(m.selectedItemInx)
598+ m.rerenderItem(m.selectionState.selectedIndex)
445 return cmd
599 return cmd
446 }
600 }
447 return nil
601 return nil
448}
602}
449
603
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.
450func (m *model) blurSelected() tea.Cmd {
606func (m *model) blurSelected() tea.Cmd {
451− if m.selectedItemInx == -1 {
607+ if !m.selectionState.isValidIndex(len(m.items)) {
452 return nil
608 return nil
453 }
609 }
454− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
610+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
455 cmd := i.Blur()
611 cmd := i.Blur()
456− m.rerenderItem(m.selectedItemInx)
612+ m.rerenderItem(m.selectionState.selectedIndex)
457 return cmd
613 return cmd
458 }
614 }
459 return nil
615 return nil
460}
616}
461
617
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.
462func (m *model) rerenderItem(inx int) {
621func (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 {
464 return
623 return
465 }
624 }
466− cached, ok := m.renderedItems.Load(inx)
625+
467− cachedItem, _ := cached.(renderedItem)
626+ cachedItem, ok := m.renderState.items[inx]
468 if !ok {
627 if !ok {
469− // No need to rerender
470 return
628 return
471 }
629 }
472− rerenderedItem := m.items[inx].View()
630+
473− rerenderedLines := strings.Split(rerenderedItem, "\n")
631+ rerenderedLines := m.getItemLines(m.items[inx])
474− if m.gapSize > 0 {
475− for range m.gapSize {
476− rerenderedLines = append(rerenderedLines, "")
477− }
478− }
479− // check if lines are the same
480 if slices.Equal(cachedItem.lines, rerenderedLines) {
632 if slices.Equal(cachedItem.lines, rerenderedLines) {
481− // No changes
482 return
633 return
483 }
634 }
484− // check if the item is in the content
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) {
485− start := cachedItem.start
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
486− end := start + cachedItem.height
667+ end = start + cachedItem.height
668+
669+ if m.viewState.reverse {
487− totalLines := len(m.renderedLines)
670+ totalLines := len(m.renderState.lines)
488− if m.reverse {
489 end = totalLines - cachedItem.start
671 end = totalLines - cachedItem.start
490 start = end - cachedItem.height
672 start = end - cachedItem.height
491 }
673 }
492− if start <= totalLines && end <= totalLines {
674+ return start, end
493− m.renderedLines = slices.Delete(m.renderedLines, start, end)
675+}
494− m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
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)
495 }
693 }
496− // TODO: if hight changed do something
694+}
497− if cachedItem.height != len(rerenderedLines) {
695+
498− if inx == len(m.items)-1 {
696+// updatePositionsForward updates positions for items after the changed item in forward mode.
499− m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
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
500 }
705 }
706+ }
707+}
501
708
502− // update the start of the other cached items
709+// updatePositionsReverse updates positions for items before the changed item in reverse mode.
503− currentStart := cachedItem.start + len(rerenderedLines)
710+func (m *model) updatePositionsReverse(inx int, currentStart int) {
504− if m.reverse {
505− for i := inx - 1; i < len(m.items); i-- {
711+ for i := inx - 1; i >= 0; i-- {
506− if existing, ok := m.renderedItems.Load(i); ok {
712+ if existing, ok := m.renderState.items[i]; ok {
507− cached := existing.(renderedItem)
508− cached.start = currentStart
713+ existing.start = currentStart
509− currentStart += cached.height
714+ currentStart += existing.height
510− m.renderedItems.Store(i, cached)
715+ m.renderState.items[i] = existing
511− } else {
512− break
513− }
514− }
515 } else {
716 } 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
717+ break
524− }
525− }
526 }
718 }
527 }
719 }
720+}
721+
722+// updateCachedItem updates the cached rendering information for a specific item.
723+func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
528− m.renderedItems.Store(inx, renderedItem{
724+ m.renderState.items[inx] = renderedItem{
529− lines: rerenderedLines,
725+ lines: newLines,
530 start: cachedItem.start,
726 start: cachedItem.start,
531− height: len(rerenderedLines),
727+ height: len(newLines),
532− })
728+ }
533− m.needsRerender = true
534}
729}
535
730
731+// increaseOffset scrolls the list down by increasing the offset.
732+// Respects the final height limit to prevent scrolling past the end.
536func (m *model) increaseOffset(n int) {
733func (m *model) increaseOffset(n int) {
537− if m.finalHeight > -1 {
734+ if m.renderState.finalHeight > NoFinalHeight {
538− if m.offset < m.finalHeight {
735+ if m.viewState.offset < m.renderState.finalHeight {
539− m.offset += n
736+ m.viewState.offset += n
540− if m.offset > m.finalHeight {
737+ if m.viewState.offset > m.renderState.finalHeight {
541− m.offset = m.finalHeight
738+ m.viewState.offset = m.renderState.finalHeight
542 }
739 }
543− m.needsRerender = true
740+ m.renderState.needsRerender = true
544 }
741 }
545 } else {
742 } else {
546− m.offset += n
743+ m.viewState.offset += n
547− m.needsRerender = true
744+ m.renderState.needsRerender = true
548 }
745 }
549}
746}
550
747
748+// decreaseOffset scrolls the list up by decreasing the offset.
749+// Prevents scrolling above the beginning of the list.
551func (m *model) decreaseOffset(n int) {
750func (m *model) decreaseOffset(n int) {
552− if m.offset > 0 {
751+ if m.viewState.offset > 0 {
553− m.offset -= n
752+ m.viewState.offset -= n
554− if m.offset < 0 {
753+ if m.viewState.offset < 0 {
555− m.offset = 0
754+ m.viewState.offset = 0
556 }
755 }
557− m.needsRerender = true
756+ m.renderState.needsRerender = true
558 }
757 }
559}
758}
560
759
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.
562func (m *model) UpdateItem(inx int, item util.Model) {
762func (m *model) UpdateItem(inx int, item util.Model) {
763+ if inx < 0 || inx >= len(m.items) {
764+ return
765+ }
563 m.items[inx] = item
766 m.items[inx] = item
564− if m.selectedItemInx == inx {
767+ if m.selectionState.selectedIndex == inx {
565− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
768+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
566 i.Focus()
769 i.Focus()
567 }
770 }
568 }
771 }
569 m.setItemSize(inx)
772 m.setItemSize(inx)
570 m.rerenderItem(inx)
773 m.rerenderItem(inx)
571− m.needsRerender = true
774+ m.renderState.needsRerender = true
572}
775}
573
776
574−// GetSize implements List.
777+// GetSize returns the current dimensions of the list.
575func (m *model) GetSize() (int, int) {
778func (m *model) GetSize() (int, int) {
576− return m.width, m.height
779+ return m.viewState.width, m.viewState.height
577}
780}
578
781
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.
580func (m *model) SetSize(width int, height int) tea.Cmd {
784func (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 {
582 return nil
786 return nil
583 }
787 }
584− if m.height != height {
788+ if m.viewState.height != height {
585− m.finalHeight = -1
789+ m.renderState.finalHeight = NoFinalHeight
586− m.height = height
790+ m.viewState.height = height
587 }
791 }
588− m.width = width
792+ m.viewState.width = width
589 m.ResetView()
793 m.ResetView()
590 return m.setAllItemsSize()
794 return m.setAllItemsSize()
591}
795}
592
796
797+// getItemSize calculates the available width for items, accounting for padding.
593func (m *model) getItemSize() int {
798func (m *model) getItemSize() int {
594− width := m.width
799+ width := m.viewState.width
595− if m.padding != nil {
596− if len(m.padding) == 1 {
800+ switch len(m.padding) {
801+ case 1:
597− width -= m.padding[0] * 2
802+ width -= m.padding[0] * 2
598− } else if len(m.padding) == 2 || len(m.padding) == 3 {
803+ case 2, 3:
599− width -= m.padding[1] * 2
804+ width -= m.padding[1] * 2
600− } else if len(m.padding) == 4 {
805+ case 4:
601− width -= m.padding[1] + m.padding[3]
806+ width -= m.padding[1] + m.padding[3]
602− }
603 }
807 }
604− return width
808+ return max(0, width)
605}
809}
606
810
811+// setItemSize updates the size of a specific item if it supports sizing.
607func (m *model) setItemSize(inx int) tea.Cmd {
812func (m *model) setItemSize(inx int) tea.Cmd {
813+ if inx < 0 || inx >= len(m.items) {
814+ return nil
815+ }
608 if i, ok := m.items[inx].(layout.Sizeable); ok {
816 if i, ok := m.items[inx].(layout.Sizeable); ok {
609− cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
817+ return i.SetSize(m.getItemSize(), 0)
610− return cmd
611 }
818 }
612 return nil
819 return nil
613}
820}
614
821
822+// setAllItemsSize updates the size of all items that support sizing.
615func (m *model) setAllItemsSize() tea.Cmd {
823func (m *model) setAllItemsSize() tea.Cmd {
616 var cmds []tea.Cmd
824 var cmds []tea.Cmd
617 for i := range m.items {
825 for i := range m.items {
618− cmd := m.setItemSize(i)
826+ if cmd := m.setItemSize(i); cmd != nil {
619− cmds = append(cmds, cmd)
827+ cmds = append(cmds, cmd)
828+ }
620 }
829 }
621 return tea.Batch(cmds...)
830 return tea.Batch(cmds...)
622}
831}
623
832
833+// listHeight calculates the available height for list content, accounting for padding.
624func (m *model) listHeight() int {
834func (m *model) listHeight() int {
625− height := m.height
835+ height := m.viewState.height
626− if m.padding != nil {
627− if len(m.padding) == 1 {
836+ switch len(m.padding) {
837+ case 1:
628− height -= m.padding[0] * 2
838+ height -= m.padding[0] * 2
629− } else if len(m.padding) == 2 {
839+ case 2:
630− height -= m.padding[1] * 2
840+ height -= m.padding[0] * 2
631− } else if len(m.padding) == 3 {
841+ case 3, 4:
632− height -= m.padding[0] + m.padding[2]
633− } else if len(m.padding) == 4 {
634− height -= m.padding[0] + m.padding[2]
842+ height -= m.padding[0] + m.padding[2]
635− }
636 }
843 }
637− return height
844+ return max(0, height)
638}
845}
639
846
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.
641func (m *model) AppendItem(item util.Model) tea.Cmd {
849func (m *model) AppendItem(item util.Model) tea.Cmd {
642− var cmds []tea.Cmd
850+ cmds := []tea.Cmd{
643− cmd := item.Init()
851+ item.Init(),
644− cmds = append(cmds, cmd)
852+ }
645 m.items = append(m.items, item)
853 m.items = append(m.items, item)
646− cmd = m.setItemSize(len(m.items) - 1)
854+ cmds = append(cmds, m.setItemSize(len(m.items)-1))
647− cmds = append(cmds, cmd)
855+ cmds = append(cmds, m.goToBottom())
648− cmd = m.goToBottom()
649− cmds = append(cmds, cmd)
650− m.needsRerender = true
856+ m.renderState.needsRerender = true
651 return tea.Batch(cmds...)
857 return tea.Batch(cmds...)
652}
858}
653
859
654−// DeleteItem implements List.
860+// DeleteItem removes an item at the specified index.
861+// Adjusts selection if necessary and triggers a complete re-render.
655func (m *model) DeleteItem(i int) {
862func (m *model) DeleteItem(i int) {
863+ if i < 0 || i >= len(m.items) {
864+ return
865+ }
656 m.items = slices.Delete(m.items, i, i+1)
866 m.items = slices.Delete(m.items, i, i+1)
657− m.renderedItems.Delete(i)
867+ delete(m.renderState.items, i)
658− if m.selectedItemInx == i {
868+
659− m.selectedItemInx--
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--
660 }
873 }
874+
661 m.ResetView()
875 m.ResetView()
662− m.needsRerender = true
876+ m.renderState.needsRerender = true
663}
877}
664
878
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.
666func (m *model) PrependItem(item util.Model) tea.Cmd {
881func (m *model) PrependItem(item util.Model) tea.Cmd {
667− var cmds []tea.Cmd
668− cmd := item.Init()
882+ cmds := []tea.Cmd{item.Init()}
669− cmds = append(cmds, cmd)
670 m.items = append([]util.Model{item}, m.items...)
883 m.items = append([]util.Model{item}, m.items...)
671− // update the indices of the rendered items
884+
885+ // Shift all cached item indices by 1
672− newRenderedItems := make(map[int]renderedItem)
886+ newItems := make(map[int]renderedItem, len(m.renderState.items))
673− m.renderedItems.Range(func(key any, value any) bool {
887+ for k, v := range m.renderState.items {
674− keyInt := key.(int)
888+ newItems[k+1] = v
675− renderedItem := value.(renderedItem)
889+ }
676− newKey := keyInt + 1
890+ m.renderState.items = newItems
677− newRenderedItems[newKey] = renderedItem
891+
678− return false
892+ if m.selectionState.selectedIndex >= 0 {
679− })
893+ m.selectionState.selectedIndex++
680− m.renderedItems.Clear()
681− for k, v := range newRenderedItems {
682− m.renderedItems.Store(k, v)
683− }
894+ }
895+
684− cmd = m.goToTop()
896+ cmds = append(cmds, m.goToTop())
685− cmds = append(cmds, cmd)
897+ cmds = append(cmds, m.setItemSize(0))
686− cmd = m.setItemSize(0)
687− cmds = append(cmds, cmd)
688− m.needsRerender = true
898+ m.renderState.needsRerender = true
689 return tea.Batch(cmds...)
899 return tea.Batch(cmds...)
690}
900}
691
901
902+// setReverse switches between forward and reverse rendering modes.
692func (m *model) setReverse(reverse bool) {
903func (m *model) setReverse(reverse bool) {
693 if reverse {
904 if reverse {
694 m.goToBottom()
905 m.goToBottom()
@@ -697,24 +908,29 @@ func (m *model) setReverse(reverse bool) {
697 }
908 }
698}
909}
699
910
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.
701func (m *model) SetItems(items []util.Model) tea.Cmd {
913func (m *model) SetItems(items []util.Model) tea.Cmd {
702 m.items = items
914 m.items = items
703− var cmds []tea.Cmd
704− cmd := m.setAllItemsSize()
915+ cmds := []tea.Cmd{m.setAllItemsSize()}
705− cmds = append(cmds, cmd)
916+
706 for _, item := range m.items {
917 for _, item := range m.items {
707 cmds = append(cmds, item.Init())
918 cmds = append(cmds, item.Init())
708 }
919 }
920+
921+ if len(m.items) > 0 {
709− if m.reverse {
922+ if m.viewState.reverse {
710− m.selectedItemInx = len(m.items) - 1
923+ m.selectionState.selectedIndex = len(m.items) - 1
924+ } else {
925+ m.selectionState.selectedIndex = 0
926+ }
711− cmd := m.focusSelected()
927+ if cmd := m.focusSelected(); cmd != nil {
712− cmds = append(cmds, cmd)
928+ cmds = append(cmds, cmd)
929+ }
713 } else {
930 } else {
714− m.selectedItemInx = 0
931+ m.selectionState.selectedIndex = NoSelection
715− cmd := m.focusSelected()
716− cmds = append(cmds, cmd)
717 }
932 }
933+
718 m.ResetView()
934 m.ResetView()
719 return tea.Batch(cmds...)
935 return tea.Batch(cmds...)
720}
936}