on main help sign in
chore: cleanup the UI
2ce602bKujtim Hoxha
internal/tui/components/anim/anim.go+43−19
@@ -80,6 +80,7 @@ type Anim struct {
80 cyclingCharWidth int
80 cyclingCharWidth int
81 label []string
81 label []string
82 labelWidth int
82 labelWidth int
83+ labelColor color.Color
83 startTime time.Time
84 startTime time.Time
84 birthOffsets []time.Duration
85 birthOffsets []time.Duration
85 initialFrames [][]string // frames for the initial characters
86 initialFrames [][]string // frames for the initial characters
@@ -112,6 +113,7 @@ func New(opts Settings) (a Anim) {
112 a.startTime = time.Now()
113 a.startTime = time.Now()
113 a.cyclingCharWidth = opts.Size
114 a.cyclingCharWidth = opts.Size
114 a.labelWidth = lipgloss.Width(opts.Label)
115 a.labelWidth = lipgloss.Width(opts.Label)
116+ a.labelColor = opts.LabelColor
115
117
116 // Total width of anim, in cells.
118 // Total width of anim, in cells.
117 a.width = opts.Size
119 a.width = opts.Size
@@ -119,25 +121,8 @@ func New(opts Settings) (a Anim) {
119 a.width += labelGapWidth + lipgloss.Width(opts.Label)
121 a.width += labelGapWidth + lipgloss.Width(opts.Label)
120 }
122 }
121
123
122− if a.labelWidth > 0 {
123− // Pre-render the label.
124+ // Render the label
124− // XXX: We should really get the graphemes for the label, not the runes.
125− labelRunes := []rune(opts.Label)
125+ a.renderLabel(opts.Label)
126− a.label = make([]string, len(labelRunes))
127− for i := range a.label {
128− a.label[i] = lipgloss.NewStyle().
129− Foreground(opts.LabelColor).
130− Render(string(labelRunes[i]))
131− }
132−
133− // Pre-render the ellipsis frames which come after the label.
134− a.ellipsisFrames = make([]string, len(ellipsisFrames))
135− for i, frame := range ellipsisFrames {
136− a.ellipsisFrames[i] = lipgloss.NewStyle().
137− Foreground(opts.LabelColor).
138− Render(frame)
139− }
140− }
141
126
142 // Pre-generate gradient.
127 // Pre-generate gradient.
143 var ramp []color.Color
128 var ramp []color.Color
@@ -208,6 +193,45 @@ func New(opts Settings) (a Anim) {
208 return a
193 return a
209}
194}
210
195
196+// SetLabel updates the label text and re-renders it.
197+func (a *Anim) SetLabel(newLabel string) {
198+ a.labelWidth = lipgloss.Width(newLabel)
199+
200+ // Update total width
201+ a.width = a.cyclingCharWidth
202+ if newLabel != "" {
203+ a.width += labelGapWidth + a.labelWidth
204+ }
205+
206+ // Re-render the label
207+ a.renderLabel(newLabel)
208+}
209+
210+// renderLabel renders the label with the current label color.
211+func (a *Anim) renderLabel(label string) {
212+ if a.labelWidth > 0 {
213+ // Pre-render the label.
214+ labelRunes := []rune(label)
215+ a.label = make([]string, len(labelRunes))
216+ for i := range a.label {
217+ a.label[i] = lipgloss.NewStyle().
218+ Foreground(a.labelColor).
219+ Render(string(labelRunes[i]))
220+ }
221+
222+ // Pre-render the ellipsis frames which come after the label.
223+ a.ellipsisFrames = make([]string, len(ellipsisFrames))
224+ for i, frame := range ellipsisFrames {
225+ a.ellipsisFrames[i] = lipgloss.NewStyle().
226+ Foreground(a.labelColor).
227+ Render(frame)
228+ }
229+ } else {
230+ a.label = nil
231+ a.ellipsisFrames = nil
232+ }
233+}
234+
211// Width returns the total width of the animation.
235// Width returns the total width of the animation.
212func (a Anim) Width() (w int) {
236func (a Anim) Width() (w int) {
213 w = a.width
237 w = a.width
internal/tui/components/chat/chat.go+6−5
@@ -304,14 +304,15 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
304 shouldShowMessage := m.shouldShowAssistantMessage(msg)
304 shouldShowMessage := m.shouldShowAssistantMessage(msg)
305 hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
305 hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
306
306
307+ var cmd tea.Cmd
307 if shouldShowMessage {
308 if shouldShowMessage {
309+ items := m.listCmp.Items()
310+ uiMsg := items[assistantIndex].(messages.MessageCmp)
311+ uiMsg.SetMessage(msg)
308 m.listCmp.UpdateItem(
312 m.listCmp.UpdateItem(
309 assistantIndex,
313 assistantIndex,
310− messages.NewMessageCmp(
314+ uiMsg,
311− msg,
312− ),
313 )
315 )
314−
315 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
316 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
316 m.listCmp.AppendItem(
317 m.listCmp.AppendItem(
317 messages.NewAssistantSection(
318 messages.NewAssistantSection(
@@ -324,7 +325,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
324 m.listCmp.DeleteItem(assistantIndex)
325 m.listCmp.DeleteItem(assistantIndex)
325 }
326 }
326
327
327− return nil
328+ return cmd
328}
329}
329
330
330// shouldShowAssistantMessage determines if an assistant message should be displayed.
331// shouldShowAssistantMessage determines if an assistant message should be displayed.
internal/tui/components/chat/messages/messages.go+28−18
@@ -25,11 +25,12 @@ import (
25// MessageCmp defines the interface for message components in the chat interface.
25// MessageCmp defines the interface for message components in the chat interface.
26// It combines standard UI model interfaces with message-specific functionality.
26// It combines standard UI model interfaces with message-specific functionality.
27type MessageCmp interface {
27type MessageCmp interface {
28− util.Model // Basic Bubble Tea model interface
28+ util.Model // Basic Bubble Tea model interface
29− layout.Sizeable // Width/height management
29+ layout.Sizeable // Width/height management
30− layout.Focusable // Focus state management
30+ layout.Focusable // Focus state management
31− GetMessage() message.Message // Access to underlying message data
31+ GetMessage() message.Message // Access to underlying message data
32+ SetMessage(msg message.Message) // Update the message content
32− Spinning() bool // Animation state for loading messages
33+ Spinning() bool // Animation state for loading messages
33}
34}
34
35
35// messageCmp implements the MessageCmp interface for displaying chat messages.
36// messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -42,7 +43,7 @@ type messageCmp struct {
42 // Core message data and state
43 // Core message data and state
43 message message.Message // The underlying message content
44 message message.Message // The underlying message content
44 spinning bool // Whether to show loading animation
45 spinning bool // Whether to show loading animation
45− anim util.Model // Animation component for loading states
46+ anim anim.Anim // Animation component for loading states
46
47
47 // Thinking viewport for displaying reasoning content
48 // Thinking viewport for displaying reasoning content
48 thinkingViewport viewport.Model
49 thinkingViewport viewport.Model
@@ -88,7 +89,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 m.spinning = m.shouldSpin()
89 m.spinning = m.shouldSpin()
89 if m.spinning {
90 if m.spinning {
90 u, cmd := m.anim.Update(msg)
91 u, cmd := m.anim.Update(msg)
91− m.anim = u.(util.Model)
92+ m.anim = u.(anim.Anim)
92 return m, cmd
93 return m, cmd
93 }
94 }
94 }
95 }
@@ -98,7 +99,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98// View renders the message component based on its current state.
99// View renders the message component based on its current state.
99// Returns different views for spinning, user, and assistant messages.
100// Returns different views for spinning, user, and assistant messages.
100func (m *messageCmp) View() string {
101func (m *messageCmp) View() string {
101− if m.spinning {
102+ if m.spinning && m.message.ReasoningContent().Thinking == "" {
102 return m.style().PaddingLeft(1).Render(m.anim.View())
103 return m.style().PaddingLeft(1).Render(m.anim.View())
103 }
104 }
104 if m.message.ID != "" {
105 if m.message.ID != "" {
@@ -118,6 +119,10 @@ func (m *messageCmp) GetMessage() message.Message {
118 return m.message
119 return m.message
119}
120}
120
121
122+func (m *messageCmp) SetMessage(msg message.Message) {
123+ m.message = msg
124+}
125+
121// textWidth calculates the available width for text content,
126// textWidth calculates the available width for text content,
122// accounting for borders and padding
127// accounting for borders and padding
123func (m *messageCmp) textWidth() int {
128func (m *messageCmp) textWidth() int {
@@ -158,6 +163,7 @@ func (m *messageCmp) renderAssistantMessage() string {
158 thinkingContent := ""
163 thinkingContent := ""
159
164
160 if thinking || m.message.ReasoningContent().Thinking != "" {
165 if thinking || m.message.ReasoningContent().Thinking != "" {
166+ m.anim.SetLabel("Thinking")
161 thinkingContent = m.renderThinkingContent()
167 thinkingContent = m.renderThinkingContent()
162 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
168 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
163 content = ""
169 content = ""
@@ -230,7 +236,7 @@ func (m *messageCmp) renderThinkingContent() string {
230 }
236 }
231 lines := strings.Split(reasoningContent.Thinking, "\n")
237 lines := strings.Split(reasoningContent.Thinking, "\n")
232 var content strings.Builder
238 var content strings.Builder
233− lineStyle := t.S().Muted.Background(t.BgBaseLighter)
239+ lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
234 for _, line := range lines {
240 for _, line := range lines {
235 if line == "" {
241 if line == "" {
236 continue
242 continue
@@ -246,15 +252,18 @@ func (m *messageCmp) renderThinkingContent() string {
246 var footer string
252 var footer string
247 if reasoningContent.StartedAt > 0 {
253 if reasoningContent.StartedAt > 0 {
248 duration := m.message.ThinkingDuration()
254 duration := m.message.ThinkingDuration()
249− opts := core.StatusOpts{
250− Title: "Thinking...",
251− Description: duration.String(),
252− }
253 if reasoningContent.FinishedAt > 0 {
255 if reasoningContent.FinishedAt > 0 {
254− opts.NoIcon = true
256+ m.anim.SetLabel("")
257+ opts := core.StatusOpts{
255− opts.Title = "Thought for"
258+ Title: "Thought for",
259+ Description: duration.String(),
260+ NoIcon: true,
261+ }
262+ footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
263+ } else {
264+ footer = m.anim.View()
256 }
265 }
257− footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
266+
258 }
267 }
259 return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
268 return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
260}
269}
@@ -273,11 +282,12 @@ func (m *messageCmp) shouldSpin() bool {
273 if m.message.Content().Text != "" {
282 if m.message.Content().Text != "" {
274 return false
283 return false
275 }
284 }
285+ if len(m.message.ToolCalls()) > 0 {
286+ return false
287+ }
276 return true
288 return true
277}
289}
278
290
279−// Focus management methods
280−
281// Blur removes focus from the message component
291// Blur removes focus from the message component
282func (m *messageCmp) Blur() tea.Cmd {
292func (m *messageCmp) Blur() tea.Cmd {
283 m.focused = false
293 m.focused = false