@@ -6,6 +6,7 @@ import (
9+ "github.com/charmbracelet/bubbles/v2/viewport"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/x/ansi"
12 "github.com/charmbracelet/x/ansi"
@@ -24,11 +25,12 @@ import (
24// MessageCmp defines the interface for message components in the chat interface.
25// MessageCmp defines the interface for message components in the chat interface.
25// It combines standard UI model interfaces with message-specific functionality.
26// It combines standard UI model interfaces with message-specific functionality.
26type MessageCmp interface {
27type MessageCmp interface {
27− util.Model // Basic Bubble Tea model interface
28+ util.Model // Basic Bubble Tea model interface
28− layout.Sizeable // Width/height management
29+ layout.Sizeable // Width/height management
29− layout.Focusable // Focus state management
30+ layout.Focusable // Focus state management
30− 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
31− Spinning() bool // Animation state for loading messages
33+ Spinning() bool // Animation state for loading messages
34// messageCmp implements the MessageCmp interface for displaying chat messages.
36// messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -41,7 +43,10 @@ type messageCmp struct {
41 // Core message data and state
43 // Core message data and state
42 message message.Message // The underlying message content
44 message message.Message // The underlying message content
43 spinning bool // Whether to show loading animation
45 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
48+ // Thinking viewport for displaying reasoning content
49+ thinkingViewport viewport.Model
47var focusedMessageBorder = lipgloss.Border{
52var focusedMessageBorder = lipgloss.Border{
@@ -51,6 +56,11 @@ var focusedMessageBorder = lipgloss.Border{
51// NewMessageCmp creates a new message component with the given message and options
56// NewMessageCmp creates a new message component with the given message and options
52func NewMessageCmp(msg message.Message) MessageCmp {
57func NewMessageCmp(msg message.Message) MessageCmp {
53 t := styles.CurrentTheme()
58 t := styles.CurrentTheme()
60+ thinkingViewport := viewport.New()
61+ thinkingViewport.SetHeight(1)
62+ thinkingViewport.KeyMap = viewport.KeyMap{}
54 m := &messageCmp{
64 m := &messageCmp{
55 message: msg,
65 message: msg,
56 anim: anim.New(anim.Settings{
66 anim: anim.New(anim.Settings{
@@ -59,6 +69,7 @@ func NewMessageCmp(msg message.Message) MessageCmp {
59 GradColorB: t.Secondary,
69 GradColorB: t.Secondary,
60 CycleColors: true,
70 CycleColors: true,
72+ thinkingViewport: thinkingViewport,
@@ -78,7 +89,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78 m.spinning = m.shouldSpin()
89 m.spinning = m.shouldSpin()
79 if m.spinning {
90 if m.spinning {
80 u, cmd := m.anim.Update(msg)
91 u, cmd := m.anim.Update(msg)
81− m.anim = u.(util.Model)
92+ m.anim = u.(anim.Anim)
82 return m, cmd
93 return m, cmd
@@ -88,7 +99,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88// View renders the message component based on its current state.
99// View renders the message component based on its current state.
89// Returns different views for spinning, user, and assistant messages.
100// Returns different views for spinning, user, and assistant messages.
90func (m *messageCmp) View() string {
101func (m *messageCmp) View() string {
91− if m.spinning {
102+ if m.spinning && m.message.ReasoningContent().Thinking == "" {
92 return m.style().PaddingLeft(1).Render(m.anim.View())
103 return m.style().PaddingLeft(1).Render(m.anim.View())
94 if m.message.ID != "" {
105 if m.message.ID != "" {
@@ -108,6 +119,10 @@ func (m *messageCmp) GetMessage() message.Message {
108 return m.message
119 return m.message
122+func (m *messageCmp) SetMessage(msg message.Message) {
111// textWidth calculates the available width for text content,
126// textWidth calculates the available width for text content,
112// accounting for borders and padding
127// accounting for borders and padding
113func (m *messageCmp) textWidth() int {
128func (m *messageCmp) textWidth() int {
@@ -139,8 +154,39 @@ func (msg *messageCmp) style() lipgloss.Style {
139// renderAssistantMessage renders assistant messages with optional footer information.
154// renderAssistantMessage renders assistant messages with optional footer information.
140// Shows model name, response time, and finish reason when the message is complete.
155// Shows model name, response time, and finish reason when the message is complete.
141func (m *messageCmp) renderAssistantMessage() string {
156func (m *messageCmp) renderAssistantMessage() string {
157+ t := styles.CurrentTheme()
142− parts := []string{
158+ parts := []string{}
143− m.markdownContent(),
159+ content := m.message.Content().String()
160+ thinking := m.message.IsThinking()
161+ finished := m.message.IsFinished()
162+ finishedData := m.message.FinishPart()
163+ thinkingContent := ""
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 {
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)
181+ if thinkingContent != "" {
182+ parts = append(parts, thinkingContent)
186+ if thinkingContent != "" {
187+ parts = append(parts, "")
189+ parts = append(parts, m.toMarkdown(content))
146 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
192 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
@@ -152,7 +198,7 @@ func (m *messageCmp) renderAssistantMessage() string {
152func (m *messageCmp) renderUserMessage() string {
198func (m *messageCmp) renderUserMessage() string {
153 t := styles.CurrentTheme()
199 t := styles.CurrentTheme()
154 parts := []string{
200 parts := []string{
155− m.markdownContent(),
201+ m.toMarkdown(m.message.Content().String()),
157 attachmentStyles := t.S().Text.
203 attachmentStyles := t.S().Text.
158 MarginLeft(1).
204 MarginLeft(1).
@@ -182,34 +228,47 @@ func (m *messageCmp) toMarkdown(content string) string {
182 return strings.TrimSuffix(rendered, "\n")
228 return strings.TrimSuffix(rendered, "\n")
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 {
188 t := styles.CurrentTheme()
232 t := styles.CurrentTheme()
189− content := m.message.Content().String()
233+ reasoningContent := m.message.ReasoningContent()
190− if m.message.Role == message.Assistant {
234+ if reasoningContent.Thinking == "" {
191− thinking := m.message.IsThinking()
235+ return ""
192− finished := m.message.IsFinished()
236+ }
193− finishedData := m.message.FinishPart()
237+ lines := strings.Split(reasoningContent.Thinking, "\n")
194− if thinking {
238+ var content strings.Builder
195− // Handle the thinking state
239+ lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
196− // TODO: maybe add the thinking content if available later.
240+ for i, line := range lines {
197− content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
241+ if line == "" {
198− } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
242+ continue
199− // Sometimes the LLMs respond with no content when they think the previous tool result
243+ }
200− // provides the requested question
244+ content.WriteString(lineStyle.Width(m.textWidth() - 2).Render(line))
201− content = ""
245+ if i < len(lines)-1 {
202− } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
246+ content.WriteString("\n")
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)
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()
256+ if reasoningContent.StartedAt > 0 {
257+ duration := m.message.ThinkingDuration()
258+ if reasoningContent.FinishedAt > 0 {
260+ opts := core.StatusOpts{
261+ Title: "Thought for",
262+ Description: duration.String(),
265+ footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
267+ footer = m.anim.View()
271+ return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
215// shouldSpin determines whether the message should show a loading animation.
274// shouldSpin determines whether the message should show a loading animation.
@@ -226,11 +285,12 @@ func (m *messageCmp) shouldSpin() bool {
226 if m.message.Content().Text != "" {
285 if m.message.Content().Text != "" {
227 return false
286 return false
288+ if len(m.message.ToolCalls()) > 0 {
229 return true
291 return true
232−// Focus management methods
234// Blur removes focus from the message component
294// Blur removes focus from the message component
235func (m *messageCmp) Blur() tea.Cmd {
295func (m *messageCmp) Blur() tea.Cmd {
236 m.focused = false
296 m.focused = false
@@ -257,8 +317,8 @@ func (m *messageCmp) GetSize() (int, int) {
258// SetSize updates the width of the message component for text wrapping
318// SetSize updates the width of the message component for text wrapping
259func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
319func (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)
262 return nil
322 return nil