chore: cleanup the UI

Kujtim Hoxha created

Change summary

internal/tui/components/anim/anim.go              | 62 +++++++++++-----
internal/tui/components/chat/chat.go              | 11 +-
internal/tui/components/chat/messages/messages.go | 46 +++++++----
3 files changed, 77 insertions(+), 42 deletions(-)

Detailed changes

internal/tui/components/anim/anim.go 🔗

@@ -80,6 +80,7 @@ type Anim struct {
 	cyclingCharWidth int
 	label            []string
 	labelWidth       int
+	labelColor       color.Color
 	startTime        time.Time
 	birthOffsets     []time.Duration
 	initialFrames    [][]string // frames for the initial characters
@@ -112,6 +113,7 @@ func New(opts Settings) (a Anim) {
 	a.startTime = time.Now()
 	a.cyclingCharWidth = opts.Size
 	a.labelWidth = lipgloss.Width(opts.Label)
+	a.labelColor = opts.LabelColor
 
 	// Total width of anim, in cells.
 	a.width = opts.Size
@@ -119,25 +121,8 @@ func New(opts Settings) (a Anim) {
 		a.width += labelGapWidth + lipgloss.Width(opts.Label)
 	}
 
-	if a.labelWidth > 0 {
-		// Pre-render the label.
-		// XXX: We should really get the graphemes for the label, not the runes.
-		labelRunes := []rune(opts.Label)
-		a.label = make([]string, len(labelRunes))
-		for i := range a.label {
-			a.label[i] = lipgloss.NewStyle().
-				Foreground(opts.LabelColor).
-				Render(string(labelRunes[i]))
-		}
-
-		// Pre-render the ellipsis frames which come after the label.
-		a.ellipsisFrames = make([]string, len(ellipsisFrames))
-		for i, frame := range ellipsisFrames {
-			a.ellipsisFrames[i] = lipgloss.NewStyle().
-				Foreground(opts.LabelColor).
-				Render(frame)
-		}
-	}
+	// Render the label
+	a.renderLabel(opts.Label)
 
 	// Pre-generate gradient.
 	var ramp []color.Color
@@ -208,6 +193,45 @@ func New(opts Settings) (a Anim) {
 	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 = make([]string, len(labelRunes))
+		for i := range a.label {
+			a.label[i] = lipgloss.NewStyle().
+				Foreground(a.labelColor).
+				Render(string(labelRunes[i]))
+		}
+
+		// Pre-render the ellipsis frames which come after the label.
+		a.ellipsisFrames = make([]string, len(ellipsisFrames))
+		for i, frame := range ellipsisFrames {
+			a.ellipsisFrames[i] = lipgloss.NewStyle().
+				Foreground(a.labelColor).
+				Render(frame)
+		}
+	} else {
+		a.label = nil
+		a.ellipsisFrames = nil
+	}
+}
+
 // Width returns the total width of the animation.
 func (a Anim) Width() (w int) {
 	w = a.width

internal/tui/components/chat/chat.go 🔗

@@ -304,14 +304,15 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 	shouldShowMessage := m.shouldShowAssistantMessage(msg)
 	hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
 
+	var cmd tea.Cmd
 	if shouldShowMessage {
+		items := m.listCmp.Items()
+		uiMsg := items[assistantIndex].(messages.MessageCmp)
+		uiMsg.SetMessage(msg)
 		m.listCmp.UpdateItem(
 			assistantIndex,
-			messages.NewMessageCmp(
-				msg,
-			),
+			uiMsg,
 		)
-
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 			m.listCmp.AppendItem(
 				messages.NewAssistantSection(
@@ -324,7 +325,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 		m.listCmp.DeleteItem(assistantIndex)
 	}
 
-	return nil
+	return cmd
 }
 
 // shouldShowAssistantMessage determines if an assistant message should be displayed.

internal/tui/components/chat/messages/messages.go 🔗

@@ -25,11 +25,12 @@ import (
 // MessageCmp defines the interface for message components in the chat interface.
 // It combines standard UI model interfaces with message-specific functionality.
 type MessageCmp interface {
-	util.Model                   // Basic Bubble Tea model interface
-	layout.Sizeable              // Width/height management
-	layout.Focusable             // Focus state management
-	GetMessage() message.Message // Access to underlying message data
-	Spinning() bool              // Animation state for loading messages
+	util.Model                      // Basic Bubble Tea model interface
+	layout.Sizeable                 // Width/height management
+	layout.Focusable                // Focus state management
+	GetMessage() message.Message    // Access to underlying message data
+	SetMessage(msg message.Message) // Update the message content
+	Spinning() bool                 // Animation state for loading messages
 }
 
 // messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -42,7 +43,7 @@ type messageCmp struct {
 	// Core message data and state
 	message  message.Message // The underlying message content
 	spinning bool            // Whether to show loading animation
-	anim     util.Model      // Animation component for loading states
+	anim     anim.Anim       // Animation component for loading states
 
 	// Thinking viewport for displaying reasoning content
 	thinkingViewport viewport.Model
@@ -88,7 +89,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.spinning = m.shouldSpin()
 		if m.spinning {
 			u, cmd := m.anim.Update(msg)
-			m.anim = u.(util.Model)
+			m.anim = u.(anim.Anim)
 			return m, cmd
 		}
 	}
@@ -98,7 +99,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 // View renders the message component based on its current state.
 // Returns different views for spinning, user, and assistant messages.
 func (m *messageCmp) View() string {
-	if m.spinning {
+	if m.spinning && m.message.ReasoningContent().Thinking == "" {
 		return m.style().PaddingLeft(1).Render(m.anim.View())
 	}
 	if m.message.ID != "" {
@@ -118,6 +119,10 @@ func (m *messageCmp) GetMessage() message.Message {
 	return m.message
 }
 
+func (m *messageCmp) SetMessage(msg message.Message) {
+	m.message = msg
+}
+
 // textWidth calculates the available width for text content,
 // accounting for borders and padding
 func (m *messageCmp) textWidth() int {
@@ -158,6 +163,7 @@ func (m *messageCmp) renderAssistantMessage() string {
 	thinkingContent := ""
 
 	if thinking || m.message.ReasoningContent().Thinking != "" {
+		m.anim.SetLabel("Thinking")
 		thinkingContent = m.renderThinkingContent()
 	} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
 		content = ""
@@ -230,7 +236,7 @@ func (m *messageCmp) renderThinkingContent() string {
 	}
 	lines := strings.Split(reasoningContent.Thinking, "\n")
 	var content strings.Builder
-	lineStyle := t.S().Muted.Background(t.BgBaseLighter)
+	lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
 	for _, line := range lines {
 		if line == "" {
 			continue
@@ -246,15 +252,18 @@ func (m *messageCmp) renderThinkingContent() string {
 	var footer string
 	if reasoningContent.StartedAt > 0 {
 		duration := m.message.ThinkingDuration()
-		opts := core.StatusOpts{
-			Title:       "Thinking...",
-			Description: duration.String(),
-		}
 		if reasoningContent.FinishedAt > 0 {
-			opts.NoIcon = true
-			opts.Title = "Thought for"
+			m.anim.SetLabel("")
+			opts := core.StatusOpts{
+				Title:       "Thought for",
+				Description: duration.String(),
+				NoIcon:      true,
+			}
+			footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
+		} else {
+			footer = m.anim.View()
 		}
-		footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
+
 	}
 	return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
 }
@@ -273,11 +282,12 @@ func (m *messageCmp) shouldSpin() bool {
 	if m.message.Content().Text != "" {
 		return false
 	}
+	if len(m.message.ToolCalls()) > 0 {
+		return false
+	}
 	return true
 }
 
-// Focus management methods
-
 // Blur removes focus from the message component
 func (m *messageCmp) Blur() tea.Cmd {
 	m.focused = false