diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index fef22d3df835f38efb2265c0021ff1beefed5714..6a5ce8eb2a6104e6462d57dd52e47399bc9cc773 100644 --- a/internal/ui/AGENTS.md +++ b/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 diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 6bcb3a1c1fef2353f77329dd8814e277367fa948..f75a9f9328ff01809208bc30a58b3531e766033d 100644 --- a/internal/ui/chat/messages.go +++ b/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 diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index fddf0538a13b9adfce781ded62d1179d9fb609a5..0d0ea18186546bbc017819cbee445a04c913d0bb 100644 --- a/internal/ui/list/list.go +++ b/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 diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 94a999cd75b5e3853cb173c2f287b0dfba3513f7..9c11a2d512d8355d66ad71c72db1eda078ecf85c 100644 --- a/internal/ui/model/chat.go +++ b/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] diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ff072d414b9211dbc19b5651346e7891f80e0c2e..ca79415617ebce0babbe881101402d9424743f03 100644 --- a/internal/ui/model/ui.go +++ b/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