on main help sign in
Merge remote-tracking branch 'origin/main' into mcp-readme
commit8e32df48e44bd00a53699c92c8cb183eecc1b8e9 authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 16 Jul 2025, 14:06 (1 year ago) parentd66797d9423d10
18 files changed+526−168
cmd/root.go−20 internal/fur/provider/provider.go+2 internal/llm/agent/agent.go+8−2 internal/llm/agent/mcp-tools.go+62−43 internal/llm/prompt/coder.go+5−2 internal/llm/provider/anthropic.go+20−7 internal/llm/provider/openai.go+23−2 internal/llm/provider/openai_test.go+91 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+99−40 internal/tui/components/chat/splash/splash.go+1 internal/tui/components/dialogs/commands/commands.go+27 internal/tui/components/dialogs/models/list.go+5−2 internal/tui/components/dialogs/permissions/permissions.go+23−6 internal/tui/page/chat/chat.go+34−1
cmd/root.go−20
@@ -6,14 +6,11 @@ import (
66 "io"
77 "log/slog"
88 "os"
9− "time"
109
1110 tea "github.com/charmbracelet/bubbletea/v2"
1211 "github.com/charmbracelet/crush/internal/app"
1312 "github.com/charmbracelet/crush/internal/config"
1413 "github.com/charmbracelet/crush/internal/db"
15− "github.com/charmbracelet/crush/internal/llm/agent"
16− "github.com/charmbracelet/crush/internal/log"
1714 "github.com/charmbracelet/crush/internal/tui"
1815 "github.com/charmbracelet/crush/internal/version"
1916 "github.com/charmbracelet/fang"
@@ -92,9 +89,6 @@ to assist developers in writing, debugging, and understanding code directly from
9289 }
9390 defer app.Shutdown()
9491
95− // Initialize MCP tools early for both modes
96− initMCPTools(ctx, app, cfg)
97−
9892 prompt, err = maybePrependStdin(prompt)
9993 if err != nil {
10094 slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
@@ -126,20 +120,6 @@ to assist developers in writing, debugging, and understanding code directly from
126120 },
127121}
128122
129−func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) {
130− go func() {
131− defer log.RecoverPanic("MCP-goroutine", nil)
132−
133− // Create a context with timeout for the initial MCP tools fetch
134− ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
135− defer cancel()
136−
137− // Set this up once with proper error handling
138− agent.GetMcpTools(ctxWithTimeout, app.Permissions, cfg)
139− slog.Info("MCP message handling goroutine exiting")
140− }()
141−}
142−
143123func Execute() {
144124 if err := fang.Execute(
145125 context.Background(),
internal/fur/provider/provider.go+2
@@ -27,6 +27,7 @@ const (
2727 InferenceProviderBedrock InferenceProvider = "bedrock"
2828 InferenceProviderVertexAI InferenceProvider = "vertexai"
2929 InferenceProviderXAI InferenceProvider = "xai"
30+ InferenceProviderGROQ InferenceProvider = "groq"
3031 InferenceProviderOpenRouter InferenceProvider = "openrouter"
3132)
3233
@@ -68,6 +69,7 @@ func KnownProviders() []InferenceProvider {
6869 InferenceProviderBedrock,
6970 InferenceProviderVertexAI,
7071 InferenceProviderXAI,
72+ InferenceProviderGROQ,
7173 InferenceProviderOpenRouter,
7274 }
7375}
internal/llm/agent/agent.go+8−2
@@ -94,7 +94,7 @@ func NewAgent(
9494) (Service, error) {
9595 ctx := context.Background()
9696 cfg := config.Get()
97− otherTools := GetMcpTools(ctx, permissions, cfg)
97+ otherTools := GetMCPTools(ctx, permissions, cfg)
9898 if len(lspClients) > 0 {
9999 otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
100100 }
@@ -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/agent/mcp-tools.go+62−43
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "fmt"
77 "log/slog"
8+ "sync"
89
910 "github.com/charmbracelet/crush/internal/config"
1011 "github.com/charmbracelet/crush/internal/llm/tools"
@@ -154,8 +155,6 @@ func NewMcpTool(name string, tool mcp.Tool, permissions permission.Service, mcpC
154155 }
155156}
156157
157−var mcpTools []tools.BaseTool
158−
159158func getTools(ctx context.Context, name string, m config.MCPConfig, permissions permission.Service, c MCPClient, workingDir string) []tools.BaseTool {
160159 var stdioTools []tools.BaseTool
161160 initRequest := mcp.InitializeRequest{}
@@ -183,52 +182,72 @@ func getTools(ctx context.Context, name string, m config.MCPConfig, permissions
183182 return stdioTools
184183}
185184
186−func GetMcpTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
187− if len(mcpTools) > 0 {
188− return mcpTools
189− }
185+var (
186+ mcpToolsOnce sync.Once
187+ mcpTools []tools.BaseTool
188+)
189+
190+func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
191+ mcpToolsOnce.Do(func() {
192+ mcpTools = doGetMCPTools(ctx, permissions, cfg)
193+ })
194+ return mcpTools
195+}
196+
197+func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
198+ var mu sync.Mutex
199+ var wg sync.WaitGroup
200+ var result []tools.BaseTool
190201 for name, m := range cfg.MCP {
191202 if m.Disabled {
192203 slog.Debug("skipping disabled mcp", "name", name)
193204 continue
194205 }
195−
196− switch m.Type {
197− case config.MCPStdio:
198− c, err := client.NewStdioMCPClient(
199− m.Command,
200− m.ResolvedEnv(),
201− m.Args...,
202− )
203− if err != nil {
204− slog.Error("error creating mcp client", "error", err)
205− continue
206− }
207−
208− mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
209− case config.MCPHttp:
210− slog.Info("creating mcp client", "name", name, "url", m.URL, "headers", m.ResolvedHeaders())
211− c, err := client.NewStreamableHttpClient(
212− m.URL,
213− transport.WithHTTPHeaders(m.ResolvedHeaders()),
214− )
215− if err != nil {
216− slog.Error("error creating mcp client", "error", err)
217− continue
218− }
219− mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
220− case config.MCPSse:
221− c, err := client.NewSSEMCPClient(
222− m.URL,
223− client.WithHeaders(m.ResolvedHeaders()),
224− )
225− if err != nil {
226− slog.Error("error creating mcp client", "error", err)
227− continue
206+ wg.Add(1)
207+ go func(name string, m config.MCPConfig) {
208+ defer wg.Done()
209+ switch m.Type {
210+ case config.MCPStdio:
211+ c, err := client.NewStdioMCPClient(
212+ m.Command,
213+ m.ResolvedEnv(),
214+ m.Args...,
215+ )
216+ if err != nil {
217+ slog.Error("error creating mcp client", "error", err)
218+ return
219+ }
220+
221+ mu.Lock()
222+ result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
223+ mu.Unlock()
224+ case config.MCPHttp:
225+ c, err := client.NewStreamableHttpClient(
226+ m.URL,
227+ transport.WithHTTPHeaders(m.ResolvedHeaders()),
228+ )
229+ if err != nil {
230+ slog.Error("error creating mcp client", "error", err)
231+ return
232+ }
233+ mu.Lock()
234+ result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
235+ mu.Unlock()
236+ case config.MCPSse:
237+ c, err := client.NewSSEMCPClient(
238+ m.URL,
239+ client.WithHeaders(m.ResolvedHeaders()),
240+ )
241+ if err != nil {
242+ slog.Error("error creating mcp client", "error", err)
243+ return
244+ }
245+ mu.Lock()
246+ result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
247+ mu.Unlock()
228248 }
229− mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
230− }
249+ }(name, m)
231250 }
232−
233− return mcpTools
251+ wg.Wait()
252+ return result
234253}
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+20−7
@@ -9,6 +9,7 @@ import (
99 "log/slog"
1010 "regexp"
1111 "strconv"
12+ "strings"
1213 "time"
1314
1415 "github.com/anthropics/anthropic-sdk-go"
@@ -72,6 +73,13 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
7273
7374 case message.Assistant:
7475 blocks := []anthropic.ContentBlockParamUnion{}
76+
77+ // Add thinking blocks first if present (required when thinking is enabled with tool use)
78+ if reasoningContent := msg.ReasoningContent(); reasoningContent.Thinking != "" {
79+ thinkingBlock := anthropic.NewThinkingBlock(reasoningContent.Signature, reasoningContent.Thinking)
80+ blocks = append(blocks, thinkingBlock)
81+ }
82+
7583 if msg.Content().String() != "" {
7684 content := anthropic.NewTextBlock(msg.Content().String())
7785 if cache && !a.providerOptions.disableCache {
@@ -159,16 +167,14 @@ func (a *anthropicClient) preparedMessages(messages []anthropic.MessageParam, to
159167 }
160168 temperature := anthropic.Float(0)
161169
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−
167170 maxTokens := model.DefaultMaxTokens
168171 if modelConfig.MaxTokens > 0 {
169172 maxTokens = modelConfig.MaxTokens
170173 }
171−
174+ if a.Model().CanReason && modelConfig.Think {
175+ thinkingParam = anthropic.ThinkingConfigParamOfEnabled(int64(float64(maxTokens) * 0.8))
176+ temperature = anthropic.Float(1)
177+ }
172178 // Override max tokens if set in provider options
173179 if a.providerOptions.maxTokens > 0 {
174180 maxTokens = a.providerOptions.maxTokens
@@ -265,6 +271,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
265271 anthropicStream := a.client.Messages.NewStreaming(
266272 ctx,
267273 preparedMessages,
274+ option.WithHeaderAdd("anthropic-beta", "interleaved-thinking-2025-05-14"),
268275 )
269276 accumulatedMessage := anthropic.Message{}
270277
@@ -300,6 +307,11 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
300307 Type: EventThinkingDelta,
301308 Thinking: event.Delta.Thinking,
302309 }
310+ } else if event.Delta.Type == "signature_delta" && event.Delta.Signature != "" {
311+ eventChan <- ProviderEvent{
312+ Type: EventSignatureDelta,
313+ Signature: event.Delta.Signature,
314+ }
303315 } else if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
304316 eventChan <- ProviderEvent{
305317 Type: EventContentDelta,
@@ -416,7 +428,8 @@ func (a *anthropicClient) shouldRetry(attempts int, err error) (bool, int64, err
416428 }
417429 }
418430
419− if apiErr.StatusCode != 429 && apiErr.StatusCode != 529 {
431+ isOverloaded := strings.Contains(apiErr.Error(), "overloaded") || strings.Contains(apiErr.Error(), "rate limit exceeded")
432+ if apiErr.StatusCode != 429 && apiErr.StatusCode != 529 && !isOverloaded {
420433 return false, 0, err
421434 }
422435
internal/llm/provider/openai.go+23−2
@@ -77,13 +77,16 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7777 Role: "assistant",
7878 }
7979
80+ hasContent := false
8081 if msg.Content().String() != "" {
82+ hasContent = true
8183 assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
8284 OfString: openai.String(msg.Content().String()),
8385 }
8486 }
8587
8688 if len(msg.ToolCalls()) > 0 {
89+ hasContent = true
8790 assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
8891 for i, call := range msg.ToolCalls() {
8992 assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
@@ -96,6 +99,10 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
9699 }
97100 }
98101 }
102+ if !hasContent {
103+ slog.Warn("There is a message without content, investigate, this should not happen")
104+ continue
105+ }
99106
100107 openaiMessages = append(openaiMessages, openai.ChatCompletionMessageParamUnion{
101108 OfAssistant: &assistantMsg,
@@ -224,6 +231,10 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
224231 return nil, retryErr
225232 }
226233
234+ if len(openaiResponse.Choices) == 0 {
235+ return nil, fmt.Errorf("received empty response from OpenAI API - check endpoint configuration")
236+ }
237+
227238 content := ""
228239 if openaiResponse.Choices[0].Message.Content != "" {
229240 content = openaiResponse.Choices[0].Message.Content
@@ -324,7 +335,9 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
324335 }
325336 if choice.FinishReason == "tool_calls" {
326337 msgToolCalls = append(msgToolCalls, currentToolCall)
327− acc.Choices[0].Message.ToolCalls = msgToolCalls
338+ if len(acc.Choices) > 0 {
339+ acc.Choices[0].Message.ToolCalls = msgToolCalls
340+ }
328341 }
329342 }
330343 }
@@ -336,7 +349,15 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
336349 slog.Debug("Response", "messages", string(jsonData))
337350 }
338351
339− resultFinishReason := acc.ChatCompletion.Choices[0].FinishReason
352+ if len(acc.Choices) == 0 {
353+ eventChan <- ProviderEvent{
354+ Type: EventError,
355+ Error: fmt.Errorf("received empty streaming response from OpenAI API - check endpoint configuration"),
356+ }
357+ return
358+ }
359+
360+ resultFinishReason := acc.Choices[0].FinishReason
340361 if resultFinishReason == "" {
341362 // If the finish reason is empty, we assume it was a successful completion
342363 // INFO: this is happening for openrouter for some reason
internal/llm/provider/openai_test.gonew file+91
@@ -0,0 +1,91 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "os"
9+ "testing"
10+ "time"
11+
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/fur/provider"
14+ "github.com/charmbracelet/crush/internal/llm/tools"
15+ "github.com/charmbracelet/crush/internal/message"
16+ "github.com/openai/openai-go"
17+ "github.com/openai/openai-go/option"
18+)
19+
20+func TestMain(m *testing.M) {
21+ _, err := config.Init(".", true)
22+ if err != nil {
23+ panic("Failed to initialize config: " + err.Error())
24+ }
25+
26+ os.Exit(m.Run())
27+}
28+
29+func TestOpenAIClientStreamChoices(t *testing.T) {
30+ // Create a mock server that returns Server-Sent Events with empty choices
31+ // This simulates the 🤡 behavior when a server returns 200 instead of 404
32+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ w.Header().Set("Content-Type", "text/event-stream")
34+ w.Header().Set("Cache-Control", "no-cache")
35+ w.Header().Set("Connection", "keep-alive")
36+ w.WriteHeader(http.StatusOK)
37+
38+ emptyChoicesChunk := map[string]any{
39+ "id": "chat-completion-test",
40+ "object": "chat.completion.chunk",
41+ "created": time.Now().Unix(),
42+ "model": "test-model",
43+ "choices": []any{}, // Empty choices array that causes panic
44+ }
45+
46+ jsonData, _ := json.Marshal(emptyChoicesChunk)
47+ w.Write([]byte("data: " + string(jsonData) + "\n\n"))
48+ w.Write([]byte("data: [DONE]\n\n"))
49+ }))
50+ defer server.Close()
51+
52+ // Create OpenAI client pointing to our mock server
53+ client := &openaiClient{
54+ providerOptions: providerClientOptions{
55+ modelType: config.SelectedModelTypeLarge,
56+ apiKey: "test-key",
57+ systemMessage: "test",
58+ model: func(config.SelectedModelType) provider.Model {
59+ return provider.Model{
60+ ID: "test-model",
61+ Model: "test-model",
62+ }
63+ },
64+ },
65+ client: openai.NewClient(
66+ option.WithAPIKey("test-key"),
67+ option.WithBaseURL(server.URL),
68+ ),
69+ }
70+
71+ // Create test messages
72+ messages := []message.Message{
73+ {
74+ Role: message.User,
75+ Parts: []message.ContentPart{message.TextContent{Text: "Hello"}},
76+ },
77+ }
78+
79+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
80+ defer cancel()
81+
82+ eventsChan := client.stream(ctx, messages, []tools.BaseTool{})
83+
84+ // Collect events - this will panic without the bounds check
85+ for event := range eventsChan {
86+ t.Logf("Received event: %+v", event)
87+ if event.Type == EventError || event.Type == EventComplete {
88+ break
89+ }
90+ }
91+}
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+99−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,46 @@ 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+ return lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + "\n\n" + footer
213271}
214272
215273// shouldSpin determines whether the message should show a loading animation.
@@ -226,11 +284,12 @@ func (m *messageCmp) shouldSpin() bool {
226284 if m.message.Content().Text != "" {
227285 return false
228286 }
287+ if len(m.message.ToolCalls()) > 0 {
288+ return false
289+ }
229290 return true
230291}
231292
232−// Focus management methods
233−
234293// Blur removes focus from the message component
235294func (m *messageCmp) Blur() tea.Cmd {
236295 m.focused = false
@@ -257,8 +316,8 @@ func (m *messageCmp) GetSize() (int, int) {
257316
258317// SetSize updates the width of the message component for text wrapping
259318func (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)
319+ m.width = util.Clamp(width, 1, 120)
320+ m.thinkingViewport.SetWidth(m.width - 4)
262321 return nil
263322}
264323
internal/tui/components/chat/splash/splash.go+1
@@ -105,6 +105,7 @@ func (s *splashCmp) SetOnboarding(onboarding bool) {
105105 "openai",
106106 "gemini",
107107 "xai",
108+ "groq",
108109 "openrouter",
109110 }
110111 for _, p := range providers {
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/components/dialogs/models/list.go+5−2
@@ -98,14 +98,17 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
9898
9999 // First, add any configured providers that are not in the known providers list
100100 // These should appear at the top of the list
101− knownProviders := provider.KnownProviders()
101+ knownProviders, err := config.Providers()
102+ if err != nil {
103+ return util.ReportError(err)
104+ }
102105 for providerID, providerConfig := range cfg.Providers {
103106 if providerConfig.Disable {
104107 continue
105108 }
106109
107110 // Check if this provider is not in the known providers list
108− if !slices.Contains(knownProviders, provider.InferenceProvider(providerID)) {
111+ if !slices.ContainsFunc(knownProviders, func(p provider.Provider) bool { return p.ID == provider.InferenceProvider(providerID) }) {
109112 // Convert config provider to provider.Provider format
110113 configProvider := provider.Provider{
111114 Name: providerConfig.Name,
internal/tui/components/dialogs/permissions/permissions.go+23−6
@@ -52,9 +52,10 @@ type permissionDialogCmp struct {
5252 selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
5353
5454 // Diff view state
55− diffSplitMode bool // true for split, false for unified
56− diffXOffset int // horizontal scroll offset
57− diffYOffset int // vertical scroll offset
55+ defaultDiffSplitMode bool // true for split, false for unified
56+ diffSplitMode *bool // nil means use defaultDiffSplitMode
57+ diffXOffset int // horizontal scroll offset
58+ diffYOffset int // vertical scroll offset
5859
5960 // Caching
6061 cachedContent string
@@ -122,7 +123,12 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
122123 )
123124 case key.Matches(msg, p.keyMap.ToggleDiffMode):
124125 if p.supportsDiffView() {
125− p.diffSplitMode = !p.diffSplitMode
126+ if p.diffSplitMode == nil {
127+ diffSplitMode := !p.defaultDiffSplitMode
128+ p.diffSplitMode = &diffSplitMode
129+ } else {
130+ *p.diffSplitMode = !*p.diffSplitMode
131+ }
126132 p.contentDirty = true // Mark content as dirty when diff mode changes
127133 return p, nil
128134 }
@@ -354,7 +360,7 @@ func (p *permissionDialogCmp) generateEditContent() string {
354360 Width(p.contentViewPort.Width()).
355361 XOffset(p.diffXOffset).
356362 YOffset(p.diffYOffset)
357− if p.diffSplitMode {
363+ if p.useDiffSplitMode() {
358364 formatter = formatter.Split()
359365 } else {
360366 formatter = formatter.Unified()
@@ -376,7 +382,7 @@ func (p *permissionDialogCmp) generateWriteContent() string {
376382 Width(p.contentViewPort.Width()).
377383 XOffset(p.diffXOffset).
378384 YOffset(p.diffYOffset)
379− if p.diffSplitMode {
385+ if p.useDiffSplitMode() {
380386 formatter = formatter.Split()
381387 } else {
382388 formatter = formatter.Unified()
@@ -438,6 +444,14 @@ func (p *permissionDialogCmp) generateDefaultContent() string {
438444 return finalContent
439445}
440446
447+func (p *permissionDialogCmp) useDiffSplitMode() bool {
448+ if p.diffSplitMode != nil {
449+ return *p.diffSplitMode
450+ } else {
451+ return p.defaultDiffSplitMode
452+ }
453+}
454+
441455func (p *permissionDialogCmp) styleViewport() string {
442456 t := styles.CurrentTheme()
443457 return t.S().Base.Render(p.contentViewPort.View())
@@ -525,6 +539,9 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
525539 p.height = int(float64(p.wHeight) * 0.5)
526540 }
527541
542+ // Default to diff split mode when dialog is wide enough.
543+ p.defaultDiffSplitMode = p.width >= 140
544+
528545 // Mark content as dirty if size changed
529546 if oldWidth != p.width || oldHeight != p.height {
530547 p.contentDirty = true
internal/tui/page/chat/chat.go+34−1
@@ -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)
@@ -380,7 +382,7 @@ func (p *chatPage) View() string {
380382 Width(p.detailsWidth).
381383 Border(lipgloss.RoundedBorder()).
382384 BorderForeground(t.BorderFocus)
383− version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
385+ version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
384386 details := style.Render(
385387 lipgloss.JoinVertical(
386388 lipgloss.Left,
@@ -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{}),