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/bash.go b/internal/ui/chat/bash.go index c57d8e8d8a3ba0f567373a81707b6fdc54166fa6..19a8d7edb4898a1861ed5045f3fb4979b8e98325 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -1,14 +1,22 @@ package chat import ( + "cmp" "encoding/json" + "fmt" "strings" + "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" ) +// ----------------------------------------------------------------------------- +// Bash Tool +// ----------------------------------------------------------------------------- + // BashToolMessageItem is a message item that represents a bash tool call. type BashToolMessageItem struct { *baseToolMessageItem @@ -23,86 +31,218 @@ func NewBashToolMessageItem( result *message.ToolResult, canceled bool, ) ToolMessageItem { - return newBaseToolMessageItem( - sty, - toolCall, - result, - &BashToolRenderContext{}, - canceled, - ) + return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled) } -// BashToolRenderContext holds context for rendering bash tool messages. -// -// It implements the [ToolRenderer] interface. +// BashToolRenderContext renders bash tool messages. type BashToolRenderContext struct{} // RenderTool implements the [ToolRenderer] interface. func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) - const toolName = "Bash" if !opts.ToolCall.Finished && !opts.Canceled { - return pendingTool(sty, toolName, opts.Anim) + return pendingTool(sty, "Bash", opts.Anim) } var params tools.BashParams - var cmd string - err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) - - if err != nil { - cmd = "failed to parse command" - } else { - cmd = strings.ReplaceAll(params.Command, "\n", " ") - cmd = strings.ReplaceAll(cmd, "\t", " ") + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + params.Command = "failed to parse command" } - // TODO: if the tool is being run in the background use the background job renderer + // Check if this is a background job. + var meta tools.BashResponseMetadata + if opts.Result != nil { + _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta) + } - toolParams := []string{ - cmd, + if meta.Background { + description := cmp.Or(meta.Description, params.Command) + content := "Command: " + params.Command + "\n" + opts.Result.Content + return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content) } + // Regular bash command. + cmd := strings.ReplaceAll(params.Command, "\n", " ") + cmd = strings.ReplaceAll(cmd, "\t", " ") + toolParams := []string{cmd} if params.RunInBackground { toolParams = append(toolParams, "background", "true") } header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...) - if opts.Nested { return header } - earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth) - - // If this is OK that means that the tool is not done yet or it was canceled - if ok { - return strings.Join([]string{header, "", earlyStateContent}, "\n") + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) } if opts.Result == nil { - // We should not get here! return header } - var meta tools.BashResponseMetadata - err = json.Unmarshal([]byte(opts.Result.Metadata), &meta) - - var output string - if err != nil { - output = "failed to parse output" - } - output = meta.Output + output := meta.Output if output == "" && opts.Result.Content != tools.BashNoOutput { output = opts.Result.Content } - if output == "" { return header } bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Job Output Tool +// ----------------------------------------------------------------------------- + +// JobOutputToolMessageItem is a message item for job_output tool calls. +type JobOutputToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil) + +// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem]. +func NewJobOutputToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled) +} + +// JobOutputToolRenderContext renders job_output tool messages. +type JobOutputToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Job", opts.Anim) + } + + var params tools.JobOutputParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var description string + if opts.Result != nil && opts.Result.Metadata != "" { + var meta tools.JobOutputResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + description = cmp.Or(meta.Description, meta.Command) + } + } + + content := "" + if opts.Result != nil { + content = opts.Result.Content + } + return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content) +} + +// ----------------------------------------------------------------------------- +// Job Kill Tool +// ----------------------------------------------------------------------------- + +// JobKillToolMessageItem is a message item for job_kill tool calls. +type JobKillToolMessageItem struct { + *baseToolMessageItem +} - output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded)) +var _ ToolMessageItem = (*JobKillToolMessageItem)(nil) + +// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem]. +func NewJobKillToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled) +} + +// JobKillToolRenderContext renders job_kill tool messages. +type JobKillToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.Canceled { + return pendingTool(sty, "Job", opts.Anim) + } + + var params tools.JobKillParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var description string + if opts.Result != nil && opts.Result.Metadata != "" { + var meta tools.JobKillResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + description = cmp.Or(meta.Description, meta.Command) + } + } + + content := "" + if opts.Result != nil { + content = opts.Result.Content + } + return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content) +} + +// renderJobTool renders a job-related tool with the common pattern: +// header → nested check → early state → body. +func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { + header := jobHeader(sty, opts.Status(), action, shellID, description, width) + if opts.Nested { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if content == "" { + return header + } + + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded)) + return joinToolParts(header, body) +} + +// jobHeader builds a header for job-related tools. +// Format: "● Job (Action) PID shellID description..." +func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string { + icon := toolIcon(sty, status) + jobPart := sty.Tool.JobToolName.Render("Job") + actionPart := sty.Tool.JobAction.Render("(" + action + ")") + pidPart := sty.Tool.JobPID.Render("PID " + shellID) + + prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart) + + if description == "" { + return prefix + } + + prefixWidth := lipgloss.Width(prefix) + availableWidth := width - prefixWidth - 1 + if availableWidth < 10 { + return prefix + } + + truncatedDesc := ansi.Truncate(description, availableWidth, "…") + return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc) +} - return strings.Join([]string{header, "", output}, "\n") +// joinToolParts joins header and body with a blank line separator. +func joinToolParts(header, body string) string { + return strings.Join([]string{header, "", body}, "\n") } 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/chat/tools.go b/internal/ui/chat/tools.go index 705462e2c10c049bccb7c2237e98b20a8f03477e..fb2f260e6b74493c0b7a6168ef219ecfee40176c 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -158,6 +158,10 @@ func NewToolMessageItem( switch toolCall.Name { case tools.BashToolName: return NewBashToolMessageItem(sty, toolCall, result, canceled) + case tools.JobOutputToolName: + return NewJobOutputToolMessageItem(sty, toolCall, result, canceled) + case tools.JobKillToolName: + return NewJobKillToolMessageItem(sty, toolCall, result, canceled) default: // TODO: Implement other tool items return newBaseToolMessageItem( diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index c5707f65412c7fd2d5932377d07ab5b4d42467a3..92105d717be7323e745373b59ee205b2b13f7267 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -47,7 +47,7 @@ func (f *ModelsList) SetGroups(groups ...ModelGroup) { // Add a space separator after each provider section items = append(items, list.NewSpacerItem(1)) } - f.List.SetItems(items...) + f.SetItems(items...) } // SetFilter sets the filter query and updates the list items. @@ -66,7 +66,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) { for _, g := range f.groups { for _, item := range g.Items { if item.ID() == itemID { - f.List.SetSelected(count) + f.SetSelected(count) return } count++ @@ -142,7 +142,7 @@ func (f *ModelsList) VisibleItems() []list.Item { // Render renders the filterable list. func (f *ModelsList) Render() string { - f.List.SetItems(f.VisibleItems()...) + f.SetItems(f.VisibleItems()...) return f.List.Render() } 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 diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index a91e3810142e259dfd38ad7827e9577699a112ae..854599184d50535afedfce472a3329b410ab52d1 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -160,6 +160,7 @@ type Styles struct { White color.Color BlueLight color.Color Blue color.Color + BlueDark color.Color Green color.Color GreenDark color.Color Red color.Color @@ -382,6 +383,7 @@ func DefaultStyles() Styles { blueLight = charmtone.Sardine blue = charmtone.Malibu + blueDark = charmtone.Damson // yellow = charmtone.Mustard yellow = charmtone.Mustard @@ -424,6 +426,7 @@ func DefaultStyles() Styles { s.White = white s.BlueLight = blueLight s.Blue = blue + s.BlueDark = blueDark s.Green = green s.GreenDark = greenDark s.Red = red @@ -992,8 +995,8 @@ func DefaultStyles() Styles { s.Tool.JobIconError = base.Foreground(redDark) s.Tool.JobIconSuccess = base.Foreground(green) s.Tool.JobToolName = base.Foreground(blue) - s.Tool.JobAction = base.Foreground(fgHalfMuted) - s.Tool.JobPID = s.Subtle + s.Tool.JobAction = base.Foreground(blueDark) + s.Tool.JobPID = s.Muted s.Tool.JobDescription = s.Subtle // Agent task styles