on main help sign in
Merge pull request #200 from charmbracelet/anthropic-thinking

Implement Anthropic extended thinking

commitc340f6639c8dba39163f3c08fcad28a6cc42430c authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 16 Jul 2025, 10:06 (1 year ago) parentb5cbb384a81efb
10 files changed+314−92
internal/llm/agent/agent.go+7−1 internal/llm/prompt/coder.go+5−2 internal/llm/provider/anthropic.go+16−6 internal/llm/provider/provider.go+17−15 internal/message/content.go+59−3 internal/tui/components/anim/anim.go+43−19 internal/tui/components/chat/chat.go+7−6 internal/tui/components/chat/messages/messages.go+100−40 internal/tui/components/dialogs/commands/commands.go+27 internal/tui/page/chat/chat.go+33
internal/llm/agent/agent.go+7−1
@@ -600,12 +600,17 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
600600
601601 switch event.Type {
602602 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)
604607 return a.messages.Update(ctx, *assistantMsg)
605608 case provider.EventContentDelta:
609+ assistantMsg.FinishThinking()
606610 assistantMsg.AppendContent(event.Content)
607611 return a.messages.Update(ctx, *assistantMsg)
608612 case provider.EventToolUseStart:
613+ assistantMsg.FinishThinking()
609614 slog.Info("Tool call started", "toolCall", event.ToolCall)
610615 assistantMsg.AddToolCall(*event.ToolCall)
611616 return a.messages.Update(ctx, *assistantMsg)
@@ -619,6 +624,7 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
619624 case provider.EventError:
620625 return event.Error
621626 case provider.EventComplete:
627+ assistantMsg.FinishThinking()
622628 assistantMsg.SetToolCalls(event.Response.ToolCalls)
623629 assistantMsg.AddFinish(event.Response.FinishReason, "", "")
624630 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
7474- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
7575
7676# 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
7878
7979- If completing the user's task requires writing or modifying files:
8080 - 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
204204- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
205205
206206# 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
208208
209209# Doing tasks
210210The 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
249249- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
250250- **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.
251251
252+# Code style
253+- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
254+
252255# Primary Workflows
253256
254257## Software Engineering Tasks
internal/llm/provider/anthropic.go+16−6
@@ -72,6 +72,13 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
7272
7373 case message.Assistant:
7474 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+
7582 if msg.Content().String() != "" {
7683 content := anthropic.NewTextBlock(msg.Content().String())
7784 if cache && !a.providerOptions.disableCache {
@@ -159,16 +166,14 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
159166 }
160167 temperature := anthropic.Float(0)
161168
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−
167169 maxTokens := model.DefaultMaxTokens
168170 if modelConfig.MaxTokens > 0 {
169171 maxTokens = modelConfig.MaxTokens
170172 }
171−
173+ if a.Model().CanReason && modelConfig.Think {
174+ thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(maxTokens) * 0.8))
175+ temperature = anthropic.Float(1)
176+ }
172177 // Override max tokens if set in provider options
173178 if a.providerOptions.maxTokens > 0 {
174179 maxTokens = a.providerOptions.maxTokens
@@ -300,6 +305,11 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
300305 Type: EventThinkingDelta,
301306 Thinking: event.Delta.Thinking,
302307 }
308+ } else if event.Delta.Type == "signature_delta" && event.Delta.Signature != "" {
309+ eventChan <- ProviderEvent{
310+ Type: EventSignatureDelta,
311+ Signature: event.Delta.Signature,
312+ }
303313 } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
304314 eventChan <- ProviderEvent{
305315 Type: EventContentDelta,
internal/llm/provider/provider.go+17−15
@@ -15,16 +15,17 @@ type EventType string
1515const maxRetries = 8
1616
1717const (
18− EventContentStart EventType = "content_start"
19− EventToolUseStart EventType = "tool_use_start"
20− EventToolUseDelta EventType = "tool_use_delta"
21− EventToolUseStop EventType = "tool_use_stop"
22− EventContentDelta EventType = "content_delta"
23− EventThinkingDelta EventType = "thinking_delta"
24− EventContentStop EventType = "content_stop"
25− EventComplete EventType = "complete"
26− EventError EventType = "error"
27− EventWarning EventType = "warning"
18+ EventContentStart EventType = "content_start"
19+ EventToolUseStart EventType = "tool_use_start"
20+ EventToolUseDelta EventType = "tool_use_delta"
21+ EventToolUseStop EventType = "tool_use_stop"
22+ EventContentDelta EventType = "content_delta"
23+ EventThinkingDelta EventType = "thinking_delta"
24+ EventSignatureDelta EventType = "signature_delta"
25+ EventContentStop EventType = "content_stop"
26+ EventComplete EventType = "complete"
27+ EventError EventType = "error"
28+ EventWarning EventType = "warning"
2829)
2930
3031type TokenUsage struct {
@@ -44,11 +45,12 @@ type ProviderResponse struct {
4445type ProviderEvent struct {
4546 Type EventType
4647
47− Content string
48− Thinking string
49− Response *ProviderResponse
50− ToolCall *message.ToolCall
51− Error error
48+ Content string
49+ Thinking string
50+ Signature string
51+ Response *ProviderResponse
52+ ToolCall *message.ToolCall
53+ Error error
5254}
5355type Provider interface {
5456 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 {
3636}
3737
3838type 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"`
4043}
4144
4245func (tc ReasoningContent) String() string {
@@ -230,15 +233,68 @@ func (m *Message) AppendReasoningContent(delta string) {
230233 found := false
231234 for i, part := range m.Parts {
232235 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+ }
234242 found = true
235243 }
236244 }
237245 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+ }
239281 }
240282}
241283
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+
242298func (m *Message) FinishToolCall(toolCallID string) {
243299 for i, part := range m.Parts {
244300 if c, ok := part.(ToolCall); ok {
internal/tui/components/anim/anim.go+43−19
@@ -80,6 +80,7 @@ type Anim struct {
8080 cyclingCharWidth int
8181 label []string
8282 labelWidth int
83+ labelColor color.Color
8384 startTime time.Time
8485 birthOffsets []time.Duration
8586 initialFrames [][]string // frames for the initial characters
@@ -112,6 +113,7 @@ func New(opts Settings) (a Anim) {
112113 a.startTime = time.Now()
113114 a.cyclingCharWidth = opts.Size
114115 a.labelWidth = lipgloss.Width(opts.Label)
116+ a.labelColor = opts.LabelColor
115117
116118 // Total width of anim, in cells.
117119 a.width = opts.Size
@@ -119,25 +121,8 @@ func New(opts Settings) (a Anim) {
119121 a.width += labelGapWidth + lipgloss.Width(opts.Label)
120122 }
121123
122− if a.labelWidth > 0 {
123− // Pre-render the label.
124− // XXX: We should really get the graphemes for the label, not the runes.
125− labelRunes := []rune(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− }
124+ // Render the label
125+ a.renderLabel(opts.Label)
141126
142127 // Pre-generate gradient.
143128 var ramp []color.Color
@@ -208,6 +193,45 @@ func New(opts Settings) (a Anim) {
208193 return a
209194}
210195
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+
211235// Width returns the total width of the animation.
212236func (a Anim) Width() (w int) {
213237 w = a.width
internal/tui/components/chat/chat.go+7−6
@@ -304,14 +304,15 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
304304 shouldShowMessage := m.shouldShowAssistantMessage(msg)
305305 hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
306306
307+ var cmd tea.Cmd
307308 if shouldShowMessage {
309+ items := m.listCmp.Items()
310+ uiMsg := items[assistantIndex].(messages.MessageCmp)
311+ uiMsg.SetMessage(msg)
308312 m.listCmp.UpdateItem(
309313 assistantIndex,
310− messages.NewMessageCmp(
311− msg,
312− ),
314+ uiMsg,
313315 )
314−
315316 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
316317 m.listCmp.AppendItem(
317318 messages.NewAssistantSection(
@@ -324,12 +325,12 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
324325 m.listCmp.DeleteItem(assistantIndex)
325326 }
326327
327− return nil
328+ return cmd
328329}
329330
330331// shouldShowAssistantMessage determines if an assistant message should be displayed.
331332func (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()
333334}
334335
335336// 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 (
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
internal/tui/components/dialogs/commands/commands.go+27
@@ -6,6 +6,8 @@ import (
66 tea "github.com/charmbracelet/bubbletea/v2"
77 "github.com/charmbracelet/lipgloss/v2"
88
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
911 "github.com/charmbracelet/crush/internal/llm/prompt"
1012 "github.com/charmbracelet/crush/internal/tui/components/chat"
1113 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -58,6 +60,7 @@ type (
5860 SwitchSessionsMsg struct{}
5961 SwitchModelMsg struct{}
6062 ToggleCompactModeMsg struct{}
63+ ToggleThinkingMsg struct{}
6164 CompactMsg struct {
6265 SessionID string
6366 }
@@ -260,6 +263,30 @@ func (c *commandDialogCmp) defaultCommands() []Command {
260263 },
261264 })
262265 }
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+
263290 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
264291 if c.wWidth > 120 && c.sessionID != "" {
265292 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) {
183183 cmd = p.updateCompactConfig(false)
184184 }
185185 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
186+ case commands.ToggleThinkingMsg:
187+ return p, p.toggleThinking()
186188 case pubsub.Event[session.Session]:
187189 u, cmd := p.header.Update(msg)
188190 p.header = u.(header.Header)
@@ -409,6 +411,35 @@ func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
409411 }
410412}
411413
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+
412443func (p *chatPage) setCompactMode(compact bool) {
413444 if p.compact == compact {
414445 return
@@ -474,6 +505,8 @@ func (p *chatPage) newSession() tea.Cmd {
474505
475506 p.session = session.Session{}
476507 p.focusedPane = PanelTypeEditor
508+ p.editor.Focus()
509+ p.chat.Blur()
477510 p.isCanceling = false
478511 return tea.Batch(
479512 util.CmdHandler(chat.SessionClearedMsg{}),