chore(chat): remove empty assistant messages

Kujtim Hoxha created

Change summary

internal/ui/AGENTS.md        | 62 +++++++++++++++++++++++++++++++------
internal/ui/chat/messages.go |  6 +-
internal/ui/list/list.go     | 25 +++++++++++++++
internal/ui/model/chat.go    | 24 ++++++++++++++
internal/ui/model/ui.go      | 12 ++++--
5 files changed, 112 insertions(+), 17 deletions(-)

Detailed changes

internal/ui/AGENTS.md 🔗

@@ -1,17 +1,59 @@
 # UI Development Instructions
 
-## General guideline
-- Never use commands to send messages when you can directly mutate children or state
-- Keep things simple do not overcomplicated
-- Create files if needed to separate logic do not nest models
+## General Guidelines
+- Never use commands to send messages when you can directly mutate children or state.
+- Keep things simple; do not overcomplicate.
+- Create files if needed to separate logic; do not nest models.
 
-## Big model
-Keep most of the logic and state in the main model `internal/ui/model/ui.go`.
+## Architecture
 
+### Main Model (`model/ui.go`)
+Keep most of the logic and state in the main model. This is where:
+- Message routing happens
+- Focus and UI state is managed
+- Layout calculations are performed
+- Dialogs are orchestrated
 
-## When working on components
-Whenever you work on components make them dumb they should not handle bubble tea messages they should have methods.
+### Components Should Be Dumb
+Components should not handle bubbletea messages directly. Instead:
+- Expose methods for state changes
+- Return `tea.Cmd` from methods when side effects are needed
+- Handle their own rendering via `Render(width int) string`
 
-## When adding logic that has to do with the chat
-Most of the logic with the chat should be in the chat component `internal/ui/model/chat.go`, keep individual items dumb and handle logic in this component.
+### Chat Logic (`model/chat.go`)
+Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`).
 
+## Key Patterns
+
+### Composition Over Inheritance
+Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus.
+
+### Interfaces
+- List item interfaces are in `list/item.go`
+- Chat message interfaces are in `chat/messages.go`
+- Dialog interface is in `dialog/dialog.go`
+
+### Styling
+- All styles are defined in `styles/styles.go`
+- Access styles via `*common.Common` passed to components
+- Use semantic color fields rather than hardcoded colors
+
+### Dialogs
+- Implement the dialog interface in `dialog/dialog.go`
+- Return message types from `Update()` to signal actions to the main model
+- Use the overlay system for managing dialog lifecycle
+
+## File Organization
+- `model/` - Main UI model and major components (chat, sidebar, etc.)
+- `chat/` - Chat message item types and renderers
+- `dialog/` - Dialog implementations
+- `list/` - Generic list component with lazy rendering
+- `common/` - Shared utilities and the Common struct
+- `styles/` - All style definitions
+- `anim/` - Animation system
+- `logo/` - Logo rendering
+
+## Common Gotchas
+- Always account for padding/borders in width calculations
+- Use `tea.Batch()` when returning multiple commands
+- Pass `*common.Common` to components that need styles or app access

internal/ui/chat/messages.go 🔗

@@ -161,7 +161,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
 		return []MessageItem{NewUserMessageItem(sty, msg)}
 	case message.Assistant:
 		var items []MessageItem
-		if shouldRenderAssistantMessage(msg) {
+		if ShouldRenderAssistantMessage(msg) {
 			items = append(items, NewAssistantMessageItem(sty, msg))
 		}
 		for _, tc := range msg.ToolCalls() {
@@ -181,11 +181,11 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
 	return []MessageItem{}
 }
 
-// shouldRenderAssistantMessage determines if an assistant message should be rendered
+// ShouldRenderAssistantMessage determines if an assistant message should be rendered
 //
 // In some cases the assistant message only has tools so we do not want to render an
 // empty message.
-func shouldRenderAssistantMessage(msg *message.Message) bool {
+func ShouldRenderAssistantMessage(msg *message.Message) bool {
 	content := strings.TrimSpace(msg.Content().Text)
 	thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
 	isError := msg.FinishReason() == message.FinishReasonError

internal/ui/list/list.go 🔗

@@ -304,6 +304,31 @@ func (l *List) AppendItems(items ...Item) {
 	l.items = append(l.items, items...)
 }
 
+// RemoveItem removes the item at the given index from the list.
+func (l *List) RemoveItem(idx int) {
+	if idx < 0 || idx >= len(l.items) {
+		return
+	}
+
+	// Remove the item
+	l.items = append(l.items[:idx], l.items[idx+1:]...)
+
+	// Adjust selection if needed
+	if l.selectedIdx == idx {
+		l.selectedIdx = -1
+	} else if l.selectedIdx > idx {
+		l.selectedIdx--
+	}
+
+	// Adjust offset if needed
+	if l.offsetIdx > idx {
+		l.offsetIdx--
+	} else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
+		l.offsetIdx = max(0, len(l.items)-1)
+		l.offsetLine = 0
+	}
+}
+
 // Focus sets the focus state of the list.
 func (l *List) Focus() {
 	l.focused = true

internal/ui/model/chat.go 🔗

@@ -245,6 +245,30 @@ func (m *Chat) ClearMessages() {
 	m.ClearMouse()
 }
 
+// RemoveMessage removes a message from the chat list by its ID.
+func (m *Chat) RemoveMessage(id string) {
+	idx, ok := m.idInxMap[id]
+	if !ok {
+		return
+	}
+
+	// Remove from list
+	m.list.RemoveItem(idx)
+
+	// Remove from index map
+	delete(m.idInxMap, id)
+
+	// Rebuild index map for all items after the removed one
+	for i := idx; i < m.list.Len(); i++ {
+		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
+			m.idInxMap[item.ID()] = i
+		}
+	}
+
+	// Clean up any paused animations for this message
+	delete(m.pausedAnimations, id)
+}
+
 // MessageItem returns the message item with the given ID, or nil if not found.
 func (m *Chat) MessageItem(id string) chat.MessageItem {
 	idx, ok := m.idInxMap[id]

internal/ui/model/ui.go 🔗

@@ -431,12 +431,16 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 	var cmds []tea.Cmd
 	existingItem := m.chat.MessageItem(msg.ID)
-	if existingItem == nil || msg.Role != message.Assistant {
-		return nil
+
+	if existingItem != nil {
+		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
+			assistantItem.SetMessage(&msg)
+		}
 	}
 
-	if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
-		assistantItem.SetMessage(&msg)
+	// if the message of the assistant does not have any  response just tool calls we need to remove it
+	if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil {
+		m.chat.RemoveMessage(msg.ID)
 	}
 
 	var items []chat.MessageItem