implement tool calls in the ui

Kujtim Hoxha created

Change summary

.editorconfig                                     |   2 
internal/llm/agent/agent.go                       |  14 -
internal/llm/prompt/coder.go                      |   4 
internal/llm/provider/anthropic.go                |   2 
internal/tui/components/anim/anim.go              |  38 +++-
internal/tui/components/chat/list_v2.go           |  84 ++++++++--
internal/tui/components/chat/messages/messages.go |  13 +
internal/tui/components/chat/messages/renderer.go |  29 ---
internal/tui/components/chat/messages/tool.go     | 107 ++++++++----
internal/tui/components/core/list/list.go         | 138 ++++++++++------
internal/tui/tui.go                               |   1 
11 files changed, 269 insertions(+), 163 deletions(-)

Detailed changes

.editorconfig 🔗

@@ -11,7 +11,7 @@ indent_size = 2
 
 [*.go]
 indent_style = tab
-indent_size = 8
+indent_size = 4
 
 [*.golden]
 insert_final_newline = false

internal/llm/agent/agent.go 🔗

@@ -443,18 +443,14 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
 		assistantMsg.AppendContent(event.Content)
 		return a.messages.Update(ctx, *assistantMsg)
 	case provider.EventToolUseStart:
+		logging.Info("Tool call started", "toolCall", event.ToolCall)
 		assistantMsg.AddToolCall(*event.ToolCall)
 		return a.messages.Update(ctx, *assistantMsg)
-	// TODO: see how to handle this
-	// case provider.EventToolUseDelta:
-	// 	tm := time.Unix(assistantMsg.UpdatedAt, 0)
-	// 	assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
-	// 	if time.Since(tm) > 1000*time.Millisecond {
-	// 		err := a.messages.Update(ctx, *assistantMsg)
-	// 		assistantMsg.UpdatedAt = time.Now().Unix()
-	// 		return err
-	// 	}
+	case provider.EventToolUseDelta:
+		assistantMsg.AppendToolCallInput(event.ToolCall.ID, event.ToolCall.Input)
+		return a.messages.Update(ctx, *assistantMsg)
 	case provider.EventToolUseStop:
+		logging.Info("Finished tool call", "toolCall", event.ToolCall)
 		assistantMsg.FinishToolCall(event.ToolCall.ID)
 		return a.messages.Update(ctx, *assistantMsg)
 	case provider.EventError:

internal/llm/prompt/coder.go 🔗

@@ -153,7 +153,7 @@ When making changes to files, first understand the file's code conventions. Mimi
 
 # Doing tasks
 The 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:
-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.
+1. Use the available search tools to understand the codebase and the user's query.
 2. Implement the solution using all tools available to you
 3. 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.
 4. 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
 
 # Tool usage policy
 - When doing file search, prefer to use the Agent tool in order to reduce context usage.
-- 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.
+- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
 - 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.
 
 You 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 🔗

@@ -305,7 +305,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
 								ToolCall: &message.ToolCall{
 									ID:       currentToolCallID,
 									Finished: false,
-									Input:    event.Delta.JSON.PartialJSON.Raw(),
+									Input:    event.Delta.PartialJSON,
 								},
 							}
 						}

internal/tui/components/anim/anim.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/google/uuid"
 	"github.com/lucasb-eyer/go-colorful"
 	"github.com/opencode-ai/opencode/internal/tui/styles"
 	"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -54,19 +55,23 @@ func (c cyclingChar) state(start time.Time) charState {
 	return charCyclingState
 }
 
-type StepCharsMsg struct{}
+type StepCharsMsg struct {
+	id string
+}
 
