diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 120864dd9761fbb4cb988dfb4af39d29dbb00c68..3234eb315d5a8d1609aa1c4e3bacc6227c799f89 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -3,12 +3,15 @@ package chat import ( "fmt" "strings" + "time" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/common/anim" + "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" ) @@ -26,18 +29,55 @@ type AssistantMessageItem struct { sty *styles.Styles thinkingExpanded bool thinkingBoxHeight int // Tracks the rendered thinking box height for click detection. + + spinning bool + anim *anim.Anim + hasToolCalls bool + isSummaryMessage bool + + thinkingStartedAt int64 + thinkingFinishedAt int64 } // NewAssistantMessage creates a new assistant message item. -func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, sty *styles.Styles) *AssistantMessageItem { - return &AssistantMessageItem{ - id: id, - content: content, - thinking: thinking, - finished: finished, - finish: finish, - sty: sty, +func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, hasToolCalls, isSummaryMessage bool, thinkingStartedAt, thinkingFinishedAt int64, sty *styles.Styles) *AssistantMessageItem { + m := &AssistantMessageItem{ + id: id, + content: content, + thinking: thinking, + finished: finished, + finish: finish, + hasToolCalls: hasToolCalls, + isSummaryMessage: isSummaryMessage, + thinkingStartedAt: thinkingStartedAt, + thinkingFinishedAt: thinkingFinishedAt, + sty: sty, } + + m.anim = anim.New(anim.Settings{ + Size: 15, + GradColorA: sty.Primary, + GradColorB: sty.Secondary, + LabelColor: sty.FgBase, + CycleColors: true, + }) + m.spinning = m.shouldSpin() + + return m +} + +// shouldSpin returns true if the message should show loading animation. +func (m *AssistantMessageItem) shouldSpin() bool { + if m.finished { + return false + } + if strings.TrimSpace(m.content) != "" { + return false + } + if m.hasToolCalls { + return false + } + return true } // ID implements Identifiable. @@ -62,11 +102,17 @@ func (m *AssistantMessageItem) HighlightStyle() lipgloss.Style { // Render implements list.Item. func (m *AssistantMessageItem) Render(width int) string { + if m.spinning && m.thinking == "" { + if m.isSummaryMessage { + m.anim.SetLabel("Summarizing") + } + return m.anim.View() + } + cappedWidth := min(width, maxTextWidth) content := strings.TrimSpace(m.content) thinking := strings.TrimSpace(m.thinking) - // Handle empty finished messages. if m.finished && content == "" { switch m.finish.Reason { case message.FinishReasonEndTurn: @@ -79,13 +125,10 @@ func (m *AssistantMessageItem) Render(width int) string { } var parts []string - - // Render thinking content if present. if thinking != "" { parts = append(parts, m.renderThinking(thinking, cappedWidth)) } - // Render main content. if content != "" { if len(parts) > 0 { parts = append(parts, "") @@ -96,6 +139,45 @@ func (m *AssistantMessageItem) Render(width int) string { return lipgloss.JoinVertical(lipgloss.Left, parts...) } +// Update implements list.Updatable for handling animation updates. +func (m *AssistantMessageItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + switch msg.(type) { + case anim.StepMsg: + m.spinning = m.shouldSpin() + if !m.spinning { + return m, nil + } + updatedAnim, cmd := m.anim.Update(msg) + m.anim = updatedAnim + if cmd != nil { + return m, cmd + } + } + + return m, nil +} + +// InitAnimation initializes and starts the animation. +func (m *AssistantMessageItem) InitAnimation() tea.Cmd { + m.spinning = m.shouldSpin() + return m.anim.Init() +} + +// SetContent updates the assistant message with new content. +func (m *AssistantMessageItem) SetContent(content, thinking string, finished bool, finish *message.Finish, hasToolCalls, isSummaryMessage bool, reasoning message.ReasoningContent) { + m.content = content + m.thinking = thinking + m.finished = finished + if finish != nil { + m.finish = *finish + } + m.hasToolCalls = hasToolCalls + m.isSummaryMessage = isSummaryMessage + m.thinkingStartedAt = reasoning.StartedAt + m.thinkingFinishedAt = reasoning.FinishedAt + m.spinning = m.shouldSpin() +} + // renderMarkdown renders content as markdown. func (m *AssistantMessageItem) renderMarkdown(content string, width int) string { renderer := common.MarkdownRenderer(m.sty, width) @@ -106,7 +188,7 @@ func (m *AssistantMessageItem) renderMarkdown(content string, width int) string return strings.TrimSuffix(result, "\n") } -// renderThinking renders the thinking/reasoning content. +// renderThinking renders the thinking/reasoning content with footer. func (m *AssistantMessageItem) renderThinking(thinking string, width int) string { renderer := common.PlainMarkdownRenderer(m.sty, width-2) rendered, err := renderer.Render(thinking) @@ -118,35 +200,51 @@ func (m *AssistantMessageItem) renderThinking(thinking string, width int) string lines := strings.Split(rendered, "\n") totalLines := len(lines) - // Collapse if not expanded and exceeds max height. isTruncated := totalLines > maxCollapsedThinkingHeight if !m.thinkingExpanded && isTruncated { lines = lines[totalLines-maxCollapsedThinkingHeight:] } - // Add hint if truncated and not expanded. if !m.thinkingExpanded && isTruncated { - hint := m.sty.Muted.Render(fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight)) + hint := m.sty.Chat.Message.ThinkingTruncationHint.Render( + fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight), + ) lines = append([]string{hint}, lines...) } - thinkingStyle := m.sty.Subtle.Background(m.sty.BgBaseLighter).Width(width) + thinkingStyle := m.sty.Chat.Message.ThinkingBox.Width(width) result := thinkingStyle.Render(strings.Join(lines, "\n")) - - // Track the rendered height for click detection. m.thinkingBoxHeight = lipgloss.Height(result) + var footer string + if m.thinkingStartedAt > 0 { + if m.thinkingFinishedAt > 0 { + duration := time.Duration(m.thinkingFinishedAt-m.thinkingStartedAt) * time.Second + if duration.String() != "0s" { + footer = m.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") + + m.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String()) + } + } else if m.finish.Reason == message.FinishReasonCanceled { + footer = m.sty.Chat.Message.ThinkingFooterCancelled.Render("Canceled") + } else { + m.anim.SetLabel("Thinking") + footer = m.anim.View() + } + } + + if footer != "" { + result += "\n\n" + footer + } + return result } // HandleMouseClick implements list.MouseClickable. func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { - // Only handle left clicks. if btn != ansi.MouseLeft { return false } - // Check if click is within the thinking box area. if m.thinking != "" && y < m.thinkingBoxHeight { m.thinkingExpanded = !m.thinkingExpanded return true @@ -157,13 +255,11 @@ func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) // HandleKeyPress implements list.KeyPressable. func (m *AssistantMessageItem) HandleKeyPress(msg tea.KeyPressMsg) bool { - // Only handle space key on thinking content. if m.thinking == "" { return false } if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) { - // Toggle thinking expansion. m.thinkingExpanded = !m.thinkingExpanded return true } diff --git a/internal/ui/chat/chat.go b/internal/ui/chat/chat.go index 597144e3d25cea08dd9056fd4f6ba85ba54344a9..e4b3ff1232fc9ee8715965dbe10af188ae815fef 100644 --- a/internal/ui/chat/chat.go +++ b/internal/ui/chat/chat.go @@ -4,6 +4,7 @@ package chat import ( tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" uv "github.com/charmbracelet/ultraviolet" @@ -24,6 +25,20 @@ type MessageItem interface { Identifiable } +// Animatable is implemented by items that support animation initialization. +type Animatable interface { + InitAnimation() tea.Cmd +} + +// ToolItem is implemented by tool items that support mutable updates. +type ToolItem interface { + SetResult(result message.ToolResult) + SetCancelled() + UpdateCall(call message.ToolCall) + SetNestedCalls(calls []ToolCallContext) + Context() *ToolCallContext +} + // Chat represents the chat UI model that handles chat interactions and // messages. type Chat struct { @@ -93,6 +108,31 @@ func (m *Chat) AppendItems(items ...list.Item) { m.list.ScrollToIndex(m.list.Len() - 1) } +// UpdateMessage updates an existing message by ID. Returns true if the message +// was found and updated. +func (m *Chat) UpdateMessage(id string, msg MessageItem) bool { + for i := 0; i < m.list.Len(); i++ { + item := m.list.GetItemAt(i) + if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id { + return m.list.UpdateItemAt(i, msg) + } + } + return false +} + +// GetMessage returns the message with the given ID. Returns nil if not found. +func (m *Chat) GetMessage(id string) MessageItem { + for i := 0; i < m.list.Len(); i++ { + item := m.list.GetItemAt(i) + if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id { + if msg, ok := item.(MessageItem); ok { + return msg + } + } + } + return nil +} + // Focus sets the focus state of the chat component. func (m *Chat) Focus() { m.list.Focus() @@ -182,3 +222,54 @@ func (m *Chat) HandleMouseDrag(x, y int) { func (m *Chat) HandleKeyPress(msg tea.KeyPressMsg) bool { return m.list.HandleKeyPress(msg) } + +// UpdateItems propagates a message to all items that support updates (e.g., +// for animations). Returns commands from updated items. +func (m *Chat) UpdateItems(msg tea.Msg) tea.Cmd { + return m.list.UpdateItems(msg) +} + +// ToolItemUpdater is implemented by tool items that support mutable updates. +type ToolItemUpdater interface { + SetResult(result message.ToolResult) + SetCancelled() + UpdateCall(call message.ToolCall) + SetNestedCalls(calls []ToolCallContext) + Context() *ToolCallContext +} + +// GetToolItem returns the tool item with the given ID, or nil if not found. +func (m *Chat) GetToolItem(id string) ToolItem { + for i := 0; i < m.list.Len(); i++ { + item := m.list.GetItemAt(i) + if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id { + if toolItem, ok := item.(ToolItem); ok { + return toolItem + } + } + } + return nil +} + +// InvalidateItem invalidates the render cache for the item with the given ID. +// Use after mutating an item via ToolItem methods. +func (m *Chat) InvalidateItem(id string) { + for i := 0; i < m.list.Len(); i++ { + item := m.list.GetItemAt(i) + if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id { + m.list.InvalidateItemAt(i) + return + } + } +} + +// DeleteMessage removes a message by ID. Returns true if found and deleted. +func (m *Chat) DeleteMessage(id string) bool { + for i := m.list.Len() - 1; i >= 0; i-- { + item := m.list.GetItemAt(i) + if identifiable, ok := item.(Identifiable); ok && identifiable.ID() == id { + return m.list.DeleteItemAt(i) + } + } + return false +} diff --git a/internal/ui/chat/tool_base.go b/internal/ui/chat/tool_base.go index 48e3a003c1f4fd4c712e95acc61c4c33d8964a87..d5965ffb197a97e7bd31a4789f84803fc63225e0 100644 --- a/internal/ui/chat/tool_base.go +++ b/internal/ui/chat/tool_base.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/common/anim" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" ) @@ -41,7 +42,6 @@ type ToolCallContext struct { IsNested bool Styles *styles.Styles - // NestedCalls holds child tool calls for agent/agentic_fetch. NestedCalls []ToolCallContext } @@ -56,7 +56,6 @@ func (ctx *ToolCallContext) Status() ToolStatus { } return ToolStatusSuccess } - // No result yet - check permission state. if ctx.PermissionRequested && !ctx.PermissionGranted { return ToolStatusAwaitingPermission } @@ -69,7 +68,6 @@ func (ctx *ToolCallContext) HasResult() bool { } // toolStyles provides common FocusStylable and HighlightStylable implementations. -// Embed this in tool items to avoid repeating style methods. type toolStyles struct { sty *styles.Styles } @@ -90,16 +88,41 @@ func (s toolStyles) HighlightStyle() lipgloss.Style { type toolItem struct { toolStyles id string - expanded bool // Whether truncated content is expanded. - wasTruncated bool // Whether the last render was truncated. + ctx ToolCallContext + expanded bool + wasTruncated bool + spinning bool + anim *anim.Anim } // newToolItem creates a new toolItem with the given context. func newToolItem(ctx ToolCallContext) toolItem { - return toolItem{ + animSize := 15 + if ctx.IsNested { + animSize = 10 + } + + t := toolItem{ toolStyles: toolStyles{sty: ctx.Styles}, id: ctx.Call.ID, + ctx: ctx, + spinning: shouldSpin(ctx), + anim: anim.New(anim.Settings{ + Size: animSize, + Label: "Working", + GradColorA: ctx.Styles.Primary, + GradColorB: ctx.Styles.Secondary, + LabelColor: ctx.Styles.FgBase, + CycleColors: true, + }), } + + return t +} + +// shouldSpin returns true if the tool should show animation. +func shouldSpin(ctx ToolCallContext) bool { + return !ctx.Call.Finished && !ctx.Cancelled } // ID implements Identifiable. @@ -109,25 +132,21 @@ func (t *toolItem) ID() string { // HandleMouseClick implements list.MouseClickable. func (t *toolItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { - // Only handle left clicks on truncated content. if btn != ansi.MouseLeft || !t.wasTruncated { return false } - // Toggle expansion. t.expanded = !t.expanded return true } // HandleKeyPress implements list.KeyPressable. func (t *toolItem) HandleKeyPress(msg tea.KeyPressMsg) bool { - // Only handle space key on truncated content. if !t.wasTruncated { return false } if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) { - // Toggle expansion. t.expanded = !t.expanded return true } @@ -135,6 +154,78 @@ func (t *toolItem) HandleKeyPress(msg tea.KeyPressMsg) bool { return false } +// updateAnimation handles animation updates and returns true if changed. +func (t *toolItem) updateAnimation(msg tea.Msg) (tea.Cmd, bool) { + if !t.spinning || t.anim == nil { + return nil, false + } + + switch msg.(type) { + case anim.StepMsg: + updatedAnim, cmd := t.anim.Update(msg) + t.anim = updatedAnim + return cmd, cmd != nil + } + + return nil, false +} + +// InitAnimation initializes and starts the animation. +func (t *toolItem) InitAnimation() tea.Cmd { + t.spinning = shouldSpin(t.ctx) + return t.anim.Init() +} + +// SetResult updates the tool call with a result. +func (t *toolItem) SetResult(result message.ToolResult) { + t.ctx.Result = &result + t.ctx.Call.Finished = true + t.spinning = false +} + +// SetCancelled marks the tool call as cancelled. +func (t *toolItem) SetCancelled() { + t.ctx.Cancelled = true + t.spinning = false +} + +// UpdateCall updates the tool call data. +func (t *toolItem) UpdateCall(call message.ToolCall) { + t.ctx.Call = call + if call.Finished { + t.spinning = false + } +} + +// SetNestedCalls sets the nested tool calls for agent tools. +func (t *toolItem) SetNestedCalls(calls []ToolCallContext) { + t.ctx.NestedCalls = calls +} + +// Context returns the current tool call context. +func (t *toolItem) Context() *ToolCallContext { + return &t.ctx +} + +// renderPending returns the pending state view with animation. +func (t *toolItem) renderPending() string { + icon := t.sty.Tool.IconPending.Render() + + var toolName string + if t.ctx.IsNested { + toolName = t.sty.Tool.NameNested.Render(prettifyToolName(t.ctx.Call.Name)) + } else { + toolName = t.sty.Tool.NameNormal.Render(prettifyToolName(t.ctx.Call.Name)) + } + + var animView string + if t.anim != nil { + animView = t.anim.View() + } + + return fmt.Sprintf("%s %s %s", icon, toolName, animView) +} + // unmarshalParams unmarshals JSON input into the target struct. func unmarshalParams(input string, target any) error { return json.Unmarshal([]byte(input), target) diff --git a/internal/ui/chat/tool_items.go b/internal/ui/chat/tool_items.go index 78a7e38d374607397f0af037d1afb39e32fd2ae0..97cd7408f23120509a0def21e2ac5a1381122126 100644 --- a/internal/ui/chat/tool_items.go +++ b/internal/ui/chat/tool_items.go @@ -6,11 +6,13 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "charm.land/lipgloss/v2/tree" "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/ui/list" ) // NewToolItem creates the appropriate tool item for the given context. @@ -80,24 +82,34 @@ func NewToolItem(ctx ToolCallContext) MessageItem { // BashToolItem renders bash command execution. type BashToolItem struct { toolItem - ctx ToolCallContext } func NewBashToolItem(ctx ToolCallContext) *BashToolItem { return &BashToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } +// Update implements list.Updatable. +func (m *BashToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + func (m *BashToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.BashParams unmarshalParams(m.ctx.Call.Input, ¶ms) cmd := strings.ReplaceAll(params.Command, "\n", " ") cmd = strings.ReplaceAll(cmd, "\t", " ") - // Check if this is a background job that finished if m.ctx.Call.Finished && m.ctx.HasResult() { var meta tools.BashResponseMetadata unmarshalParams(m.ctx.Result.Metadata, &meta) @@ -153,17 +165,19 @@ func (m *BashToolItem) renderBackgroundJob(params tools.BashParams, meta tools.B // JobOutputToolItem renders job output retrieval. type JobOutputToolItem struct { toolItem - ctx ToolCallContext } func NewJobOutputToolItem(ctx ToolCallContext) *JobOutputToolItem { return &JobOutputToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *JobOutputToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.JobOutputParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -191,17 +205,19 @@ func (m *JobOutputToolItem) Render(width int) string { // JobKillToolItem renders job termination. type JobKillToolItem struct { toolItem - ctx ToolCallContext } func NewJobKillToolItem(ctx ToolCallContext) *JobKillToolItem { return &JobKillToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *JobKillToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.JobKillParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -263,17 +279,19 @@ func renderJobHeader(ctx *ToolCallContext, action, pid, description string, widt // ViewToolItem renders file viewing with syntax highlighting. type ViewToolItem struct { toolItem - ctx ToolCallContext } func NewViewToolItem(ctx ToolCallContext) *ViewToolItem { return &ViewToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *ViewToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.ViewParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -290,7 +308,6 @@ func (m *ViewToolItem) Render(width int) string { return result } - // Handle image content if m.ctx.Result.Data != "" && strings.HasPrefix(m.ctx.Result.MIMEType, "image/") { body := renderImageContent(m.ctx.Result.Data, m.ctx.Result.MIMEType, "", m.ctx.Styles) return joinHeaderBody(header, body, m.ctx.Styles) @@ -306,17 +323,19 @@ func (m *ViewToolItem) Render(width int) string { // EditToolItem renders file editing with diff visualization. type EditToolItem struct { toolItem - ctx ToolCallContext } func NewEditToolItem(ctx ToolCallContext) *EditToolItem { return &EditToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *EditToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.EditParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -342,17 +361,19 @@ func (m *EditToolItem) Render(width int) string { // MultiEditToolItem renders multiple file edits with diff visualization. type MultiEditToolItem struct { toolItem - ctx ToolCallContext } func NewMultiEditToolItem(ctx ToolCallContext) *MultiEditToolItem { return &MultiEditToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *MultiEditToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.MultiEditParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -376,7 +397,6 @@ func (m *MultiEditToolItem) Render(width int) string { body := renderDiffContent(file, meta.OldContent, meta.NewContent, width-2, m.ctx.Styles, &m.toolItem) - // Add failed edits warning if any exist if len(meta.EditsFailed) > 0 { sty := m.ctx.Styles noteTag := sty.Tool.NoteTag.Render("Note") @@ -391,17 +411,19 @@ func (m *MultiEditToolItem) Render(width int) string { // WriteToolItem renders file writing with syntax-highlighted content preview. type WriteToolItem struct { toolItem - ctx ToolCallContext } func NewWriteToolItem(ctx ToolCallContext) *WriteToolItem { return &WriteToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *WriteToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.WriteParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -425,17 +447,19 @@ func (m *WriteToolItem) Render(width int) string { // GlobToolItem renders glob file pattern matching results. type GlobToolItem struct { toolItem - ctx ToolCallContext } func NewGlobToolItem(ctx ToolCallContext) *GlobToolItem { return &GlobToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *GlobToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.GlobParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -457,17 +481,19 @@ func (m *GlobToolItem) Render(width int) string { // GrepToolItem renders grep content search results. type GrepToolItem struct { toolItem - ctx ToolCallContext } func NewGrepToolItem(ctx ToolCallContext) *GrepToolItem { return &GrepToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *GrepToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.GrepParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -491,17 +517,19 @@ func (m *GrepToolItem) Render(width int) string { // LSToolItem renders directory listing results. type LSToolItem struct { toolItem - ctx ToolCallContext } func NewLSToolItem(ctx ToolCallContext) *LSToolItem { return &LSToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *LSToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.LSParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -522,17 +550,19 @@ func (m *LSToolItem) Render(width int) string { // SourcegraphToolItem renders code search results. type SourcegraphToolItem struct { toolItem - ctx ToolCallContext } func NewSourcegraphToolItem(ctx ToolCallContext) *SourcegraphToolItem { return &SourcegraphToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *SourcegraphToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.SourcegraphParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -559,17 +589,19 @@ func (m *SourcegraphToolItem) Render(width int) string { // FetchToolItem renders URL fetching with format-specific content display. type FetchToolItem struct { toolItem - ctx ToolCallContext } func NewFetchToolItem(ctx ToolCallContext) *FetchToolItem { return &FetchToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *FetchToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.FetchParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -585,7 +617,6 @@ func (m *FetchToolItem) Render(width int) string { return result } - // Use appropriate extension for syntax highlighting file := "fetch.md" switch params.Format { case "text": @@ -601,17 +632,19 @@ func (m *FetchToolItem) Render(width int) string { // AgenticFetchToolItem renders agentic URL fetching with nested tool calls. type AgenticFetchToolItem struct { toolItem - ctx ToolCallContext } func NewAgenticFetchToolItem(ctx ToolCallContext) *AgenticFetchToolItem { return &AgenticFetchToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *AgenticFetchToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.AgenticFetchParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -630,17 +663,19 @@ func (m *AgenticFetchToolItem) Render(width int) string { // WebFetchToolItem renders web page fetching. type WebFetchToolItem struct { toolItem - ctx ToolCallContext } func NewWebFetchToolItem(ctx ToolCallContext) *WebFetchToolItem { return &WebFetchToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *WebFetchToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.WebFetchParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -658,17 +693,19 @@ func (m *WebFetchToolItem) Render(width int) string { // WebSearchToolItem renders web search results. type WebSearchToolItem struct { toolItem - ctx ToolCallContext } func NewWebSearchToolItem(ctx ToolCallContext) *WebSearchToolItem { return &WebSearchToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *WebSearchToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.WebSearchParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -686,17 +723,19 @@ func (m *WebSearchToolItem) Render(width int) string { // DownloadToolItem renders file downloading. type DownloadToolItem struct { toolItem - ctx ToolCallContext } func NewDownloadToolItem(ctx ToolCallContext) *DownloadToolItem { return &DownloadToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *DownloadToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.DownloadParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -723,17 +762,19 @@ func (m *DownloadToolItem) Render(width int) string { // DiagnosticsToolItem renders project-wide diagnostic information. type DiagnosticsToolItem struct { toolItem - ctx ToolCallContext } func NewDiagnosticsToolItem(ctx ToolCallContext) *DiagnosticsToolItem { return &DiagnosticsToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *DiagnosticsToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + args := NewParamBuilder().Main("project").Build() header := renderToolHeader(&m.ctx, "Diagnostics", width, args...) @@ -748,17 +789,19 @@ func (m *DiagnosticsToolItem) Render(width int) string { // ReferencesToolItem renders LSP references search results. type ReferencesToolItem struct { toolItem - ctx ToolCallContext } func NewReferencesToolItem(ctx ToolCallContext) *ReferencesToolItem { return &ReferencesToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *ReferencesToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params tools.ReferencesParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -784,17 +827,19 @@ func (m *ReferencesToolItem) Render(width int) string { // TodosToolItem renders todo list management. type TodosToolItem struct { toolItem - ctx ToolCallContext } func NewTodosToolItem(ctx ToolCallContext) *TodosToolItem { return &TodosToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *TodosToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + sty := m.ctx.Styles var params tools.TodosParams var meta tools.TodosResponseMetadata @@ -887,17 +932,19 @@ func (m *TodosToolItem) formatTodosFromMeta(meta tools.TodosResponseMetadata, wi // AgentToolItem renders agent task execution with nested tool calls. type AgentToolItem struct { toolItem - ctx ToolCallContext } func NewAgentToolItem(ctx ToolCallContext) *AgentToolItem { return &AgentToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *AgentToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + var params agent.AgentParams unmarshalParams(m.ctx.Call.Input, ¶ms) @@ -942,7 +989,6 @@ func renderAgentBody(ctx *ToolCallContext, prompt, tagLabel, header string, widt childTools.Enumerator(roundedEnumerator(2, tagWidth-5)).String(), } - // Add pending indicator if not complete if !ctx.HasResult() { parts = append(parts, "", sty.Tool.StateWaiting.Render("Working...")) } @@ -978,34 +1024,36 @@ func roundedEnumerator(lPadding, lineWidth int) tree.Enumerator { // GenericToolItem renders unknown tool types with basic parameter display. type GenericToolItem struct { toolItem - ctx ToolCallContext } func NewGenericToolItem(ctx ToolCallContext) *GenericToolItem { return &GenericToolItem{ toolItem: newToolItem(ctx), - ctx: ctx, } } func (m *GenericToolItem) Render(width int) string { + if !m.ctx.Call.Finished && !m.ctx.Cancelled { + return m.renderPending() + } + name := prettifyToolName(m.ctx.Call.Name) // Handle media content if m.ctx.Result != nil && m.ctx.Result.Data != "" { if strings.HasPrefix(m.ctx.Result.MIMEType, "image/") { - args := NewParamBuilder().Main(m.ctx.Call.Input).Build() + args := NewParamBuilder().Main(m.toolItem.ctx.Call.Input).Build() header := renderToolHeader(&m.ctx, name, width, args...) body := renderImageContent(m.ctx.Result.Data, m.ctx.Result.MIMEType, m.ctx.Result.Content, m.ctx.Styles) return joinHeaderBody(header, body, m.ctx.Styles) } - args := NewParamBuilder().Main(m.ctx.Call.Input).Build() + args := NewParamBuilder().Main(m.toolItem.ctx.Call.Input).Build() header := renderToolHeader(&m.ctx, name, width, args...) body := renderMediaContent(m.ctx.Result.MIMEType, m.ctx.Result.Content, m.ctx.Styles) return joinHeaderBody(header, body, m.ctx.Styles) } - args := NewParamBuilder().Main(m.ctx.Call.Input).Build() + args := NewParamBuilder().Main(m.toolItem.ctx.Call.Input).Build() header := renderToolHeader(&m.ctx, name, width, args...) if result, done := renderEarlyState(&m.ctx, header, width); done { @@ -1098,3 +1146,183 @@ func truncateText(s string, width int) string { } return "…" } + +// Update implements list.Updatable. +func (m *JobOutputToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *JobKillToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *ViewToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *EditToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *MultiEditToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *WriteToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *GlobToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *GrepToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *LSToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *SourcegraphToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *FetchToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *AgenticFetchToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *WebFetchToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *WebSearchToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *DownloadToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *DiagnosticsToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *ReferencesToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *TodosToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *AgentToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} + +// Update implements list.Updatable. +func (m *GenericToolItem) Update(msg tea.Msg) (list.Item, tea.Cmd) { + cmd, changed := m.updateAnimation(msg) + if changed { + return m, cmd + } + return m, nil +} diff --git a/internal/ui/common/anim/anim.go b/internal/ui/common/anim/anim.go new file mode 100644 index 0000000000000000000000000000000000000000..838c6d6a1c7de4cc5f1a9c4554d4ddebb4bc2b64 --- /dev/null +++ b/internal/ui/common/anim/anim.go @@ -0,0 +1,446 @@ +// Package anim provides an animated spinner. +package anim + +import ( + "fmt" + "image/color" + "math/rand/v2" + "strings" + "sync/atomic" + "time" + + "github.com/zeebo/xxh3" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/lucasb-eyer/go-colorful" + + "github.com/charmbracelet/crush/internal/csync" +) + +const ( + fps = 20 + initialChar = '.' + labelGap = " " + labelGapWidth = 1 + + // Periods of ellipsis animation speed in steps. + // + // If the FPS is 20 (50 milliseconds) this means that the ellipsis will + // change every 8 frames (400 milliseconds). + ellipsisAnimSpeed = 8 + + // The maximum amount of time that can pass before a character appears. + // This is used to create a staggered entrance effect. + maxBirthOffset = time.Second + + // Number of frames to prerender for the animation. After this number + // of frames, the animation will loop. This only applies when color + // cycling is disabled. + prerenderedFrames = 10 + + // Default number of cycling chars. + defaultNumCyclingChars = 10 +) + +// Default colors for gradient. +var ( + defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff} + defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff} + defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff} +) + +var ( + availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_") + ellipsisFrames = []string{".", "..", "...", ""} +) + +// Internal ID management. Used during animating to ensure that frame messages +// are received only by spinner components that sent them. +var lastID int64 + +func nextID() int { + return int(atomic.AddInt64(&lastID, 1)) +} + +// Cache for expensive animation calculations +type animCache struct { + initialFrames [][]string + cyclingFrames [][]string + width int + labelWidth int + label []string + ellipsisFrames []string +} + +var animCacheMap = csync.NewMap[string, *animCache]() + +// settingsHash creates a hash key for the settings to use for caching +func settingsHash(opts Settings) string { + h := xxh3.New() + fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t", + opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// StepMsg is a message type used to trigger the next step in the animation. +type StepMsg struct{ id int } + +// Settings defines settings for the animation. +type Settings struct { + Size int + Label string + LabelColor color.Color + GradColorA color.Color + GradColorB color.Color + CycleColors bool +} + +// Default settings. +const () + +// Anim is a Bubble for an animated spinner. +type Anim struct { + width int + cyclingCharWidth int + label *csync.Slice[string] + labelWidth int + labelColor color.Color + startTime time.Time + birthOffsets []time.Duration + initialFrames [][]string // frames for the initial characters + initialized atomic.Bool + cyclingFrames [][]string // frames for the cycling characters + step atomic.Int64 // current main frame step + ellipsisStep atomic.Int64 // current ellipsis frame step + ellipsisFrames *csync.Slice[string] // ellipsis animation frames + id int +} + +// New creates a new Anim instance with the specified width and label. +func New(opts Settings) *Anim { + a := &Anim{} + // Validate settings. + if opts.Size < 1 { + opts.Size = defaultNumCyclingChars + } + if colorIsUnset(opts.GradColorA) { + opts.GradColorA = defaultGradColorA + } + if colorIsUnset(opts.GradColorB) { + opts.GradColorB = defaultGradColorB + } + if colorIsUnset(opts.LabelColor) { + opts.LabelColor = defaultLabelColor + } + + a.id = nextID() + a.startTime = time.Now() + a.cyclingCharWidth = opts.Size + a.labelColor = opts.LabelColor + + // Check cache first + cacheKey := settingsHash(opts) + cached, exists := animCacheMap.Get(cacheKey) + + if exists { + // Use cached values + a.width = cached.width + a.labelWidth = cached.labelWidth + a.label = csync.NewSliceFrom(cached.label) + a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames) + a.initialFrames = cached.initialFrames + a.cyclingFrames = cached.cyclingFrames + } else { + // Generate new values and cache them + a.labelWidth = lipgloss.Width(opts.Label) + + // Total width of anim, in cells. + a.width = opts.Size + if opts.Label != "" { + a.width += labelGapWidth + lipgloss.Width(opts.Label) + } + + // Render the label + a.renderLabel(opts.Label) + + // Pre-generate gradient. + var ramp []color.Color + numFrames := prerenderedFrames + if opts.CycleColors { + ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB) + numFrames = a.width * 2 + } else { + ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB) + } + + // Pre-render initial characters. + a.initialFrames = make([][]string, numFrames) + offset := 0 + for i := range a.initialFrames { + a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth) + for j := range a.initialFrames[i] { + if j+offset >= len(ramp) { + continue // skip if we run out of colors + } + + var c color.Color + if j <= a.cyclingCharWidth { + c = ramp[j+offset] + } else { + c = opts.LabelColor + } + + // Also prerender the initial character with Lip Gloss to avoid + // processing in the render loop. + a.initialFrames[i][j] = lipgloss.NewStyle(). + Foreground(c). + Render(string(initialChar)) + } + if opts.CycleColors { + offset++ + } + } + + // Prerender scrambled rune frames for the animation. + a.cyclingFrames = make([][]string, numFrames) + offset = 0 + for i := range a.cyclingFrames { + a.cyclingFrames[i] = make([]string, a.width) + for j := range a.cyclingFrames[i] { + if j+offset >= len(ramp) { + continue // skip if we run out of colors + } + + // Also prerender the color with Lip Gloss here to avoid processing + // in the render loop. + r := availableRunes[rand.IntN(len(availableRunes))] + a.cyclingFrames[i][j] = lipgloss.NewStyle(). + Foreground(ramp[j+offset]). + Render(string(r)) + } + if opts.CycleColors { + offset++ + } + } + + // Cache the results + labelSlice := make([]string, a.label.Len()) + for i, v := range a.label.Seq2() { + labelSlice[i] = v + } + ellipsisSlice := make([]string, a.ellipsisFrames.Len()) + for i, v := range a.ellipsisFrames.Seq2() { + ellipsisSlice[i] = v + } + cached = &animCache{ + initialFrames: a.initialFrames, + cyclingFrames: a.cyclingFrames, + width: a.width, + labelWidth: a.labelWidth, + label: labelSlice, + ellipsisFrames: ellipsisSlice, + } + animCacheMap.Set(cacheKey, cached) + } + + // Random assign a birth to each character for a stagged entrance effect. + a.birthOffsets = make([]time.Duration, a.width) + for i := range a.birthOffsets { + a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond + } + + return a +} + +// SetLabel updates the label text and re-renders it. +func (a *Anim) SetLabel(newLabel string) { + a.labelWidth = lipgloss.Width(newLabel) + + // Update total width + a.width = a.cyclingCharWidth + if newLabel != "" { + a.width += labelGapWidth + a.labelWidth + } + + // Re-render the label + a.renderLabel(newLabel) +} + +// renderLabel renders the label with the current label color. +func (a *Anim) renderLabel(label string) { + if a.labelWidth > 0 { + // Pre-render the label. + labelRunes := []rune(label) + a.label = csync.NewSlice[string]() + for i := range labelRunes { + rendered := lipgloss.NewStyle(). + Foreground(a.labelColor). + Render(string(labelRunes[i])) + a.label.Append(rendered) + } + + // Pre-render the ellipsis frames which come after the label. + a.ellipsisFrames = csync.NewSlice[string]() + for _, frame := range ellipsisFrames { + rendered := lipgloss.NewStyle(). + Foreground(a.labelColor). + Render(frame) + a.ellipsisFrames.Append(rendered) + } + } else { + a.label = csync.NewSlice[string]() + a.ellipsisFrames = csync.NewSlice[string]() + } +} + +// Width returns the total width of the animation. +func (a *Anim) Width() (w int) { + w = a.width + if a.labelWidth > 0 { + w += labelGapWidth + a.labelWidth + + var widestEllipsisFrame int + for _, f := range ellipsisFrames { + fw := lipgloss.Width(f) + if fw > widestEllipsisFrame { + widestEllipsisFrame = fw + } + } + w += widestEllipsisFrame + } + return w +} + +// Init starts the animation. +func (a *Anim) Init() tea.Cmd { + return a.Step() +} + +// Update processes animation steps (or not). +func (a *Anim) Update(msg tea.Msg) (*Anim, tea.Cmd) { + switch msg := msg.(type) { + case StepMsg: + if msg.id != a.id { + // Reject messages that are not for this instance. + return a, nil + } + + step := a.step.Add(1) + if int(step) >= len(a.cyclingFrames) { + a.step.Store(0) + } + + if a.initialized.Load() && a.labelWidth > 0 { + // Manage the ellipsis animation. + ellipsisStep := a.ellipsisStep.Add(1) + if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) { + a.ellipsisStep.Store(0) + } + } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset { + a.initialized.Store(true) + } + return a, a.Step() + default: + return a, nil + } +} + +// View renders the current state of the animation. +func (a *Anim) View() string { + var b strings.Builder + step := int(a.step.Load()) + for i := range a.width { + switch { + case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]: + // Birth offset not reached: render initial character. + b.WriteString(a.initialFrames[step][i]) + case i < a.cyclingCharWidth: + // Render a cycling character. + b.WriteString(a.cyclingFrames[step][i]) + case i == a.cyclingCharWidth: + // Render label gap. + b.WriteString(labelGap) + case i > a.cyclingCharWidth: + // Label. + if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok { + b.WriteString(labelChar) + } + } + } + // Render animated ellipsis at the end of the label if all characters + // have been initialized. + if a.initialized.Load() && a.labelWidth > 0 { + ellipsisStep := int(a.ellipsisStep.Load()) + if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok { + b.WriteString(ellipsisFrame) + } + } + + return b.String() +} + +// Step is a command that triggers the next step in the animation. +func (a *Anim) Step() tea.Cmd { + return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg { + return StepMsg{id: a.id} + }) +} + +// makeGradientRamp() returns a slice of colors blended between the given keys. +// Blending is done as Hcl to stay in gamut. +func makeGradientRamp(size int, stops ...color.Color) []color.Color { + if len(stops) < 2 { + return nil + } + + points := make([]colorful.Color, len(stops)) + for i, k := range stops { + points[i], _ = colorful.MakeColor(k) + } + + numSegments := len(stops) - 1 + if numSegments == 0 { + return nil + } + blended := make([]color.Color, 0, size) + + // Calculate how many colors each segment should have. + segmentSizes := make([]int, numSegments) + baseSize := size / numSegments + remainder := size % numSegments + + // Distribute the remainder across segments. + for i := range numSegments { + segmentSizes[i] = baseSize + if i < remainder { + segmentSizes[i]++ + } + } + + // Generate colors for each segment. + for i := range numSegments { + c1 := points[i] + c2 := points[i+1] + segmentSize := segmentSizes[i] + + for j := range segmentSize { + if segmentSize == 0 { + continue + } + t := float64(j) / float64(segmentSize) + c := c1.BlendHcl(c2, t) + blended = append(blended, c) + } + } + + return blended +} + +func colorIsUnset(c color.Color) bool { + if c == nil { + return true + } + _, _, _, a := c.RGBA() + return a == 0 +} diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index f9aeffd488773b212d64aef1d981452f23ba6ccf..6e72f4892be22bbcd0e2c0fdf8179470a6387ef9 100644 --- a/internal/ui/list/item.go +++ b/internal/ui/list/item.go @@ -13,6 +13,14 @@ type Item interface { Render(width int) string } +// Updatable represents an item that can handle tea.Msg updates (e.g., for +// animations or interactive state changes). +type Updatable interface { + // Update processes a message and returns an updated item and optional + // command. The returned Item should be the same type as the receiver. + Update(tea.Msg) (Item, tea.Cmd) +} + // FocusStylable represents an item that can be styled based on focus state. type FocusStylable interface { // FocusStyle returns the style to apply when the item is focused. diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 3bf3bd06fdf21ea38f19d4151730b6abbbccbb57..d8e27eb3d5762658cca646ece6f41ea680b8d276 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -417,6 +417,64 @@ func (l *List) AppendItems(items ...Item) { l.items = append(l.items, items...) } +// UpdateItemAt updates the item at the given index and invalidates its cache. +// Returns true if the index was valid and the item was updated. +func (l *List) UpdateItemAt(idx int, item Item) bool { + if idx < 0 || idx >= len(l.items) { + return false + } + l.items[idx] = item + l.invalidateItem(idx) + return true +} + +// GetItemAt returns the item at the given index. Returns nil if the index is +// out of bounds. +func (l *List) GetItemAt(idx int) Item { + if idx < 0 || idx >= len(l.items) { + return nil + } + return l.items[idx] +} + +// InvalidateItemAt invalidates the render cache for the item at the given +// index without replacing the item. Use this when you've mutated an item's +// internal state and need to force a re-render. +func (l *List) InvalidateItemAt(idx int) { + if idx >= 0 && idx < len(l.items) { + l.invalidateItem(idx) + } +} + +// DeleteItemAt removes the item at the given index. Returns true if the index +// was valid and the item was removed. +func (l *List) DeleteItemAt(idx int) bool { + if idx < 0 || idx >= len(l.items) { + return false + } + + // Remove from items slice. + l.items = append(l.items[:idx], l.items[idx+1:]...) + + // Clear and rebuild cache with shifted indices. + newCache := make(map[int]renderedItem, len(l.renderedItems)) + for i, val := range l.renderedItems { + if i < idx { + newCache[i] = val + } else if i > idx { + newCache[i-1] = val + } + } + l.renderedItems = newCache + + // Adjust selection if needed. + if l.selectedIdx >= len(l.items) && len(l.items) > 0 { + l.selectedIdx = len(l.items) - 1 + } + + return true +} + // Focus sets the focus state of the list. func (l *List) Focus() { l.focused = true @@ -700,6 +758,31 @@ func (l *List) HandleKeyPress(msg tea.KeyPressMsg) bool { return false } +// UpdateItems propagates a message to all items that implement Updatable. +// This is typically used for animation messages like anim.StepMsg. +// Returns commands from updated items. +func (l *List) UpdateItems(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + for i, item := range l.items { + if updatable, ok := item.(Updatable); ok { + updated, cmd := updatable.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + // Invalidate cache when animation updates, even if pointer is same. + l.invalidateItem(i) + } + if updated != item { + l.items[i] = updated + l.invalidateItem(i) + } + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + // findItemAtY finds the item at the given viewport y coordinate. // Returns the item index and the y offset within that item. It returns -1, -1 // if no item is found. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index a24417ecc04c06579a1753d8ab521bf4a1c5831a..c434507144763dab6f4a1d8b10de4511e1aeed2b 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -25,6 +25,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/common/anim" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" @@ -122,6 +123,10 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + + // lastUserMessageTime tracks the timestamp of the last user message for + // calculating response duration. + lastUserMessageTime int64 } // New creates a new instance of the [UI] model. @@ -227,6 +232,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lspStates = app.GetLSPStates() case pubsub.Event[mcp.Event]: m.mcpStates = mcp.GetStates() + case pubsub.Event[message.Message]: + cmds = append(cmds, m.handleMessageEvent(msg)) + case anim.StepMsg: + // Forward animation updates to chat items. + cmds = append(cmds, m.chat.UpdateItems(msg)) case tea.TerminalVersionMsg: termVersion := strings.ToLower(msg.Name) // Only enable progress bar for the following terminals. @@ -708,6 +718,12 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) { case uiFocusMain: case uiFocusEditor: switch { + case key.Matches(msg, m.keyMap.Editor.SendMessage): + text := strings.TrimSpace(m.textarea.Value()) + if text != "" { + cmds = append(cmds, m.sendMessage(text, nil)) + } + return cmds case key.Matches(msg, m.keyMap.Editor.Newline): m.textarea.InsertRune('\n') } @@ -1039,8 +1055,12 @@ func (m *UI) convertAssistantMessage(msg message.Message, toolResultMap map[stri isError := msg.FinishReason() == message.FinishReasonError isCancelled := msg.FinishReason() == message.FinishReasonCanceled - // Show assistant message if there's content, thinking, or status to display. - if content != "" || thinking != "" || isError || isCancelled { + hasToolCalls := len(msg.ToolCalls()) > 0 + reasoning := msg.ReasoningContent() + + // Show when: no tool calls yet, OR has content, OR has thinking, OR is in thinking state. + shouldShow := !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() + if shouldShow || isError || isCancelled { var finish message.Finish if fp := msg.FinishPart(); fp != nil { finish = *fp @@ -1052,6 +1072,10 @@ func (m *UI) convertAssistantMessage(msg message.Message, toolResultMap map[stri thinking, msg.IsFinished(), finish, + hasToolCalls, + msg.IsSummaryMessage, + reasoning.StartedAt, + reasoning.FinishedAt, m.com.Styles, )) } @@ -1149,3 +1173,247 @@ func renderLogo(t *styles.Styles, compact bool, width int) string { Width: width, }) } + +// ----------------------------------------------------------------------------- +// Message Event Handling +// ----------------------------------------------------------------------------- + +// handleMessageEvent processes pubsub message events (created/updated/deleted). +func (m *UI) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd { + // Ignore events for other sessions. + if m.session == nil || event.Payload.SessionID != m.session.ID { + return m.handleChildSessionEvent(event) + } + + switch event.Type { + case pubsub.CreatedEvent: + if m.chat.GetMessage(event.Payload.ID) != nil { + return nil // Already exists. + } + return m.handleNewMessage(event.Payload) + case pubsub.UpdatedEvent: + return m.handleUpdateMessage(event.Payload) + case pubsub.DeletedEvent: + m.chat.DeleteMessage(event.Payload.ID) + } + return nil +} + +// handleChildSessionEvent handles messages from child sessions (agent tools). +func (m *UI) handleChildSessionEvent(event pubsub.Event[message.Message]) tea.Cmd { + if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { + return nil + } + + // Check if this is an agent tool session. + childSessionID := event.Payload.SessionID + parentMessageID, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID) + if !ok { + return nil + } + + // Find and update the parent tool call with new nested calls. + if tool := m.chat.GetToolItem(toolCallID); tool != nil { + tool.SetNestedCalls(m.loadNestedToolCalls(parentMessageID, toolCallID)) + m.chat.InvalidateItem(toolCallID) + } + return nil +} + +// handleNewMessage routes new messages to appropriate handlers based on role. +func (m *UI) handleNewMessage(msg message.Message) tea.Cmd { + switch msg.Role { + case message.User: + return m.handleNewUserMessage(msg) + case message.Assistant: + return m.handleNewAssistantMessage(msg) + case message.Tool: + return m.handleToolResultMessage(msg) + } + return nil +} + +// handleNewUserMessage adds a new user message to the chat. +func (m *UI) handleNewUserMessage(msg message.Message) tea.Cmd { + m.lastUserMessageTime = msg.CreatedAt + userItem := chat.NewUserMessage(msg.ID, msg.Content().Text, msg.BinaryContent(), m.com.Styles) + m.chat.AppendMessages(userItem) + m.chat.ScrollToBottom() + return nil +} + +// handleNewAssistantMessage adds a new assistant message and its tool calls. +func (m *UI) handleNewAssistantMessage(msg message.Message) tea.Cmd { + var cmds []tea.Cmd + + content := strings.TrimSpace(msg.Content().Text) + thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) + hasToolCalls := len(msg.ToolCalls()) > 0 + + // Add assistant message if it has content to display. + if m.shouldShowAssistantMessage(msg) { + assistantItem := m.createAssistantItem(msg, content, thinking, hasToolCalls) + m.chat.AppendMessages(assistantItem) + cmds = append(cmds, assistantItem.InitAnimation()) + } + + // Add tool call items. + for _, tc := range msg.ToolCalls() { + ctx := m.buildToolCallContext(tc, msg, nil) + if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName { + ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID) + } + toolItem := chat.NewToolItem(ctx) + m.chat.AppendMessages(toolItem) + if animatable, ok := toolItem.(chat.Animatable); ok { + cmds = append(cmds, animatable.InitAnimation()) + } + } + + m.chat.ScrollToBottom() + return tea.Batch(cmds...) +} + +// handleUpdateMessage routes update messages based on role. +func (m *UI) handleUpdateMessage(msg message.Message) tea.Cmd { + switch msg.Role { + case message.Assistant: + return m.handleUpdateAssistantMessage(msg) + case message.Tool: + return m.handleToolResultMessage(msg) + } + return nil +} + +// handleUpdateAssistantMessage updates existing assistant message and tool calls. +func (m *UI) handleUpdateAssistantMessage(msg message.Message) tea.Cmd { + var cmds []tea.Cmd + + content := strings.TrimSpace(msg.Content().Text) + thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) + hasToolCalls := len(msg.ToolCalls()) > 0 + shouldShow := m.shouldShowAssistantMessage(msg) + + // Update or create/delete assistant message item. + existingItem := m.chat.GetMessage(msg.ID) + if existingItem != nil { + if shouldShow { + // Update existing message. + if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { + assistantItem.SetContent(content, thinking, msg.IsFinished(), msg.FinishPart(), hasToolCalls, msg.IsSummaryMessage, msg.ReasoningContent()) + m.chat.InvalidateItem(msg.ID) + } + + // Add section separator when assistant finishes with EndTurn. + if msg.FinishReason() == message.FinishReasonEndTurn { + modelName := m.getModelName(msg) + sectionItem := chat.NewSectionItem(msg, time.Unix(m.lastUserMessageTime, 0), modelName, m.com.Styles) + m.chat.AppendMessages(sectionItem) + } + } else if hasToolCalls && content == "" && thinking == "" { + // Remove if it's now just tool calls with no content. + m.chat.DeleteMessage(msg.ID) + } + } + + // Update existing or add new tool calls. + for _, tc := range msg.ToolCalls() { + if tool := m.chat.GetToolItem(tc.ID); tool != nil { + // Update existing tool call. + tool.UpdateCall(tc) + if msg.FinishReason() == message.FinishReasonCanceled { + tool.SetCancelled() + } + if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName { + tool.SetNestedCalls(m.loadNestedToolCalls(msg.ID, tc.ID)) + } + m.chat.InvalidateItem(tc.ID) + } else { + // Add new tool call. + ctx := m.buildToolCallContext(tc, msg, nil) + if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName { + ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID) + } + toolItem := chat.NewToolItem(ctx) + m.chat.AppendMessages(toolItem) + if animatable, ok := toolItem.(chat.Animatable); ok { + cmds = append(cmds, animatable.InitAnimation()) + } + } + } + + return tea.Batch(cmds...) +} + +// handleToolResultMessage updates tool calls with their results. +func (m *UI) handleToolResultMessage(msg message.Message) tea.Cmd { + for _, tr := range msg.ToolResults() { + if tool := m.chat.GetToolItem(tr.ToolCallID); tool != nil { + tool.SetResult(tr) + m.chat.InvalidateItem(tr.ToolCallID) + } + } + return nil +} + +// shouldShowAssistantMessage returns true if the message should be displayed. +func (m *UI) shouldShowAssistantMessage(msg message.Message) bool { + hasToolCalls := len(msg.ToolCalls()) > 0 + content := strings.TrimSpace(msg.Content().Text) + thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) + return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() +} + +// createAssistantItem creates a new assistant message item. +func (m *UI) createAssistantItem(msg message.Message, content, thinking string, hasToolCalls bool) *chat.AssistantMessageItem { + var finish message.Finish + if fp := msg.FinishPart(); fp != nil { + finish = *fp + } + reasoning := msg.ReasoningContent() + + return chat.NewAssistantMessage( + msg.ID, + content, + thinking, + msg.IsFinished(), + finish, + hasToolCalls, + msg.IsSummaryMessage, + reasoning.StartedAt, + reasoning.FinishedAt, + m.com.Styles, + ) +} + +// sendMessage sends a user message to the agent coordinator. +func (m *UI) sendMessage(text string, attachments []message.Attachment) tea.Cmd { + sess := m.session + + // Create a new session if we don't have one. + if sess == nil || sess.ID == "" { + newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session") + if err != nil { + return nil + } + sess = &newSession + m.session = sess + m.state = uiChat + } + + // Check if the agent coordinator is available. + if m.com.App.AgentCoordinator == nil { + return nil + } + + // Clear the textarea. + m.textarea.Reset() + m.randomizePlaceholders() + + // Run the agent in a goroutine. + sessionID := sess.ID + return func() tea.Msg { + _, _ = m.com.App.AgentCoordinator.Run(context.Background(), sessionID, text, attachments...) + return nil + } +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index b3d902d4e65b61242a17c05085282722eb272157..2177a2c0fab8425d0e209d55b59412ba7ef3fd1e 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -207,13 +207,19 @@ type Styles struct { Attachment lipgloss.Style ToolCallFocused lipgloss.Style ToolCallBlurred lipgloss.Style - ThinkingFooter lipgloss.Style SectionHeader lipgloss.Style // Section styles - for assistant response metadata SectionIcon lipgloss.Style // Model icon SectionModel lipgloss.Style // Model name SectionDuration lipgloss.Style // Response duration + + // Thinking section styles + ThinkingBox lipgloss.Style // Background for thinking content + ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint + ThinkingFooterTitle lipgloss.Style // "Thought for" text + ThinkingFooterDuration lipgloss.Style // Duration value + ThinkingFooterCancelled lipgloss.Style // "*Canceled*" text } } @@ -1066,9 +1072,15 @@ func DefaultStyles() Styles { BorderLeft(true). BorderForeground(greenDark) s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2) - s.Chat.Message.ThinkingFooter = s.Base s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2) + // Thinking section styles + s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter) + s.Chat.Message.ThinkingTruncationHint = s.Muted + s.Chat.Message.ThinkingFooterTitle = s.Muted + s.Chat.Message.ThinkingFooterDuration = s.Subtle + s.Chat.Message.ThinkingFooterCancelled = s.Subtle + // Section metadata styles s.Chat.Message.SectionIcon = s.Subtle s.Chat.Message.SectionModel = s.Muted