Detailed changes
@@ -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
}
@@ -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
+}
@@ -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)
@@ -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
+}
@@ -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
+}
@@ -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.
@@ -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.
@@ -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
+ }
+}
@@ -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