@@ -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"
@@ -42,6 +43,9 @@ type messageCmp struct {
42 message message.Message // The underlying message content
43 message message.Message // The underlying message content
43 spinning bool // Whether to show loading animation
44 spinning bool // Whether to show loading animation
44 anim util.Model // Animation component for loading states
45 anim util.Model // Animation component for loading states
47+ // Thinking viewport for displaying reasoning content
48+ thinkingViewport viewport.Model
47var focusedMessageBorder = lipgloss.Border{
51var focusedMessageBorder = lipgloss.Border{
@@ -51,6 +55,11 @@ var focusedMessageBorder = lipgloss.Border{
51// NewMessageCmp creates a new message component with the given message and options
55// NewMessageCmp creates a new message component with the given message and options
52func NewMessageCmp(msg message.Message) MessageCmp {
56func NewMessageCmp(msg message.Message) MessageCmp {
53 t := styles.CurrentTheme()
57 t := styles.CurrentTheme()
59+ thinkingViewport := viewport.New()
60+ thinkingViewport.SetHeight(1)
61+ thinkingViewport.KeyMap = viewport.KeyMap{}
54 m := &messageCmp{
63 m := &messageCmp{
55 message: msg,
64 message: msg,
56 anim: anim.New(anim.Settings{
65 anim: anim.New(anim.Settings{
@@ -59,6 +68,7 @@ func NewMessageCmp(msg message.Message) MessageCmp {
59 GradColorB: t.Secondary,
68 GradColorB: t.Secondary,
60 CycleColors: true,
69 CycleColors: true,
71+ thinkingViewport: thinkingViewport,
@@ -139,8 +149,38 @@ func (msg *messageCmp) style() lipgloss.Style {
139// renderAssistantMessage renders assistant messages with optional footer information.
149// renderAssistantMessage renders assistant messages with optional footer information.
140// Shows model name, response time, and finish reason when the message is complete.
150// Shows model name, response time, and finish reason when the message is complete.
141func (m *messageCmp) renderAssistantMessage() string {
151func (m *messageCmp) renderAssistantMessage() string {
152+ t := styles.CurrentTheme()
142− parts := []string{
153+ parts := []string{}
143− m.markdownContent(),
154+ content := m.message.Content().String()
155+ thinking := m.message.IsThinking()
156+ finished := m.message.IsFinished()
157+ finishedData := m.message.FinishPart()
158+ thinkingContent := ""
160+ if thinking || m.message.ReasoningContent().Thinking != "" {
161+ thinkingContent = m.renderThinkingContent()
162+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
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)
175+ if thinkingContent != "" {
176+ parts = append(parts, thinkingContent)
180+ if thinkingContent != "" {
181+ parts = append(parts, "")
183+ parts = append(parts, m.toMarkdown(content))
146 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
186 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
@@ -152,7 +192,7 @@ func (m *messageCmp) renderAssistantMessage() string {
152func (m *messageCmp) renderUserMessage() string {
192func (m *messageCmp) renderUserMessage() string {
153 t := styles.CurrentTheme()
193 t := styles.CurrentTheme()
154 parts := []string{
194 parts := []string{
155− m.markdownContent(),
195+ m.toMarkdown(m.message.Content().String()),
157 attachmentStyles := t.S().Text.
197 attachmentStyles := t.S().Text.
158 MarginLeft(1).
198 MarginLeft(1).
@@ -182,34 +222,41 @@ func (m *messageCmp) toMarkdown(content string) string {
182 return strings.TrimSuffix(rendered, "\n")
222 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 {
225+func (m *messageCmp) renderThinkingContent() string {
188 t := styles.CurrentTheme()
226 t := styles.CurrentTheme()
189− content := m.message.Content().String()
227+ reasoningContent := m.message.ReasoningContent()
190− if m.message.Role == message.Assistant {
228+ if reasoningContent.Thinking == "" {
191− thinking := m.message.IsThinking()
229+ return ""
192− finished := m.message.IsFinished()
230+ }
193− finishedData := m.message.FinishPart()
231+ lines := strings.Split(reasoningContent.Thinking, "\n")
194− if thinking {
232+ var content strings.Builder
195− // Handle the thinking state
233+ lineStyle := t.S().Muted.Background(t.BgBaseLighter)
196− // TODO: maybe add the thinking content if available later.
234+ for _, line := range lines {
197− content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
235+ if line == "" {
198− } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
236+ continue
199− // Sometimes the LLMs respond with no content when they think the previous tool result
237+ }
200− // provides the requested question
238+ content.WriteString(lineStyle.Width(m.textWidth()-2).Render(line) + "\n")
202− } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
240+ fullContent := content.String()
203− content = "*Canceled*"
241+ height := util.Clamp(lipgloss.Height(fullContent), 1, 10)
204− } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
242+ m.thinkingViewport.SetHeight(height)
205− errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
243+ m.thinkingViewport.SetWidth(m.textWidth())
206− truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
244+ m.thinkingViewport.SetContent(fullContent)
207− title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
245+ m.thinkingViewport.GotoBottom()
208− details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
246+ var footer string
209− return fmt.Sprintf("%s\n\n%s", title, details)
247+ if reasoningContent.StartedAt > 0 {
248+ duration := m.message.ThinkingDuration()
249+ opts := core.StatusOpts{
250+ Title: "Thinking...",
251+ Description: duration.String(),
253+ if reasoningContent.FinishedAt > 0 {
255+ opts.Title = "Thought for"
257+ footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
212− return m.toMarkdown(content)
259+ 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.
262// shouldSpin determines whether the message should show a loading animation.
@@ -257,8 +304,8 @@ func (m *messageCmp) GetSize() (int, int) {
258// SetSize updates the width of the message component for text wrapping
305// SetSize updates the width of the message component for text wrapping
259func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
306func (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)
262 return nil
309 return nil