-func stepChars() tea.Cmd {
+func stepChars(id string) tea.Cmd {
 	return tea.Tick(charCyclingFPS, func(time.Time) tea.Msg {
-		return StepCharsMsg{}
+		return StepCharsMsg{id}
 	})
 }
 
-type ColorCycleMsg struct{}
+type ColorCycleMsg struct {
+	id string
+}
 
-func cycleColors() tea.Cmd {
+func cycleColors(id string) tea.Cmd {
 	return tea.Tick(colorCycleFPS, func(time.Time) tea.Msg {
-		return ColorCycleMsg{}
+		return ColorCycleMsg{id}
 	})
 }
 
@@ -80,6 +85,7 @@ type anim struct {
 	label           []rune
 	ellipsis        spinner.Model
 	ellipsisStarted bool
+	id              string
 }
 
 func New(cyclingCharsSize uint, label string) util.Model {
@@ -91,10 +97,12 @@ func New(cyclingCharsSize uint, label string) util.Model {
 		gap = ""
 	}
 
+	id := uuid.New()
 	c := anim{
 		start:    time.Now(),
 		label:    []rune(gap + label),
 		ellipsis: spinner.New(spinner.WithSpinner(spinner.Ellipsis)),
+		id:       id.String(),
 	}
 
 	// If we're in truecolor mode (and there are enough cycling characters)
@@ -144,15 +152,18 @@ func New(cyclingCharsSize uint, label string) util.Model {
 }
 
 // Init initializes the animation.
-func (anim) Init() tea.Cmd {
-	return tea.Batch(stepChars(), cycleColors())
+func (a anim) Init() tea.Cmd {
+	return tea.Batch(stepChars(a.id), cycleColors(a.id))
 }
 
 // Update handles messages.
 func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
-	switch msg.(type) {
+	switch msg := msg.(type) {
 	case StepCharsMsg:
+		if msg.id != a.id {
+			return a, nil
+		}
 		a.updateChars(&a.cyclingChars)
 		a.updateChars(&a.labelChars)
 
@@ -173,14 +184,17 @@ func (a anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 
-		return a, tea.Batch(stepChars(), cmd)
+		return a, tea.Batch(stepChars(a.id), cmd)
 	case ColorCycleMsg:
+		if msg.id != a.id {
+			return a, nil
+		}
 		const minColorCycleSize = 2
 		if len(a.ramp) < minColorCycleSize {
 			return a, nil
 		}
 		a.ramp = append(a.ramp[1:], a.ramp[0])
-		return a, cycleColors()
+		return a, cycleColors(a.id)
 	case spinner.TickMsg:
 		var cmd tea.Cmd
 		a.ellipsis, cmd = a.ellipsis.Update(msg)
@@ -216,7 +230,7 @@ func (a anim) View() string {
 		b.WriteRune(c.currentValue)
 	}
 
-	if len(a.label) > 1 {
+	if len(a.labelChars) > 1 {
 		textStyle := styles.BaseStyle().
 			Foreground(t.Text())
 		for _, c := range a.labelChars {

internal/tui/components/chat/list_v2.go 🔗

@@ -7,6 +7,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/app"
+	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/pubsub"
 	"github.com/opencode-ai/opencode/internal/session"
@@ -61,7 +62,8 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, m.listCmp.SetItems([]util.Model{})
 
 	case pubsub.Event[message.Message]:
-		return m, m.handleMessageEvent(msg)
+		cmd := m.handleMessageEvent(msg)
+		return m, cmd
 	default:
 		var cmds []tea.Cmd
 		u, cmd := m.listCmp.Update(msg)
@@ -92,8 +94,8 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
 		// more likely to be at the end of the list
 		items := m.listCmp.Items()
 		for i := len(items) - 1; i >= 0; i-- {
-			msg := items[i].(messages.MessageCmp)
-			if msg.GetMessage().ID == event.Payload.ID {
+			msg, ok := items[i].(messages.MessageCmp)
+			if ok && msg.GetMessage().ID == event.Payload.ID {
 				messageExists = true
 				break
 			}
@@ -109,7 +111,6 @@ func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message])
 		case message.Tool:
 			return m.handleToolMessage(event.Payload)
 		}
-		// TODO: handle tools
 	case pubsub.UpdatedEvent:
 		return m.handleUpdateAssistantMessage(event.Payload)
 	}
@@ -122,30 +123,79 @@ func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
 }
 
 func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
+	items := m.listCmp.Items()
+	for _, tr := range msg.ToolResults() {
+		for i := len(items) - 1; i >= 0; i-- {
+			message := items[i]
+			if toolCall, ok := message.(messages.ToolCallCmp); ok {
+				if toolCall.GetToolCall().ID == tr.ToolCallID {
+					toolCall.SetToolResult(tr)
+					m.listCmp.UpdateItem(
+						i,
+						toolCall,
+					)
+					break
+				}
+			}
+		}
+	}
 	return nil
 }
 
 func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
 	// Simple update the content
 	items := m.listCmp.Items()
-	lastItem := items[len(items)-1].(messages.MessageCmp)
-	// TODO:handle tool calls
-	if lastItem.GetMessage().ID != msg.ID {
-		return nil
+	assistantMessageInx := -1
+	toolCalls := map[int]messages.ToolCallCmp{}
+
+	// we go backwards because the messages are most likely at the end of the list
+	for i := len(items) - 1; i >= 0; i-- {
+		message := items[i]
+		if asMsg, ok := message.(messages.MessageCmp); ok {
+			if asMsg.GetMessage().ID == msg.ID {
+				assistantMessageInx = i
+			}
+		} else if tc, ok := message.(messages.ToolCallCmp); ok {
+			if tc.ParentMessageId() == msg.ID {
+				toolCalls[i] = tc
+			}
+		}
 	}
-	// for now just updet the last message
-	if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
+
+	logging.Info("Update Assistant Message", "msg", msg, "assistantMessageInx", assistantMessageInx, "toolCalls", toolCalls)
+
+	if assistantMessageInx > -1 && (len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()) {
 		m.listCmp.UpdateItem(
-			len(items)-1,
+			assistantMessageInx,
 			messages.NewMessageCmp(
 				msg,
 				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
-	} else if len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
-		m.listCmp.DeleteItem(len(items) - 1)
+	} else if assistantMessageInx > -1 && len(msg.ToolCalls()) > 0 && msg.Content().Text == "" {
+		m.listCmp.DeleteItem(assistantMessageInx)
 	}
-	return nil
+	for _, tc := range msg.ToolCalls() {
+		found := false
+		for inx, tcc := range toolCalls {
+			if tc.ID == tcc.GetToolCall().ID {
+				tcc.SetToolCall(tc)
+				m.listCmp.UpdateItem(
+					inx,
+					tcc,
+				)
+				found = true
+				break
+			}
+		}
+		if !found {
+			cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	return tea.Batch(cmds...)
 }
 
 func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
@@ -161,7 +211,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
 		cmds = append(cmds, cmd)
 	}
 	for _, tc := range msg.ToolCalls() {
-		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(tc))
+		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
 		cmds = append(cmds, cmd)
 	}
 	return tea.Batch(cmds...)
@@ -206,10 +256,10 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 				if tr, ok := toolResultMap[tc.ID]; ok {
 					options = append(options, messages.WithToolCallResult(tr))
 				}
-				if msg.FinishPart().Reason == message.FinishReasonCanceled {
+				if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
 					options = append(options, messages.WithToolCallCancelled())
 				}
-				uiMessages = append(uiMessages, messages.NewToolCallCmp(tc, options...))
+				uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
 			}
 		}
 	}

internal/tui/components/chat/messages/messages.go 🔗

@@ -65,9 +65,16 @@ func (m *messageCmp) Init() tea.Cmd {
 }
 
 func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	u, cmd := m.anim.Update(msg)
-	m.anim = u.(util.Model)
-	return m, cmd
+	switch msg := msg.(type) {
+	case anim.ColorCycleMsg, anim.StepCharsMsg:
+		m.spinning = m.shouldSpin()
+		if m.spinning {
+			u, cmd := m.anim.Update(msg)
+			m.anim = u.(util.Model)
+			return m, cmd
+		}
+	}
+	return m, nil
 }
 
 func (m *messageCmp) View() string {

internal/tui/components/chat/messages/renderer.go 🔗

@@ -524,32 +524,3 @@ func prettifyToolName(name string) string {
 		return name
 	}
 }
-
-func toolAction(name string) string {
-	switch name {
-	case agent.AgentToolName:
-		return "Preparing prompt..."
-	case tools.BashToolName:
-		return "Building command..."
-	case tools.EditToolName:
-		return "Preparing edit..."
-	case tools.FetchToolName:
-		return "Writing fetch..."
-	case tools.GlobToolName:
-		return "Finding files..."
-	case tools.GrepToolName:
-		return "Searching content..."
-	case tools.LSToolName:
-		return "Listing directory..."
-	case tools.SourcegraphToolName:
-		return "Searching code..."
-	case tools.ViewToolName:
-		return "Reading file..."
-	case tools.WriteToolName:
-		return "Preparing write..."
-	case tools.PatchToolName:
-		return "Preparing patch..."
-	default:
-		return "Working..."
-	}
-}

internal/tui/components/chat/messages/tool.go 🔗

@@ -6,9 +6,9 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
-	"github.com/opencode-ai/opencode/internal/llm/agent"
-	"github.com/opencode-ai/opencode/internal/llm/tools"
+	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/opencode-ai/opencode/internal/tui/components/anim"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/styles"
 	"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -21,15 +21,24 @@ type ToolCallCmp interface {
 	layout.Focusable
 	GetToolCall() message.ToolCall
 	GetToolResult() message.ToolResult
+	SetToolResult(message.ToolResult)
+	SetToolCall(message.ToolCall)
+	SetCancelled()
+	ParentMessageId() string
+	Spinning() bool
 }
 
 type toolCallCmp struct {
 	width   int
 	focused bool
 
-	call      message.ToolCall
-	result    message.ToolResult
-	cancelled bool
+	parentMessageId string
+	call            message.ToolCall
+	result          message.ToolResult
+	cancelled       bool
+
+	spinning bool
+	anim     util.Model
 }
 
 type ToolCallOption func(*toolCallCmp)
@@ -46,9 +55,11 @@ func WithToolCallResult(result message.ToolResult) ToolCallOption {
 	}
 }
 
-func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
+func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
 	m := &toolCallCmp{
-		call: tc,
+		call:            tc,
+		parentMessageId: parentMessageId,
+		anim:            anim.New(15, "Working"),
 	}
 	for _, opt := range opts {
 		opt(m)
@@ -57,10 +68,24 @@ func NewToolCallCmp(tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
 }
 
 func (m *toolCallCmp) Init() tea.Cmd {
+	m.spinning = m.shouldSpin()
+	logging.Info("Initializing tool call spinner", "tool_call", m.call.Name, "spinning", m.spinning)
+	if m.spinning {
+		return m.anim.Init()
+	}
 	return nil
 }
 
 func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	logging.Debug("Tool call update", "msg", msg)
+	switch msg := msg.(type) {
+	case anim.ColorCycleMsg, anim.StepCharsMsg:
+		if m.spinning {
+			u, cmd := m.anim.Update(msg)
+			m.anim = u.(util.Model)
+			return m, cmd
+		}
+	}
 	return m, nil
 }
 
@@ -75,6 +100,30 @@ func (m *toolCallCmp) View() string {
 	return box.PaddingLeft(1).Render(r.Render(m))
 }
 
+// SetCancelled implements ToolCallCmp.
+func (m *toolCallCmp) SetCancelled() {
+	m.cancelled = true
+}
+
+// SetToolCall implements ToolCallCmp.
+func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
+	m.call = call
+	if m.call.Finished {
+		m.spinning = false
+	}
+}
+
+// ParentMessageId implements ToolCallCmp.
+func (m *toolCallCmp) ParentMessageId() string {
+	return m.parentMessageId
+}
+
+// SetToolResult implements ToolCallCmp.
+func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
+	m.result = result
+	m.spinning = false
+}
+
 // GetToolCall implements ToolCallCmp.
 func (m *toolCallCmp) GetToolCall() message.ToolCall {
 	return m.call
@@ -86,7 +135,7 @@ func (m *toolCallCmp) GetToolResult() message.ToolResult {
 }
 
 func (m *toolCallCmp) renderPending() string {
-	return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), toolAction(m.call.Name))
+	return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), m.anim.View())
 }
 
 func (m *toolCallCmp) style() lipgloss.Style {
@@ -113,35 +162,6 @@ func (m *toolCallCmp) fit(content string, width int) string {
 	return ansi.Truncate(content, width, dots)
 }
 
-func (m *toolCallCmp) toolName() string {
-	switch m.call.Name {
-	case agent.AgentToolName:
-		return "Task"
-	case tools.BashToolName:
-		return "Bash"
-	case tools.EditToolName:
-		return "Edit"
-	case tools.FetchToolName:
-		return "Fetch"
-	case tools.GlobToolName:
-		return "Glob"
-	case tools.GrepToolName:
-		return "Grep"
-	case tools.LSToolName:
-		return "List"
-	case tools.SourcegraphToolName:
-		return "Sourcegraph"
-	case tools.ViewToolName:
-		return "View"
-	case tools.WriteToolName:
-		return "Write"
-	case tools.PatchToolName:
-		return "Patch"
-	default:
-		return m.call.Name
-	}
-}
-
 func (m *toolCallCmp) Blur() tea.Cmd {
 	m.focused = false
 	return nil
@@ -165,3 +185,16 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
 	m.width = width
 	return nil
 }
+
+func (m *toolCallCmp) shouldSpin() bool {
+	if !m.call.Finished {
+		return true
+	} else if m.result.ToolCallID != m.call.ID {
+		return true
+	}
+	return false
+}
+
+func (m *toolCallCmp) Spinning() bool {
+	return m.spinning
+}

internal/tui/components/core/list/list.go 🔗

@@ -9,7 +9,6 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/opencode-ai/opencode/internal/logging"
 	"github.com/opencode-ai/opencode/internal/tui/components/anim"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/util"
@@ -39,7 +38,7 @@ type renderedItem struct {
 }
 type model struct {
 	width, height, offset int
-	finalHight            int // this gets set when the last item is rendered to mark the max offset
+	finalHeight           int // this gets set when the last item is rendered to mark the max offset
 	reverse               bool
 	help                  help.Model
 	keymap                KeyMap
@@ -95,7 +94,7 @@ func New(opts ...listOptions) ListModel {
 		gapSize:         0,
 		padding:         []int{},
 		selectedItemInx: -1,
-		finalHight:      -1,
+		finalHeight:     -1,
 		lastRenderedInx: -1,
 		renderedItems:   new(sync.Map),
 	}
@@ -160,20 +159,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.goToBottom()
 			return m, nil
 		}
-	case anim.ColorCycleMsg:
-		logging.Info("ColorCycleMsg", "msg", msg)
-		for inx, item := range m.items {
-			if i, ok := item.(HasAnim); ok {
-				if i.Spinning() {
-					updated, cmd := i.Update(msg)
-					cmds = append(cmds, cmd)
-					m.UpdateItem(inx, updated.(util.Model))
-				}
-			}
-		}
-		return m, tea.Batch(cmds...)
-	case anim.StepCharsMsg:
-		logging.Info("ColorCycleMsg", "msg", msg)
+	case anim.ColorCycleMsg, anim.StepCharsMsg:
 		for inx, item := range m.items {
 			if i, ok := item.(HasAnim); ok {
 				if i.Spinning() {
@@ -189,7 +175,6 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		u, cmd := m.items[m.selectedItemInx].Update(msg)
 		cmds = append(cmds, cmd)
 		m.UpdateItem(m.selectedItemInx, u.(util.Model))
-		cmds = append(cmds, cmd)
 		return m, tea.Batch(cmds...)
 	}
 
@@ -232,7 +217,7 @@ func (m *model) renderVisibleReverse() {
 			itemLines = cachedContent.(renderedItem).lines
 		} else {
 			itemLines = strings.Split(items[i].View(), "\n")
-			if m.gapSize > 0 && realIndex != len(m.items)-1 {
+			if m.gapSize > 0 {
 				for range m.gapSize {
 					itemLines = append(itemLines, "")
 				}
@@ -245,7 +230,7 @@ func (m *model) renderVisibleReverse() {
 		}
 
 		if realIndex == 0 {
-			m.finalHight = max(0, start+len(itemLines)-m.listHeight())
+			m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
 		}
 		m.renderedLines = append(itemLines, m.renderedLines...)
 		m.lastRenderedInx = realIndex
@@ -256,9 +241,9 @@ func (m *model) renderVisibleReverse() {
 		start += len(itemLines)
 	}
 	m.needsRerender = false
-	if m.finalHight > -1 {
+	if m.finalHeight > -1 {
 		// make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
-		m.offset = min(m.offset, m.finalHight)
+		m.offset = min(m.offset, m.finalHeight)
 	}
 	maxHeight := min(m.listHeight(), len(m.renderedLines))
 	if m.offset < len(m.renderedLines) {
@@ -293,7 +278,7 @@ func (m *model) renderVisible() {
 			itemLines = cachedContent.(renderedItem).lines
 		} else {
 			itemLines = strings.Split(item.View(), "\n")
-			if m.gapSize > 0 && realIndex != len(m.items)-1 {
+			if m.gapSize > 0 {
 				for range m.gapSize {
 					itemLines = append(itemLines, "")
 				}
@@ -310,7 +295,7 @@ func (m *model) renderVisible() {
 		}
 
 		if realIndex == len(m.items)-1 {
-			m.finalHight = max(0, start+len(itemLines)-m.listHeight())
+			m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
 		}
 
 		m.renderedLines = append(m.renderedLines, itemLines...)
@@ -319,9 +304,9 @@ func (m *model) renderVisible() {
 	}
 	m.needsRerender = false
 	maxHeight := min(m.listHeight(), len(m.renderedLines))
-	if m.finalHight > -1 {
+	if m.finalHeight > -1 {
 		// make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
-		m.offset = min(m.offset, m.finalHight)
+		m.offset = min(m.offset, m.finalHeight)
 	}
 	if m.offset < len(m.renderedLines) {
 		m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
@@ -412,6 +397,9 @@ func (m *model) downOneItem() tea.Cmd {
 }
 
 func (m *model) goToBottom() tea.Cmd {
+	if len(m.items) == 0 {
+		return nil
+	}
 	var cmds []tea.Cmd
 	m.reverse = true
 	cmd := m.blurSelected()
@@ -428,11 +416,14 @@ func (m *model) ResetView() {
 	m.renderedLines = []string{}
 	m.offset = 0
 	m.lastRenderedInx = -1
-	m.finalHight = -1
+	m.finalHeight = -1
 	m.needsRerender = true
 }
 
 func (m *model) goToTop() tea.Cmd {
+	if len(m.items) == 0 {
+		return nil
+	}
 	var cmds []tea.Cmd
 	m.reverse = false
 	cmd := m.blurSelected()
@@ -480,7 +471,7 @@ func (m *model) rerenderItem(inx int) {
 	}
 	rerenderedItem := m.items[inx].View()
 	rerenderedLines := strings.Split(rerenderedItem, "\n")
-	if m.gapSize > 0 && inx != len(m.items)-1 {
+	if m.gapSize > 0 {
 		for range m.gapSize {
 			rerenderedLines = append(rerenderedLines, "")
 		}
@@ -492,7 +483,6 @@ func (m *model) rerenderItem(inx int) {
 	}
 	// check if the item is in the content
 	start := cachedItem.start
-	logging.Info("rerenderItem", "inx", inx, "start", start, "cachedItem.start", cachedItem.start, "cachedItem.height", cachedItem.height)
 	end := start + cachedItem.height
 	totalLines := len(m.renderedLines)
 	if m.reverse {
@@ -504,9 +494,35 @@ func (m *model) rerenderItem(inx int) {
 		m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
 	}
 	// TODO: if hight changed do something
-	if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
+	if cachedItem.height != len(rerenderedLines) {
 		if inx == len(m.items)-1 {
-			m.finalHight = max(0, start+len(rerenderedLines)-m.listHeight())
+			m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
+		}
+
+		// update the start of the other cached items
+		currentStart := cachedItem.start + len(rerenderedLines)
+		if m.reverse {
+			for i := inx - 1; i < len(m.items); i-- {
+				if existing, ok := m.renderedItems.Load(i); ok {
+					cached := existing.(renderedItem)
+					cached.start = currentStart
+					currentStart += cached.height
+					m.renderedItems.Store(i, cached)
+				} else {
+					break
+				}
+			}
+		} else {
+			for i := inx + 1; i < len(m.items); i++ {
+				if existing, ok := m.renderedItems.Load(i); ok {
+					cached := existing.(renderedItem)
+					cached.start = currentStart
+					currentStart += cached.height
+					m.renderedItems.Store(i, cached)
+				} else {
+					break
+				}
+			}
 		}
 	}
 	m.renderedItems.Store(inx, renderedItem{
@@ -518,11 +534,11 @@ func (m *model) rerenderItem(inx int) {
 }
 
 func (m *model) increaseOffset(n int) {
-	if m.finalHight > -1 {
-		if m.offset < m.finalHight {
+	if m.finalHeight > -1 {
+		if m.offset < m.finalHeight {
 			m.offset += n
-			if m.offset > m.finalHight {
-				m.offset = m.finalHight
+			if m.offset > m.finalHeight {
+				m.offset = m.finalHeight
 			}
 			m.needsRerender = true
 		}
@@ -550,7 +566,8 @@ func (m *model) UpdateItem(inx int, item util.Model) {
 			i.Focus()
 		}
 	}
-	m.ResetView()
+	m.setItemSize(inx)
+	m.rerenderItem(inx)
 	m.needsRerender = true
 }
 
@@ -565,16 +582,15 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
 		return nil
 	}
 	if m.height != height {
-		m.finalHight = -1
+		m.finalHeight = -1
 		m.height = height
 	}
 	m.width = width
 	m.ResetView()
-	return m.setItemsSize()
+	return m.setAllItemsSize()
 }
 
-func (m *model) setItemsSize() tea.Cmd {
-	var cmds []tea.Cmd
+func (m *model) getItemSize() int {
 	width := m.width
 	if m.padding != nil {
 		if len(m.padding) == 1 {
@@ -585,11 +601,22 @@ func (m *model) setItemsSize() tea.Cmd {
 			width -= m.padding[1] + m.padding[3]
 		}
 	}
-	for _, item := range m.items {
-		if i, ok := item.(layout.Sizeable); ok {
-			cmd := i.SetSize(width, 0) // height is not limited
-			cmds = append(cmds, cmd)
-		}
+	return width
+}
+
+func (m *model) setItemSize(inx int) tea.Cmd {
+	if i, ok := m.items[inx].(layout.Sizeable); ok {
+		cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
+		return cmd
+	}
+	return nil
+}
+
+func (m *model) setAllItemsSize() tea.Cmd {
+	var cmds []tea.Cmd
+	for i := range m.items {
+		cmd := m.setItemSize(i)
+		cmds = append(cmds, cmd)
 	}
 	return tea.Batch(cmds...)
 }
@@ -612,11 +639,16 @@ func (m *model) listHeight() int {
 
 // AppendItem implements List.
 func (m *model) AppendItem(item util.Model) tea.Cmd {
+	var cmds []tea.Cmd
 	cmd := item.Init()
+	cmds = append(cmds, cmd)
 	m.items = append(m.items, item)
-	m.goToBottom()
+	cmd = m.setItemSize(len(m.items) - 1)
+	cmds = append(cmds, cmd)
+	cmd = m.goToBottom()
+	cmds = append(cmds, cmd)
 	m.needsRerender = true
-	return cmd
+	return tea.Batch(cmds...)
 }
 
 // DeleteItem implements List.
@@ -632,7 +664,9 @@ func (m *model) DeleteItem(i int) {
 
 // PrependItem implements List.
 func (m *model) PrependItem(item util.Model) tea.Cmd {
+	var cmds []tea.Cmd
 	cmd := item.Init()
+	cmds = append(cmds, cmd)
 	m.items = append([]util.Model{item}, m.items...)
 	// update the indices of the rendered items
 	newRenderedItems := make(map[int]renderedItem)
@@ -647,9 +681,12 @@ func (m *model) PrependItem(item util.Model) tea.Cmd {
 	for k, v := range newRenderedItems {
 		m.renderedItems.Store(k, v)
 	}
-	m.goToTop()
+	cmd = m.goToTop()
+	cmds = append(cmds, cmd)
+	cmd = m.setItemSize(0)
+	cmds = append(cmds, cmd)
 	m.needsRerender = true
-	return cmd
+	return tea.Batch(cmds...)
 }
 
 func (m *model) setReverse(reverse bool) {
@@ -664,7 +701,7 @@ func (m *model) setReverse(reverse bool) {
 func (m *model) SetItems(items []util.Model) tea.Cmd {
 	m.items = items
 	var cmds []tea.Cmd
-	cmd := m.setItemsSize()
+	cmd := m.setAllItemsSize()
 	cmds = append(cmds, cmd)
 	for _, item := range m.items {
 		cmds = append(cmds, item.Init())
@@ -678,7 +715,6 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
 		cmd := m.focusSelected()
 		cmds = append(cmds, cmd)
 	}
-	m.needsRerender = true
 	m.ResetView()
 	return tea.Batch(cmds...)
 }

internal/tui/tui.go 🔗

@@ -186,7 +186,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		logging.Info("Window size changed main: ", "Width", msg.Width, "Height", msg.Height)
 		msg.Height -= 1 // Make space for the status bar
 		a.width, a.height = msg.Width, msg.Height