Detailed changes
@@ -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
@@ -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
@@ -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
@@ -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]
@@ -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