on main help sign in
Merge pull request #200 from charmbracelet/anthropic-thinking
c340f66Kujtim Hoxha
internal/llm/agent/agent.go+7−1
@@ -600,12 +600,17 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
600
600
601 switch event.Type {
601 switch event.Type {
602 case provider.EventThinkingDelta:
602 case provider.EventThinkingDelta:
603− assistantMsg.AppendReasoningContent(event.Content)
603+ assistantMsg.AppendReasoningContent(event.Thinking)
604+ return a.messages.Update(ctx, *assistantMsg)
605+ case provider.EventSignatureDelta:
606+ assistantMsg.AppendReasoningSignature(event.Signature)
604 return a.messages.Update(ctx, *assistantMsg)
607 return a.messages.Update(ctx, *assistantMsg)
605 case provider.EventContentDelta:
608 case provider.EventContentDelta:
609+ assistantMsg.FinishThinking()
606 assistantMsg.AppendContent(event.Content)
610 assistantMsg.AppendContent(event.Content)
607 return a.messages.Update(ctx, *assistantMsg)
611 return a.messages.Update(ctx, *assistantMsg)
608 case provider.EventToolUseStart:
612 case provider.EventToolUseStart:
613+ assistantMsg.FinishThinking()
609 slog.Info("Tool call started", "toolCall", event.ToolCall)
614 slog.Info("Tool call started", "toolCall", event.ToolCall)
610 assistantMsg.AddToolCall(*event.ToolCall)
615 assistantMsg.AddToolCall(*event.ToolCall)
611 return a.messages.Update(ctx, *assistantMsg)
616 return a.messages.Update(ctx, *assistantMsg)
@@ -619,6 +624,7 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
619 case provider.EventError:
624 case provider.EventError:
620 return event.Error
625 return event.Error
621 case provider.EventComplete:
626 case provider.EventComplete:
627+ assistantMsg.FinishThinking()
622 assistantMsg.SetToolCalls(event.Response.ToolCalls)
628 assistantMsg.SetToolCalls(event.Response.ToolCalls)
623 assistantMsg.AddFinish(event.Response.FinishReason, "", "")
629 assistantMsg.AddFinish(event.Response.FinishReason, "", "")
624 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
630 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
internal/llm/prompt/coder.go+5−2
@@ -74,7 +74,7 @@ When making changes to files, first understand the file's code conventions. Mimi
74- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
74- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
75
75
76# Code style
76# Code style
77−- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
77+- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
78
78
79- If completing the user's task requires writing or modifying files:
79- If completing the user's task requires writing or modifying files:
80 - Your code and final answer should follow these _CODING GUIDELINES_:
80 - Your code and final answer should follow these _CODING GUIDELINES_:
@@ -204,7 +204,7 @@ When making changes to files, first understand the file's code conventions. Mimi
204- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
204- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
205
205
206# Code style
206# Code style
207−- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
207+- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
208
208
209# Doing tasks
209# Doing tasks
210The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
210The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
@@ -249,6 +249,9 @@ When you spend time searching for commands to typecheck, lint, build, or test, y
249- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
249- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
250- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
250- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
251
251
252+# Code style
253+- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
254+
252# Primary Workflows
255# Primary Workflows
253
256
254## Software Engineering Tasks
257## Software Engineering Tasks
internal/llm/provider/anthropic.go+16−6
@@ -72,6 +72,13 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
72
72
73 case message.Assistant:
73 case message.Assistant:
74 blocks := []anthropic.ContentBlockParamUnion{}
74 blocks := []anthropic.ContentBlockParamUnion{}
75+
76+ // Add thinking blocks first if present (required when thinking is enabled with tool use)
77+ if reasoningContent := msg.ReasoningContent(); reasoningContent.Thinking != "" {
78+ thinkingBlock := anthropic.NewThinkingBlock(reasoningContent.Signature, reasoningContent.Thinking)
79+ blocks = append(blocks, thinkingBlock)
80+ }
81+
75 if msg.Content().String() != "" {
82 if msg.Content().String() != "" {
76 content := anthropic.NewTextBlock(msg.Content().String())
83 content := anthropic.NewTextBlock(msg.Content().String())
77 if cache && !a.providerOptions.disableCache {
84 if cache && !a.providerOptions.disableCache {
@@ -159,16 +166,14 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
159 }
166 }
160 temperature := anthropic.Float(0)
167 temperature := anthropic.Float(0)
161
168
162− if a.Model().CanReason && modelConfig.Think {
163− thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(a.providerOptions.maxTokens) * 0.8))
164− temperature = anthropic.Float(1)
165− }
166−
167 maxTokens := model.DefaultMaxTokens
169 maxTokens := model.DefaultMaxTokens
168 if modelConfig.MaxTokens > 0 {
170 if modelConfig.MaxTokens > 0 {
169 maxTokens = modelConfig.MaxTokens
171 maxTokens = modelConfig.MaxTokens
170 }
172 }
171−
173+ if a.Model().CanReason && modelConfig.Think {
174+ thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(maxTokens) * 0.8))
175+ temperature = anthropic.Float(1)
176+ }
172 // Override max tokens if set in provider options
177 // Override max tokens if set in provider options
173 if a.providerOptions.maxTokens > 0 {
178 if a.providerOptions.maxTokens > 0 {
174 maxTokens = a.providerOptions.maxTokens
179 maxTokens = a.providerOptions.maxTokens
@@ -300,6 +305,11 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
300 Type: EventThinkingDelta,
305 Type: EventThinkingDelta,
301 Thinking: event.Delta.Thinking,
306 Thinking: event.Delta.Thinking,
302 }
307 }
308+ } else if event.Delta.Type == "signature_delta" && event.Delta.Signature != "" {
309+ eventChan <- ProviderEvent{
310+ Type: EventSignatureDelta,
311+ Signature: event.Delta.Signature,
312+ }
303 } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
313 } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
304 eventChan <- ProviderEvent{
314 eventChan <- ProviderEvent{
305 Type: EventContentDelta,
315 Type: EventContentDelta,
internal/llm/provider/provider.go+17−15
@@ -15,16 +15,17 @@ type EventType string
15const maxRetries = 8
15const maxRetries = 8
16
16
17const (
17const (
18− EventContentStart EventType = "content_start"
18+ EventContentStart EventType = "content_start"
19− EventToolUseStart EventType = "tool_use_start"
19+ EventToolUseStart EventType = "tool_use_start"
20− EventToolUseDelta EventType = "tool_use_delta"
20+ EventToolUseDelta EventType = "tool_use_delta"
21− EventToolUseStop EventType = "tool_use_stop"
21+ EventToolUseStop EventType = "tool_use_stop"
22− EventContentDelta EventType = "content_delta"
22+ EventContentDelta EventType = "content_delta"
23− EventThinkingDelta EventType = "thinking_delta"
23+ EventThinkingDelta EventType = "thinking_delta"
24+ EventSignatureDelta EventType = "signature_delta"
24− EventContentStop EventType = "content_stop"
25+ EventContentStop EventType = "content_stop"
25− EventComplete EventType = "complete"
26+ EventComplete EventType = "complete"
26− EventError EventType = "error"
27+ EventError EventType = "error"
27− EventWarning EventType = "warning"
28+ EventWarning EventType = "warning"
28)
29)
29
30
30type TokenUsage struct {
31type TokenUsage struct {
@@ -44,11 +45,12 @@ type ProviderResponse struct {
44type ProviderEvent struct {
45type ProviderEvent struct {
45 Type EventType
46 Type EventType
46
47
47− Content string
48+ Content string
48− Thinking string
49+ Thinking string
50+ Signature string
49− Response *ProviderResponse
51+ Response *ProviderResponse
50− ToolCall *message.ToolCall
52+ ToolCall *message.ToolCall
51− Error error
53+ Error error
52}
54}
53type Provider interface {
55type Provider interface {
54 SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
56 SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
internal/message/content.go+59−3
@@ -36,7 +36,10 @@ type ContentPart interface {
36}
36}
37
37
38type ReasoningContent struct {
38type ReasoningContent struct {
39− Thinking string `json:"thinking"`
39+ Thinking string `json:"thinking"`
40+ Signature string `json:"signature"`
41+ StartedAt int64 `json:"started_at,omitempty"`
42+ FinishedAt int64 `json:"finished_at,omitempty"`
40}
43}
41
44
42func (tc ReasoningContent) String() string {
45func (tc ReasoningContent) String() string {
@@ -230,15 +233,68 @@ func (m *Message) AppendReasoningContent(delta string) {
230 found := false
233 found := false
231 for i, part := range m.Parts {
234 for i, part := range m.Parts {
232 if c, ok := part.(ReasoningContent); ok {
235 if c, ok := part.(ReasoningContent); ok {
233− m.Parts[i] = ReasoningContent{Thinking: c.Thinking + delta}
236+ m.Parts[i] = ReasoningContent{
237+ Thinking: c.Thinking + delta,
238+ Signature: c.Signature,
239+ StartedAt: c.StartedAt,
240+ FinishedAt: c.FinishedAt,
241+ }
234 found = true
242 found = true
235 }
243 }
236 }
244 }
237 if !found {
245 if !found {
238− m.Parts = append(m.Parts, ReasoningContent{Thinking: delta})
246+ m.Parts = append(m.Parts, ReasoningContent{
247+ Thinking: delta,
248+ StartedAt: time.Now().Unix(),
249+ })
250+ }
251+}
252+
253+func (m *Message) AppendReasoningSignature(signature string) {
254+ for i, part := range m.Parts {
255+ if c, ok := part.(ReasoningContent); ok {
256+ m.Parts[i] = ReasoningContent{
257+ Thinking: c.Thinking,
258+ Signature: c.Signature + signature,
259+ StartedAt: c.StartedAt,
260+ FinishedAt: c.FinishedAt,
261+ }
262+ return
263+ }
264+ }
265+ m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
266+}
267+
268+func (m *Message) FinishThinking() {
269+ for i, part := range m.Parts {
270+ if c, ok := part.(ReasoningContent); ok {
271+ if c.FinishedAt == 0 {
272+ m.Parts[i] = ReasoningContent{
273+ Thinking: c.Thinking,
274+ Signature: c.Signature,
275+ StartedAt: c.StartedAt,
276+ FinishedAt: time.Now().Unix(),
277+ }
278+ }
279+ return
280+ }
239 }
281 }
240}
282}
241
283
284+func (m *Message) ThinkingDuration() time.Duration {
285+ reasoning := m.ReasoningContent()
286+ if reasoning.StartedAt == 0 {
287+ return 0
288+ }
289+
290+ endTime := reasoning.FinishedAt
291+ if endTime == 0 {
292+ endTime = time.Now().Unix()
293+ }
294+
295+ return time.Duration(endTime-reasoning.StartedAt) * time.Second
296+}
297+
242func (m *Message) FinishToolCall(toolCallID string) {
298func (m *Message) FinishToolCall(toolCallID string) {
243 for i, part := range m.Parts {
299 for i, part := range m.Parts {
244 if c, ok := part.(ToolCall); ok {
300 if c, ok := part.(ToolCall); ok {
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+7−6
@@ -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,12 +325,12 @@ 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.
331func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
332func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
332− return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
333+ return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
333}
334}
334
335
335// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
336// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
internal/tui/components/chat/messages/messages.go+100−40
@@ -6,6 +6,7 @@ import (
6 "strings"
6 "strings"
7 "time"
7 "time"
8
8
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
32}
34}
33
35
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
47+
48+ // Thinking viewport for displaying reasoning content
49+ thinkingViewport viewport.Model
45}
50}
46
51
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()
59+
60+ thinkingViewport := viewport.New()
61+ thinkingViewport.SetHeight(1)
62+ thinkingViewport.KeyMap = viewport.KeyMap{}
63+
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,
61 }),
71 }),
72+ thinkingViewport: thinkingViewport,
62 }
73 }
63 return m
74 return m
64}
75}
@@ -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
83 }
94 }
84 }
95 }
@@ -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())
93 }
104 }
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
109}
120}
110
121
122+func (m *messageCmp) SetMessage(msg message.Message) {
123+ m.message = msg
124+}
125+
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 := ""
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))
144 }
190 }
145
191
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()),
156 }
202 }
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")
183}
229}
184
230
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)
210 }
247 }
211 }
248 }
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
213}
272}
214
273
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
228 }
287 }
288+ if len(m.message.ToolCalls()) > 0 {
289+ return false
290+ }
229 return true
291 return true
230}
292}
231
293
232−// Focus management methods
233−
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) {
257
317
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
263}
323}
264
324
internal/tui/components/dialogs/commands/commands.go+27
@@ -6,6 +6,8 @@ import (
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/charmbracelet/lipgloss/v2"
8
8
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
9 "github.com/charmbracelet/crush/internal/llm/prompt"
11 "github.com/charmbracelet/crush/internal/llm/prompt"
10 "github.com/charmbracelet/crush/internal/tui/components/chat"
12 "github.com/charmbracelet/crush/internal/tui/components/chat"
11 "github.com/charmbracelet/crush/internal/tui/components/completions"
13 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -58,6 +60,7 @@ type (
58 SwitchSessionsMsg struct{}
60 SwitchSessionsMsg struct{}
59 SwitchModelMsg struct{}
61 SwitchModelMsg struct{}
60 ToggleCompactModeMsg struct{}
62 ToggleCompactModeMsg struct{}
63+ ToggleThinkingMsg struct{}
61 CompactMsg struct {
64 CompactMsg struct {
62 SessionID string
65 SessionID string
63 }
66 }
@@ -260,6 +263,30 @@ func (c *commandDialogCmp) defaultCommands() []Command {
260 },
263 },
261 })
264 })
262 }
265 }
266+
267+ // Only show thinking toggle for Anthropic models that can reason
268+ cfg := config.Get()
269+ if agentCfg, ok := cfg.Agents["coder"]; ok {
270+ providerCfg := cfg.GetProviderForModel(agentCfg.Model)
271+ model := cfg.GetModelByType(agentCfg.Model)
272+ if providerCfg != nil && model != nil &&
273+ providerCfg.Type == provider.TypeAnthropic && model.CanReason {
274+ selectedModel := cfg.Models[agentCfg.Model]
275+ status := "Enable"
276+ if selectedModel.Think {
277+ status = "Disable"
278+ }
279+ commands = append(commands, Command{
280+ ID: "toggle_thinking",
281+ Title: status + " Thinking Mode",
282+ Description: "Toggle model thinking for reasoning-capable models",
283+ Handler: func(cmd Command) tea.Cmd {
284+ return util.CmdHandler(ToggleThinkingMsg{})
285+ },
286+ })
287+ }
288+ }
289+
263 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
290 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
264 if c.wWidth > 120 && c.sessionID != "" {
291 if c.wWidth > 120 && c.sessionID != "" {
265 commands = append(commands, Command{
292 commands = append(commands, Command{
internal/tui/page/chat/chat.go+33
@@ -183,6 +183,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
183 cmd = p.updateCompactConfig(false)
183 cmd = p.updateCompactConfig(false)
184 }
184 }
185 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
185 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
186+ case commands.ToggleThinkingMsg:
187+ return p, p.toggleThinking()
186 case pubsub.Event[session.Session]:
188 case pubsub.Event[session.Session]:
187 u, cmd := p.header.Update(msg)
189 u, cmd := p.header.Update(msg)
188 p.header = u.(header.Header)
190 p.header = u.(header.Header)
@@ -409,6 +411,35 @@ func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
409 }
411 }
410}
412}
411
413
414+func (p *chatPage) toggleThinking() tea.Cmd {
415+ return func() tea.Msg {
416+ cfg := config.Get()
417+ agentCfg := cfg.Agents["coder"]
418+ currentModel := cfg.Models[agentCfg.Model]
419+
420+ // Toggle the thinking mode
421+ currentModel.Think = !currentModel.Think
422+ cfg.Models[agentCfg.Model] = currentModel
423+
424+ // Update the agent with the new configuration
425+ if err := p.app.UpdateAgentModel(); err != nil {
426+ return util.InfoMsg{
427+ Type: util.InfoTypeError,
428+ Msg: "Failed to update thinking mode: " + err.Error(),
429+ }
430+ }
431+
432+ status := "disabled"
433+ if currentModel.Think {
434+ status = "enabled"
435+ }
436+ return util.InfoMsg{
437+ Type: util.InfoTypeInfo,
438+ Msg: "Thinking mode " + status,
439+ }
440+ }
441+}
442+
412func (p *chatPage) setCompactMode(compact bool) {
443func (p *chatPage) setCompactMode(compact bool) {
413 if p.compact == compact {
444 if p.compact == compact {
414 return
445 return
@@ -474,6 +505,8 @@ func (p *chatPage) newSession() tea.Cmd {
474
505
475 p.session = session.Session{}
506 p.session = session.Session{}
476 p.focusedPane = PanelTypeEditor
507 p.focusedPane = PanelTypeEditor
508+ p.editor.Focus()
509+ p.chat.Blur()
477 p.isCanceling = false
510 p.isCanceling = false
478 return tea.Batch(
511 return tea.Batch(
479 util.CmdHandler(chat.SessionClearedMsg{}),
512 util.CmdHandler(chat.SessionClearedMsg{}),