on main help sign in
implement tool calls in the ui
commit288325689ea359b22d83587cb0e8f82b7505d398 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 23 May 2025, 14:04 (1 year ago) parent4b5ea74
11 files changed+269−163
.editorconfig+1−1 internal/llm/agent/agent.go+5−9 internal/llm/prompt/coder.go+2−2 internal/llm/provider/anthropic.go+1−1 internal/tui/components/anim/anim.go+26−12 internal/tui/components/chat/list_v2.go+67−17 internal/tui/components/chat/messages/messages.go+10−3 internal/tui/components/chat/messages/renderer.go−29 internal/tui/components/chat/messages/tool.go+70−37 internal/tui/components/core/list/list.go+87−51 internal/tui/tui.go−1
.editorconfig+1−1
@@ -11,7 +11,7 @@ indent_size = 2
1111
1212[*.go]
1313indent_style = tab
14−indent_size = 8
14+indent_size = 4
1515
1616[*.golden]
1717insert_final_newline = false
internal/llm/agent/agent.go+5−9
@@ -443,18 +443,14 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
443443 assistantMsg.AppendContent(event.Content)
444444 return a.messages.Update(ctx, *assistantMsg)
445445 case provider.EventToolUseStart:
446+ logging.Info("Tool call started", "toolCall", event.ToolCall)
446447 assistantMsg.AddToolCall(*event.ToolCall)
447448 return a.messages.Update(ctx, *assistantMsg)
448− // TODO: see how to handle this
449− // case provider.EventToolUseDelta:
450− // tm := time.Unix(assistantMsg.UpdatedAt, 0)
451− // assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
452− // if time.Since(tm) > 1000*time.Millisecond {
453− // err := a.messages.Update(ctx, *assistantMsg)
454− // assistantMsg.UpdatedAt = time.Now().Unix()
455− // return err
456− // }
449+ case provider.EventToolUseDelta:
450+ assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
451+ return a.messages.Update(ctx, *assistantMsg)
457452 case provider.EventToolUseStop:
453+ logging.Info("Finished tool call", "toolCall", event.ToolCall)
458454 assistantMsg.FinishToolCall(event.ToolCall.ID)
459455 return a.messages.Update(ctx, *assistantMsg)
460456 case provider.EventError:
internal/llm/prompt/coder.go+2−2
@@ -153,7 +153,7 @@ When making changes to files, first understand the file's code conventions. Mimi
153153
154154# Doing tasks
155155The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
156−1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
156+1. Use the available search tools to understand the codebase and the user's query.
1571572. Implement the solution using all tools available to you
1581583. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
1591594. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time.
@@ -162,7 +162,7 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
162162
163163# Tool usage policy
164164- When doing file search, prefer to use the Agent tool in order to reduce context usage.
165−- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
165+- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
166166- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
167167
168168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
internal/llm/provider/anthropic.go+1−1
@@ -305,7 +305,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
305305 ToolCall: &message.ToolCall{
306306 ID: currentToolCallID,
307307 Finished: false,
308− Input: event.Delta.JSON.PartialJSON.Raw(),
308+ Input: event.Delta.PartialJSON,
309309 },
310310 }
311311 }
internal/tui/components/anim/anim.go+26−12
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/bubbles/v2/spinner"
1010 tea "github.com/charmbracelet/bubbletea/v2"
1111 "github.com/charmbracelet/lipgloss/v2"
12+ "github.com/google/uuid"
1213 "github.com/lucasb-eyer/go-colorful"
1314 "github.com/opencode-ai/opencode/internal/tui/styles"
1415 "github.com/opencode-ai/opencode/internal/tui/theme"
@@ -54,19 +55,23 @@ func (c cyclingChar) state(start time.Time) charState {
5455 return charCyclingState
5556}
5657
57−type StepCharsMsg struct{}
58+type StepCharsMsg struct {
59+ id string
60+}
5861
59−func stepChars() tea.Cmd {
62+func stepChars(id string) tea.Cmd {
6063 return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
61− return StepCharsMsg{}
64+ return StepCharsMsg{id}
6265 })
6366}
6467
65−type ColorCycleMsg struct{}
68+type ColorCycleMsg struct {
69+ id string
70+}
6671
67−func cycleColors() tea.Cmd {
72+func cycleColors(id string) tea.Cmd {
6873 return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
69− return ColorCycleMsg{}
74+ return ColorCycleMsg{id}
7075 })
7176}
7277
@@ -80,6 +85,7 @@ type anim struct {
8085 label []rune
8186 ellipsis spinner.Model
8287 ellipsisStarted bool
88+ id string
8389}
8490
8591func New(cyclingCharsSize uint, label string) util.Model {
@@ -91,10 +97,12 @@ func New(cyclingCharsSize uint, label string) util.Model {
9197 gap = ""
9298 }
9399
100+ id := uuid.New()
94101 c := anim{
95102 start: time.Now(),
96103 label: []rune(gap + label),
97104 ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
105+ id: id.String(),
98106 }
99107
100108 // If we're in truecolor mode (and there are enough cycling characters)
@@ -144,15 +152,18 @@ func New(cyclingCharsSize uint, label string) util.Model {
144152}
145153
146154// Init initializes the animation.
147−func (anim) Init() tea.Cmd {
148− return tea.Batch(stepChars(), cycleColors())
155+func (a anim) Init() tea.Cmd {
156+ return tea.Batch(stepChars(a.id), cycleColors(a.id))
149157}
150158
151159// Update handles messages.
152160func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153161 var cmd tea.Cmd
154− switch msg.(type) {
162+ switch msg := msg.(type) {
155163 case StepCharsMsg:
164+ if msg.id != a.id {
165+ return a, nil
166+ }
156167 a.updateChars(&a.cyclingChars)
157168 a.updateChars(&a.labelChars)
158169
@@ -173,14 +184,17 @@ func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
173184 }
174185 }
175186
176− return a, tea.Batch(stepChars(), cmd)
187+ return a, tea.Batch(stepChars(a.id), cmd)
177188 case ColorCycleMsg:
189+ if msg.id != a.id {
190+ return a, nil
191+ }
178192 const minColorCycleSize = 2
179193 if len(a.ramp) < minColorCycleSize {
180194 return a, nil
181195 }
182196 a.ramp = append(a.ramp[1:], a.ramp[0])
183− return a, cycleColors()
197+ return a, cycleColors(a.id)
184198 case spinner.TickMsg:
185199 var cmd tea.Cmd
186200 a.ellipsis, cmd = a.ellipsis.Update(msg)
@@ -216,7 +230,7 @@ func (a anim) View() string {
216230 b.WriteRune(c.currentValue)
217231 }
218232
219− if len(a.label) > 1 {
233+ if len(a.labelChars) > 1 {
220234 textStyle := styles.BaseStyle().
221235 Foreground(t.Text())
222236 for _, c := range a.labelChars {
internal/tui/components/chat/list_v2.go+67−17
@@ -7,6 +7,7 @@ import (
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/charmbracelet/lipgloss/v2"
99 "github.com/opencode-ai/opencode/internal/app"
10+ "github.com/opencode-ai/opencode/internal/logging"
1011 "github.com/opencode-ai/opencode/internal/message"
1112 "github.com/opencode-ai/opencode/internal/pubsub"
1213 "github.com/opencode-ai/opencode/internal/session"
@@ -61,7 +62,8 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6162 return m, m.listCmp.SetItems([]util.Model{})
6263
6364 case pubsub.Event[message.Message]:
64− return m, m.handleMessageEvent(msg)
65+ cmd := m.handleMessageEvent(msg)
66+ return m, cmd
6567 default:
6668 var cmds []tea.Cmd
6769 u, cmd := m.listCmp.Update(msg)
@@ -92,8 +94,8 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
9294 // more likely to be at the end of the list
9395 items := m.listCmp.Items()
9496 for i := len(items) - 1; i >= 0; i-- {
95− msg := items[i].(messages.MessageCmp)
96− if msg.GetMessage().ID == event.Payload.ID {
97+ msg, ok := items[i].(messages.MessageCmp)
98+ if ok && msg.GetMessage().ID == event.Payload.ID {
9799 messageExists = true
98100 break
99101 }
@@ -109,7 +111,6 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
109111 case message.Tool:
110112 return m.handleToolMessage(event.Payload)
111113 }
112− // TODO: handle tools
113114 case pubsub.UpdatedEvent:
114115 return m.handleUpdateAssistantMessage(event.Payload)
115116 }
@@ -122,30 +123,79 @@ func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
122123}
123124
124125func (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+ }
125142 return nil
126143}
127144
128145func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
146+ var cmds []tea.Cmd
129147 // Simple update the content
130148 items := m.listCmp.Items()
131− lastItem := items[len(items)-1].(messages.MessageCmp)
132− // TODO:handle tool calls
133− if lastItem.GetMessage().ID != msg.ID {
134− return nil
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+ }
135164 }
136− // for now just updet the last message
137− if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
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()) {
138169 m.listCmp.UpdateItem(
139− len(items)-1,
170+ assistantMessageInx,
140171 messages.NewMessageCmp(
141172 msg,
142173 messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
143174 ),
144175 )
145− } else if len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
146− m.listCmp.DeleteItem(len(items) - 1)
176+ } else if assistantMessageInx > -1 && len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
177+ m.listCmp.DeleteItem(assistantMessageInx)
147178 }
148− return nil
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...)
149199}
150200
151201func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
@@ -161,7 +211,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
161211 cmds = append(cmds, cmd)
162212 }
163213 for _, tc := range msg.ToolCalls() {
164− cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(tc))
214+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
165215 cmds = append(cmds, cmd)
166216 }
167217 return tea.Batch(cmds...)
@@ -206,10 +256,10 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
206256 if tr, ok := toolResultMap[tc.ID]; ok {
207257 options = append(options, messages.WithToolCallResult(tr))
208258 }
209− if msg.FinishPart().Reason == message.FinishReasonCanceled {
259+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
210260 options = append(options, messages.WithToolCallCancelled())
211261 }
212− uiMessages = append(uiMessages, messages.NewToolCallCmp(tc, options...))
262+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
213263 }
214264 }
215265 }
internal/tui/components/chat/messages/messages.go+10−3
@@ -65,9 +65,16 @@ func (m *messageCmp) Init() tea.Cmd {
6565}
6666
6767func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
68− u, cmd := m.anim.Update(msg)
69− m.anim = u.(util.Model)
70− return m, cmd
68+ switch msg := msg.(type) {
69+ case anim.ColorCycleMsg, anim.StepCharsMsg:
70+ m.spinning = m.shouldSpin()
71+ if m.spinning {
72+ u, cmd := m.anim.Update(msg)
73+ m.anim = u.(util.Model)
74+ return m, cmd
75+ }
76+ }
77+ return m, nil
7178}
7279
7380func (m *messageCmp) View() string {
internal/tui/components/chat/messages/renderer.go−29
@@ -524,32 +524,3 @@ func prettifyToolName(name string) string {
524524 return name
525525 }
526526}
527−
528−func toolAction(name string) string {
529− switch name {
530− case agent.AgentToolName:
531− return "Preparing prompt..."
532− case tools.BashToolName:
533− return "Building command..."
534− case tools.EditToolName:
535− return "Preparing edit..."
536− case tools.FetchToolName:
537− return "Writing fetch..."
538− case tools.GlobToolName:
539− return "Finding files..."
540− case tools.GrepToolName:
541− return "Searching content..."
542− case tools.LSToolName:
543− return "Listing directory..."
544− case tools.SourcegraphToolName:
545− return "Searching code..."
546− case tools.ViewToolName:
547− return "Reading file..."
548− case tools.WriteToolName:
549− return "Preparing write..."
550− case tools.PatchToolName:
551− return "Preparing patch..."
552− default:
553− return "Working..."
554− }
555−}
internal/tui/components/chat/messages/tool.go+70−37
@@ -6,9 +6,9 @@ import (
66 tea "github.com/charmbracelet/bubbletea/v2"
77 "github.com/charmbracelet/lipgloss/v2"
88 "github.com/charmbracelet/x/ansi"
9− "github.com/opencode-ai/opencode/internal/llm/agent"
10− "github.com/opencode-ai/opencode/internal/llm/tools"
9+ "github.com/opencode-ai/opencode/internal/logging"
1110 "github.com/opencode-ai/opencode/internal/message"
11+ "github.com/opencode-ai/opencode/internal/tui/components/anim"
1212 "github.com/opencode-ai/opencode/internal/tui/layout"
1313 "github.com/opencode-ai/opencode/internal/tui/styles"
1414 "github.com/opencode-ai/opencode/internal/tui/theme"
@@ -21,15 +21,24 @@ type ToolCallCmp interface {
2121 layout.Focusable
2222 GetToolCall() message.ToolCall
2323 GetToolResult() message.ToolResult
24+ SetToolResult(message.ToolResult)
25+ SetToolCall(message.ToolCall)
26+ SetCancelled()
27+ ParentMessageId() string
28+ Spinning() bool
2429}
2530
2631type toolCallCmp struct {
2732 width int
2833 focused bool
2934
30− call message.ToolCall
31− result message.ToolResult
32− cancelled bool
35+ parentMessageId string
36+ call message.ToolCall
37+ result message.ToolResult
38+ cancelled bool
39+
40+ spinning bool
41+ anim util.Model
3342}
3443
3544type ToolCallOption func(*toolCallCmp)
@@ -46,9 +55,11 @@ func WithToolCallResult(result message.ToolResult) ToolCallOption {
4655 }
4756}
4857
49−func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
58+func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
5059 m := &toolCallCmp{
51− call: tc,
60+ call: tc,
61+ parentMessageId: parentMessageId,
62+ anim: anim.New(15, "Working"),
5263 }
5364 for _, opt := range opts {
5465 opt(m)
@@ -57,10 +68,24 @@ func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
5768}
5869
5970func (m *toolCallCmp) Init() tea.Cmd {
71+ m.spinning = m.shouldSpin()
72+ logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
73+ if m.spinning {
74+ return m.anim.Init()
75+ }
6076 return nil
6177}
6278
6379func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80+ logging.Debug("Tool call update", "msg", msg)
81+ switch msg := msg.(type) {
82+ case anim.ColorCycleMsg, anim.StepCharsMsg:
83+ if m.spinning {
84+ u, cmd := m.anim.Update(msg)
85+ m.anim = u.(util.Model)
86+ return m, cmd
87+ }
88+ }
6489 return m, nil
6590}
6691
@@ -75,6 +100,30 @@ func (m *toolCallCmp) View() string {
75100 return box.PaddingLeft(1).Render(r.Render(m))
76101}
77102
103+// SetCancelled implements ToolCallCmp.
104+func (m *toolCallCmp) SetCancelled() {
105+ m.cancelled = true
106+}
107+
108+// SetToolCall implements ToolCallCmp.
109+func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
110+ m.call = call
111+ if m.call.Finished {
112+ m.spinning = false
113+ }
114+}
115+
116+// ParentMessageId implements ToolCallCmp.
117+func (m *toolCallCmp) ParentMessageId() string {
118+ return m.parentMessageId
119+}
120+
121+// SetToolResult implements ToolCallCmp.
122+func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
123+ m.result = result
124+ m.spinning = false
125+}
126+
78127// GetToolCall implements ToolCallCmp.
79128func (m *toolCallCmp) GetToolCall() message.ToolCall {
80129 return m.call
@@ -86,7 +135,7 @@ func (m *toolCallCmp) GetToolResult() message.ToolResult {
86135}
87136
88137func (m *toolCallCmp) renderPending() string {
89− return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), toolAction(m.call.Name))
138+ return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
90139}
91140
92141func (m *toolCallCmp) style() lipgloss.Style {
@@ -113,35 +162,6 @@ func (m *toolCallCmp) fit(content string, width int) string {
113162 return ansi.Truncate(content, width, dots)
114163}
115164
116−func (m *toolCallCmp) toolName() string {
117− switch m.call.Name {
118− case agent.AgentToolName:
119− return "Task"
120− case tools.BashToolName:
121− return "Bash"
122− case tools.EditToolName:
123− return "Edit"
124− case tools.FetchToolName:
125− return "Fetch"
126− case tools.GlobToolName:
127− return "Glob"
128− case tools.GrepToolName:
129− return "Grep"
130− case tools.LSToolName:
131− return "List"
132− case tools.SourcegraphToolName:
133− return "Sourcegraph"
134− case tools.ViewToolName:
135− return "View"
136− case tools.WriteToolName:
137− return "Write"
138− case tools.PatchToolName:
139− return "Patch"
140− default:
141− return m.call.Name
142− }
143−}
144−
145165func (m *toolCallCmp) Blur() tea.Cmd {
146166 m.focused = false
147167 return nil
@@ -165,3 +185,16 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
165185 m.width = width
166186 return nil
167187}
188+
189+func (m *toolCallCmp) shouldSpin() bool {
190+ if !m.call.Finished {
191+ return true
192+ } else if m.result.ToolCallID != m.call.ID {
193+ return true
194+ }
195+ return false
196+}
197+
198+func (m *toolCallCmp) Spinning() bool {
199+ return m.spinning
200+}
internal/tui/components/core/list/list.go+87−51
@@ -9,7 +9,6 @@ import (
99 "github.com/charmbracelet/bubbles/v2/key"
1010 tea "github.com/charmbracelet/bubbletea/v2"
1111 "github.com/charmbracelet/lipgloss/v2"
12− "github.com/opencode-ai/opencode/internal/logging"
1312 "github.com/opencode-ai/opencode/internal/tui/components/anim"
1413 "github.com/opencode-ai/opencode/internal/tui/layout"
1514 "github.com/opencode-ai/opencode/internal/tui/util"
@@ -39,7 +38,7 @@ type renderedItem struct {
3938}
4039type model struct {
4140 width, height, offset int
42− finalHight int // this gets set when the last item is rendered to mark the max offset
41+ finalHeight int // this gets set when the last item is rendered to mark the max offset
4342 reverse bool
4443 help help.Model
4544 keymap KeyMap
@@ -95,7 +94,7 @@ func New(opts ...listOptions) ListModel {
9594 gapSize: 0,
9695 padding: []int{},
9796 selectedItemInx: -1,
98− finalHight: -1,
97+ finalHeight: -1,
9998 lastRenderedInx: -1,
10099 renderedItems: new(sync.Map),
101100 }
@@ -160,20 +159,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160159 m.goToBottom()
161160 return m, nil
162161 }
163− case anim.ColorCycleMsg:
164− logging.Info("ColorCycleMsg", "msg", msg)
165− for inx, item := range m.items {
166− if i, ok := item.(HasAnim); ok {
167− if i.Spinning() {
168− updated, cmd := i.Update(msg)
169− cmds = append(cmds, cmd)
170− m.UpdateItem(inx, updated.(util.Model))
171− }
172− }
173− }
174− return m, tea.Batch(cmds...)
175− case anim.StepCharsMsg:
176− logging.Info("ColorCycleMsg", "msg", msg)
162+ case anim.ColorCycleMsg, anim.StepCharsMsg:
177163 for inx, item := range m.items {
178164 if i, ok := item.(HasAnim); ok {
179165 if i.Spinning() {
@@ -189,7 +175,6 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
189175 u, cmd := m.items[m.selectedItemInx].Update(msg)
190176 cmds = append(cmds, cmd)
191177 m.UpdateItem(m.selectedItemInx, u.(util.Model))
192− cmds = append(cmds, cmd)
193178 return m, tea.Batch(cmds...)
194179 }
195180
@@ -232,7 +217,7 @@ func (m *model) renderVisibleReverse() {
232217 itemLines = cachedContent.(renderedItem).lines
233218 } else {
234219 itemLines = strings.Split(items[i].View(), "\n")
235− if m.gapSize > 0 && realIndex != len(m.items)-1 {
220+ if m.gapSize > 0 {
236221 for range m.gapSize {
237222 itemLines = append(itemLines, "")
238223 }
@@ -245,7 +230,7 @@ func (m *model) renderVisibleReverse() {
245230 }
246231
247232 if realIndex == 0 {
248− m.finalHight = max(0, start+len(itemLines)-m.listHeight())
233+ m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
249234 }
250235 m.renderedLines = append(itemLines, m.renderedLines...)
251236 m.lastRenderedInx = realIndex
@@ -256,9 +241,9 @@ func (m *model) renderVisibleReverse() {
256241 start += len(itemLines)
257242 }
258243 m.needsRerender = false
259− if m.finalHight > -1 {
244+ if m.finalHeight > -1 {
260245 // 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
261− m.offset = min(m.offset, m.finalHight)
246+ m.offset = min(m.offset, m.finalHeight)
262247 }
263248 maxHeight := min(m.listHeight(), len(m.renderedLines))
264249 if m.offset < len(m.renderedLines) {
@@ -293,7 +278,7 @@ func (m *model) renderVisible() {
293278 itemLines = cachedContent.(renderedItem).lines
294279 } else {
295280 itemLines = strings.Split(item.View(), "\n")
296− if m.gapSize > 0 && realIndex != len(m.items)-1 {
281+ if m.gapSize > 0 {
297282 for range m.gapSize {
298283 itemLines = append(itemLines, "")
299284 }
@@ -310,7 +295,7 @@ func (m *model) renderVisible() {
310295 }
311296
312297 if realIndex == len(m.items)-1 {
313− m.finalHight = max(0, start+len(itemLines)-m.listHeight())
298+ m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
314299 }
315300
316301 m.renderedLines = append(m.renderedLines, itemLines...)
@@ -319,9 +304,9 @@ func (m *model) renderVisible() {
319304 }
320305 m.needsRerender = false
321306 maxHeight := min(m.listHeight(), len(m.renderedLines))
322− if m.finalHight > -1 {
307+ if m.finalHeight > -1 {
323308 // 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
324− m.offset = min(m.offset, m.finalHight)
309+ m.offset = min(m.offset, m.finalHeight)
325310 }
326311 if m.offset < len(m.renderedLines) {
327312 m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
@@ -412,6 +397,9 @@ func (m *model) downOneItem() tea.Cmd {
412397}
413398
414399func (m *model) goToBottom() tea.Cmd {
400+ if len(m.items) == 0 {
401+ return nil
402+ }
415403 var cmds []tea.Cmd
416404 m.reverse = true
417405 cmd := m.blurSelected()
@@ -428,11 +416,14 @@ func (m *model) ResetView() {
428416 m.renderedLines = []string{}
429417 m.offset = 0
430418 m.lastRenderedInx = -1
431− m.finalHight = -1
419+ m.finalHeight = -1
432420 m.needsRerender = true
433421}
434422
435423func (m *model) goToTop() tea.Cmd {
424+ if len(m.items) == 0 {
425+ return nil
426+ }
436427 var cmds []tea.Cmd
437428 m.reverse = false
438429 cmd := m.blurSelected()
@@ -480,7 +471,7 @@ func (m *model) rerenderItem(inx int) {
480471 }
481472 rerenderedItem := m.items[inx].View()
482473 rerenderedLines := strings.Split(rerenderedItem, "\n")
483− if m.gapSize > 0 && inx != len(m.items)-1 {
474+ if m.gapSize > 0 {
484475 for range m.gapSize {
485476 rerenderedLines = append(rerenderedLines, "")
486477 }
@@ -492,7 +483,6 @@ func (m *model) rerenderItem(inx int) {
492483 }
493484 // check if the item is in the content
494485 start := cachedItem.start
495− logging.Info("rerenderItem", "inx", inx, "start", start, "cachedItem.start", cachedItem.start, "cachedItem.height", cachedItem.height)
496486 end := start + cachedItem.height
497487 totalLines := len(m.renderedLines)
498488 if m.reverse {
@@ -504,9 +494,35 @@ func (m *model) rerenderItem(inx int) {
504494 m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
505495 }
506496 // TODO: if hight changed do something
507− if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
497+ if cachedItem.height != len(rerenderedLines) {
508498 if inx == len(m.items)-1 {
509− m.finalHight = max(0, start+len(rerenderedLines)-m.listHeight())
499+ m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
500+ }
501+
502+ // update the start of the other cached items
503+ currentStart := cachedItem.start + len(rerenderedLines)
504+ if m.reverse {
505+ for i := inx - 1; i < len(m.items); i-- {
506+ if existing, ok := m.renderedItems.Load(i); ok {
507+ cached := existing.(renderedItem)
508+ cached.start = currentStart
509+ currentStart += cached.height
510+ m.renderedItems.Store(i, cached)
511+ } else {
512+ break
513+ }
514+ }
515+ } else {
516+ for i := inx + 1; i < len(m.items); i++ {
517+ if existing, ok := m.renderedItems.Load(i); ok {
518+ cached := existing.(renderedItem)
519+ cached.start = currentStart
520+ currentStart += cached.height
521+ m.renderedItems.Store(i, cached)
522+ } else {
523+ break
524+ }
525+ }
510526 }
511527 }
512528 m.renderedItems.Store(inx, renderedItem{
@@ -518,11 +534,11 @@ func (m *model) rerenderItem(inx int) {
518534}
519535
520536func (m *model) increaseOffset(n int) {
521− if m.finalHight > -1 {
522− if m.offset < m.finalHight {
537+ if m.finalHeight > -1 {
538+ if m.offset < m.finalHeight {
523539 m.offset += n
524− if m.offset > m.finalHight {
525− m.offset = m.finalHight
540+ if m.offset > m.finalHeight {
541+ m.offset = m.finalHeight
526542 }
527543 m.needsRerender = true
528544 }
@@ -550,7 +566,8 @@ func (m *model) UpdateItem(inx int, item util.Model) {
550566 i.Focus()
551567 }
552568 }
553− m.ResetView()
569+ m.setItemSize(inx)
570+ m.rerenderItem(inx)
554571 m.needsRerender = true
555572}
556573
@@ -565,16 +582,15 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
565582 return nil
566583 }
567584 if m.height != height {
568− m.finalHight = -1
585+ m.finalHeight = -1
569586 m.height = height
570587 }
571588 m.width = width
572589 m.ResetView()
573− return m.setItemsSize()
590+ return m.setAllItemsSize()
574591}
575592
576−func (m *model) setItemsSize() tea.Cmd {
577− var cmds []tea.Cmd
593+func (m *model) getItemSize() int {
578594 width := m.width
579595 if m.padding != nil {
580596 if len(m.padding) == 1 {
@@ -585,11 +601,22 @@ func (m *model) setItemsSize() tea.Cmd {
585601 width -= m.padding[1] + m.padding[3]
586602 }
587603 }
588− for _, item := range m.items {
589− if i, ok := item.(layout.Sizeable); ok {
590− cmd := i.SetSize(width, 0) // height is not limited
591− cmds = append(cmds, cmd)
592− }
604+ return width
605+}
606+
607+func (m *model) setItemSize(inx int) tea.Cmd {
608+ if i, ok := m.items[inx].(layout.Sizeable); ok {
609+ cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
610+ return cmd
611+ }
612+ return nil
613+}
614+
615+func (m *model) setAllItemsSize() tea.Cmd {
616+ var cmds []tea.Cmd
617+ for i := range m.items {
618+ cmd := m.setItemSize(i)
619+ cmds = append(cmds, cmd)
593620 }
594621 return tea.Batch(cmds...)
595622}
@@ -612,11 +639,16 @@ func (m *model) listHeight() int {
612639
613640// AppendItem implements List.
614641func (m *model) AppendItem(item util.Model) tea.Cmd {
642+ var cmds []tea.Cmd
615643 cmd := item.Init()
644+ cmds = append(cmds, cmd)
616645 m.items = append(m.items, item)
617− m.goToBottom()
646+ cmd = m.setItemSize(len(m.items) - 1)
647+ cmds = append(cmds, cmd)
648+ cmd = m.goToBottom()
649+ cmds = append(cmds, cmd)
618650 m.needsRerender = true
619− return cmd
651+ return tea.Batch(cmds...)
620652}
621653
622654// DeleteItem implements List.
@@ -632,7 +664,9 @@ func (m *model) DeleteItem(i int) {
632664
633665// PrependItem implements List.
634666func (m *model) PrependItem(item util.Model) tea.Cmd {
667+ var cmds []tea.Cmd
635668 cmd := item.Init()
669+ cmds = append(cmds, cmd)
636670 m.items = append([]util.Model{item}, m.items...)
637671 // update the indices of the rendered items
638672 newRenderedItems := make(map[int]renderedItem)
@@ -647,9 +681,12 @@ func (m *model) PrependItem(item util.Model) tea.Cmd {
647681 for k, v := range newRenderedItems {
648682 m.renderedItems.Store(k, v)
649683 }
650− m.goToTop()
684+ cmd = m.goToTop()
685+ cmds = append(cmds, cmd)
686+ cmd = m.setItemSize(0)
687+ cmds = append(cmds, cmd)
651688 m.needsRerender = true
652− return cmd
689+ return tea.Batch(cmds...)
653690}
654691
655692func (m *model) setReverse(reverse bool) {
@@ -664,7 +701,7 @@ func (m *model) setReverse(reverse bool) {
664701func (m *model) SetItems(items []util.Model) tea.Cmd {
665702 m.items = items
666703 var cmds []tea.Cmd
667− cmd := m.setItemsSize()
704+ cmd := m.setAllItemsSize()
668705 cmds = append(cmds, cmd)
669706 for _, item := range m.items {
670707 cmds = append(cmds, item.Init())
@@ -678,7 +715,6 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
678715 cmd := m.focusSelected()
679716 cmds = append(cmds, cmd)
680717 }
681− m.needsRerender = true
682718 m.ResetView()
683719 return tea.Batch(cmds...)
684720}
internal/tui/tui.go−1
@@ -186,7 +186,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186186 var cmd tea.Cmd
187187 switch msg := msg.(type) {
188188 case tea.WindowSizeMsg:
189− logging.Info("Window size changed main: ", "Width", msg.Width, "Height", msg.Height)
190189 msg.Height -= 1 // Make space for the status bar
191190 a.width, a.height = msg.Width, msg.Height
192191