@@ -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"
@@ -42,6 +43,9 @@ type messageCmp struct {
4243 message message.Message // The underlying message content
4344 spinning bool // Whether to show loading animation
4445 anim util.Model // Animation component for loading states
46+
47+ // Thinking viewport for displaying reasoning content
48+ thinkingViewport viewport.Model
4549}
4650
4751var focusedMessageBorder = lipgloss.Border{
@@ -51,6 +55,11 @@ var focusedMessageBorder = lipgloss.Border{
5155// NewMessageCmp creates a new message component with the given message and options
5256func NewMessageCmp(msg message.Message) MessageCmp {
5357 t := styles.CurrentTheme()
58+
59+ thinkingViewport := viewport.New()
60+ thinkingViewport.SetHeight(1)
61+ thinkingViewport.KeyMap = viewport.KeyMap{}
62+
5463 m := &messageCmp{
5564 message: msg,
5665 anim: anim.New(anim.Settings{
@@ -59,6 +68,7 @@ func NewMessageCmp(msg message.Message) MessageCmp {
5968 GradColorB: t.Secondary,
6069 CycleColors: true,
6170 }),
71+ thinkingViewport: thinkingViewport,
6272 }
6373 return m
6474}
@@ -139,8 +149,38 @@ func (msg *messageCmp) style() lipgloss.Style {
139149// renderAssistantMessage renders assistant messages with optional footer information.
140150// Shows model name, response time, and finish reason when the message is complete.
141151func (m *messageCmp) renderAssistantMessage() string {
142− parts := []string{
143− m.markdownContent(),
152+ t := styles.CurrentTheme()
153+ parts := []string{}
154+ content := m.message.Content().String()
155+ thinking := m.message.IsThinking()
156+ finished := m.message.IsFinished()
157+ finishedData := m.message.FinishPart()
158+ thinkingContent := ""
159+
160+ if thinking || m.message.ReasoningContent().Thinking != "" {
161+ thinkingContent = m.renderThinkingContent()
162+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
163+ content = ""
164+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
165+ content = "*Canceled*"
166+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
167+ errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
168+ truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
169+ title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
170+ details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
171+ // Handle error messages differently
172+ return fmt.Sprintf("%s\n\n%s", title, details)
173+ }
174+
175+ if thinkingContent != "" {
176+ parts = append(parts, thinkingContent)
177+ }
178+
179+ if content != "" {
180+ if thinkingContent != "" {
181+ parts = append(parts, "")
182+ }
183+ parts = append(parts, m.toMarkdown(content))
144184 }
145185
146186 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
@@ -152,7 +192,7 @@ func (m *messageCmp) renderAssistantMessage() string {
152192func (m *messageCmp) renderUserMessage() string {
153193 t := styles.CurrentTheme()
154194 parts := []string{
155− m.markdownContent(),
195+ m.toMarkdown(m.message.Content().String()),
156196 }
157197 attachmentStyles := t.S().Text.
158198 MarginLeft(1).
@@ -182,34 +222,41 @@ func (m *messageCmp) toMarkdown(content string) string {
182222 return strings.TrimSuffix(rendered, "\n")
183223}
184224
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 {
225+func (m *messageCmp) renderThinkingContent() string {
188226 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)
227+ reasoningContent := m.message.ReasoningContent()
228+ if reasoningContent.Thinking == "" {
229+ return ""
230+ }
231+ lines := strings.Split(reasoningContent.Thinking, "\n")
232+ var content strings.Builder
233+ lineStyle := t.S().Muted.Background(t.BgBaseLighter)
234+ for _, line := range lines {
235+ if line == "" {
236+ continue
237+ }
238+ content.WriteString(lineStyle.Width(m.textWidth()-2).Render(line) + "\n")
239+ }
240+ fullContent := content.String()
241+ height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
242+ m.thinkingViewport.SetHeight(height)
243+ m.thinkingViewport.SetWidth(m.textWidth())
244+ m.thinkingViewport.SetContent(fullContent)
245+ m.thinkingViewport.GotoBottom()
246+ var footer string
247+ if reasoningContent.StartedAt > 0 {
248+ duration := m.message.ThinkingDuration()
249+ opts := core.StatusOpts{
250+ Title: "Thinking...",
251+ Description: duration.String(),
252+ }
253+ if reasoningContent.FinishedAt > 0 {
254+ opts.NoIcon = true
255+ opts.Title = "Thought for"
210256 }
257+ footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
211258 }
212− return m.toMarkdown(content)
259+ return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
213260}
214261
215262// shouldSpin determines whether the message should show a loading animation.
@@ -257,8 +304,8 @@ func (m *messageCmp) GetSize() (int, int) {
257304
258305// SetSize updates the width of the message component for text wrapping
259306func (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)
307+ m.width = util.Clamp(width, 1, 120)
308+ m.thinkingViewport.SetWidth(m.width - 4)
262309 return nil
263310}
264311