on main help sign in
feat: implement thinking mode for anthropic models
commit6c15eaf0d4ef5b9048975b98974c141894d98336 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 15 Jul 2025, 19:01 (1 year ago) parent9dabfe2
9 files changed+240−58
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/chat/chat.go+1−1 internal/tui/components/chat/messages/messages.go+77−30 internal/tui/components/dialogs/commands/commands.go+27 internal/tui/page/chat/chat.go+31
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/chat/chat.go+1−1
@@ -329,7 +329,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
329329
330330// shouldShowAssistantMessage determines if an assistant message should be displayed.
331331func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
332− return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
332+ return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
333333}
334334
335335// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
internal/tui/components/chat/messages/messages.go+77−30
@@ -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
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+31
@@ -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