@@ -6,6 +6,7 @@ import (
66 "strings"
77 "time"
88
9+ "github.com/charmbracelet/bubbles/v2/viewport"
910 tea "github.com/charmbracelet/bubbletea/v2"
1011 "github.com/charmbracelet/lipgloss/v2"
1112 "github.com/charmbracelet/x/ansi"
@@ -24,11 +25,12 @@ import (
2425// MessageCmp defines the interface for message components in the chat interface.
2526// It combines standard UI model interfaces with message-specific functionality.
2627type MessageCmp interface {
27− util.Model // Basic Bubble Tea model interface
28− layout.Sizeable // Width/height management
29− layout.Focusable // Focus state management
30− GetMessage() message.Message // Access to underlying message data
31− Spinning() bool // Animation state for loading messages
28+ util.Model // Basic Bubble Tea model interface
29+ layout.Sizeable // Width/height management
30+ layout.Focusable // Focus state management
31+ GetMessage() message.Message // Access to underlying message data
32+ SetMessage(msg message.Message) // Update the message content
33+ Spinning() bool // Animation state for loading messages
3234}
3335
3436// messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -41,7 +43,10 @@ type messageCmp struct {
4143 // Core message data and state
4244 message message.Message // The underlying message content
4345 spinning bool // Whether to show loading animation
44− anim util.Model // Animation component for loading states
46+ anim anim.Anim // Animation component for loading states
47+
48+ // Thinking viewport for displaying reasoning content
49+ thinkingViewport viewport.Model
4550}
4651
4752var focusedMessageBorder = lipgloss.Border{
@@ -51,6 +56,11 @@ var focusedMessageBorder = lipgloss.Border{
5156// NewMessageCmp creates a new message component with the given message and options
5257func NewMessageCmp(msg message.Message) MessageCmp {
5358 t := styles.CurrentTheme()
59+
60+ thinkingViewport := viewport.New()
61+ thinkingViewport.SetHeight(1)
62+ thinkingViewport.KeyMap = viewport.KeyMap{}
63+
5464 m := &messageCmp{
5565 message: msg,
5666 anim: anim.New(anim.Settings{
@@ -59,6 +69,7 @@ func NewMessageCmp(msg message.Message) MessageCmp {
5969 GradColorB: t.Secondary,
6070 CycleColors: true,
6171 }),
72+ thinkingViewport: thinkingViewport,
6273 }
6374 return m
6475}
@@ -78,7 +89,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7889 m.spinning = m.shouldSpin()
7990 if m.spinning {
8091 u, cmd := m.anim.Update(msg)
81− m.anim = u.(util.Model)
92+ m.anim = u.(anim.Anim)
8293 return m, cmd
8394 }
8495 }
@@ -88,7 +99,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8899// View renders the message component based on its current state.
89100// Returns different views for spinning, user, and assistant messages.
90101func (m *messageCmp) View() string {
91− if m.spinning {
102+ if m.spinning && m.message.ReasoningContent().Thinking == "" {
92103 return m.style().PaddingLeft(1).Render(m.anim.View())
93104 }
94105 if m.message.ID != "" {
@@ -108,6 +119,10 @@ func (m *messageCmp) GetMessage() message.Message {
108119 return m.message
109120}
110121
122+func (m *messageCmp) SetMessage(msg message.Message) {
123+ m.message = msg
124+}
125+
111126// textWidth calculates the available width for text content,
112127// accounting for borders and padding
113128func (m *messageCmp) textWidth() int {
@@ -139,8 +154,39 @@ func (msg *messageCmp) style() lipgloss.Style {
139154// renderAssistantMessage renders assistant messages with optional footer information.
140155// Shows model name, response time, and finish reason when the message is complete.
141156func (m *messageCmp) renderAssistantMessage() string {
142− parts := []string{
143− m.markdownContent(),
157+ t := styles.CurrentTheme()
158+ parts := []string{}
159+ content := m.message.Content().String()
160+ thinking := m.message.IsThinking()
161+ finished := m.message.IsFinished()
162+ finishedData := m.message.FinishPart()
163+ thinkingContent := ""
164+
165+ if thinking || m.message.ReasoningContent().Thinking != "" {
166+ m.anim.SetLabel("Thinking")
167+ thinkingContent = m.renderThinkingContent()
168+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
169+ content = ""
170+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
171+ content = "*Canceled*"
172+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
173+ errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
174+ truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
175+ title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
176+ details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
177+ // Handle error messages differently
178+ return fmt.Sprintf("%s\n\n%s", title, details)
179+ }
180+
181+ if thinkingContent != "" {
182+ parts = append(parts, thinkingContent)
183+ }
184+
185+ if content != "" {
186+ if thinkingContent != "" {
187+ parts = append(parts, "")
188+ }
189+ parts = append(parts, m.toMarkdown(content))
144190 }
145191
146192 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
@@ -152,7 +198,7 @@ func (m *messageCmp) renderAssistantMessage() string {
152198func (m *messageCmp) renderUserMessage() string {
153199 t := styles.CurrentTheme()
154200 parts := []string{
155− m.markdownContent(),
201+ m.toMarkdown(m.message.Content().String()),
156202 }
157203 attachmentStyles := t.S().Text.
158204 MarginLeft(1).
@@ -182,34 +228,47 @@ func (m *messageCmp) toMarkdown(content string) string {
182228 return strings.TrimSuffix(rendered, "\n")
183229}
184230
185−// markdownContent processes the message content and handles special states.
186−// Returns appropriate content for thinking, finished, and error states.
187−func (m *messageCmp) markdownContent() string {
231+func (m *messageCmp) renderThinkingContent() string {
188232 t := styles.CurrentTheme()
189− content := m.message.Content().String()
190− if m.message.Role == message.Assistant {
191− thinking := m.message.IsThinking()
192− finished := m.message.IsFinished()
193− finishedData := m.message.FinishPart()
194− if thinking {
195− // Handle the thinking state
196− // TODO: maybe add the thinking content if available later.
197− content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
198− } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
199− // Sometimes the LLMs respond with no content when they think the previous tool result
200− // provides the requested question
201− content = ""
202− } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
203− content = "*Canceled*"
204− } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
205− errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
206− truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
207− title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
208− details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
209− return fmt.Sprintf("%s\n\n%s", title, details)
233+ reasoningContent := m.message.ReasoningContent()
234+ if reasoningContent.Thinking == "" {
235+ return ""
236+ }
237+ lines := strings.Split(reasoningContent.Thinking, "\n")
238+ var content strings.Builder
239+ lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
240+ for i, line := range lines {
241+ if line == "" {
242+ continue
243+ }
244+ content.WriteString(lineStyle.Width(m.textWidth() - 2).Render(line))
245+ if i < len(lines)-1 {
246+ content.WriteString("\n")
210247 }
211248 }
212− return m.toMarkdown(content)
249+ fullContent := content.String()
250+ height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
251+ m.thinkingViewport.SetHeight(height)
252+ m.thinkingViewport.SetWidth(m.textWidth())
253+ m.thinkingViewport.SetContent(fullContent)
254+ m.thinkingViewport.GotoBottom()
255+ var footer string
256+ if reasoningContent.StartedAt > 0 {
257+ duration := m.message.ThinkingDuration()
258+ if reasoningContent.FinishedAt > 0 {
259+ m.anim.SetLabel("")
260+ opts := core.StatusOpts{
261+ Title: "Thought for",
262+ Description: duration.String(),
263+ NoIcon: true,
264+ }
265+ footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
266+ } else {
267+ footer = m.anim.View()
268+ }
269+
270+ }
271+ return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
213272}
214273
215274// shouldSpin determines whether the message should show a loading animation.
@@ -226,11 +285,12 @@ func (m *messageCmp) shouldSpin() bool {
226285 if m.message.Content().Text != "" {
227286 return false
228287 }
288+ if len(m.message.ToolCalls()) > 0 {
289+ return false
290+ }
229291 return true
230292}
231293
232−// Focus management methods
233−
234294// Blur removes focus from the message component
235295func (m *messageCmp) Blur() tea.Cmd {
236296 m.focused = false
@@ -257,8 +317,8 @@ func (m *messageCmp) GetSize() (int, int) {
257317
258318// SetSize updates the width of the message component for text wrapping
259319func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
260− // For better readability, we limit the width to a maximum of 120 characters
261− m.width = min(width, 120)
320+ m.width = util.Clamp(width, 1, 120)
321+ m.thinkingViewport.SetWidth(m.width - 4)
262322 return nil
263323}
264324