From 2ce602bd248f8b6bc332e4197a20ebf72013c607 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 16 Jul 2025 09:50:41 +0200 Subject: [PATCH] chore: cleanup the UI --- internal/tui/components/anim/anim.go | 62 +++++++++++++------ internal/tui/components/chat/chat.go | 11 ++-- .../tui/components/chat/messages/messages.go | 46 ++++++++------ 3 files changed, 77 insertions(+), 42 deletions(-) diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 63d365b2d5f3adf138a61a91db0b90f9edd1688d..d47c8919171d225deeb490410cef02daaf95ab16 100644 --- a/internal/tui/components/anim/anim.go +++ b/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 diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 8601182e2e46bad8ee90aac25ff763fa6bd5f752..091231039c71e24b918a755d56ba0a0de27ae509 100644 --- a/internal/tui/components/chat/chat.go +++ b/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. diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index b2d34966fe8a4d035a1fe8cda7c2d2a3d459293b..c3321ca18781312ed851ab2ece7b412609ba7281 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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