Detailed changes
@@ -1,39 +0,0 @@
-package claudetool
-
-import (
- "context"
- "encoding/json"
-
- "shelley.exe.dev/llm"
-)
-
-// The Think tool provides space to think.
-var Think = &llm.Tool{
- Name: thinkName,
- Description: thinkDescription,
- InputSchema: llm.MustSchema(thinkInputSchema),
- Run: thinkRun,
-}
-
-const (
- thinkName = "think"
- thinkDescription = `Think out loud, take notes, form plans. Has no external effects.`
-
- // If you modify this, update the termui template for prettier rendering.
- thinkInputSchema = `
-{
- "type": "object",
- "required": ["thoughts"],
- "properties": {
- "thoughts": {
- "type": "string",
- "description": "The thoughts, notes, or plans to record"
- }
- }
-}
-`
-)
-
-func thinkRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
- return llm.ToolOut{LLMContent: llm.TextContent("recorded")}
-}
@@ -1,34 +0,0 @@
-package claudetool
-
-import (
- "context"
- "encoding/json"
- "testing"
-)
-
-func TestThinkRun(t *testing.T) {
- input := struct {
- Thoughts string `json:"thoughts"`
- }{
- Thoughts: "This is a test thought",
- }
-
- inputBytes, err := json.Marshal(input)
- if err != nil {
- t.Fatal(err)
- }
-
- result := thinkRun(context.Background(), inputBytes)
-
- if result.Error != nil {
- t.Errorf("unexpected error: %v", result.Error)
- }
-
- if len(result.LLMContent) == 0 {
- t.Error("expected LLM content")
- }
-
- if result.LLMContent[0].Text != "recorded" {
- t.Errorf("expected 'recorded', got %q", result.LLMContent[0].Text)
- }
-}
@@ -126,7 +126,6 @@ func NewToolSet(ctx context.Context, cfg ToolSetConfig) *ToolSet {
outputIframeTool := &OutputIframeTool{WorkingDir: wd}
tools := []*llm.Tool{
- Think,
bashTool.Tool(),
patchTool.Tool(),
keywordTool.Tool(),
@@ -82,11 +82,12 @@ func (s *Service) MaxImageDimension() int {
// Service provides Claude completions.
// Fields should not be altered concurrently with calling any method on Service.
type Service struct {
- HTTPC *http.Client // defaults to http.DefaultClient if nil
- URL string // defaults to DefaultURL if empty
- APIKey string // must be non-empty
- Model string // defaults to DefaultModel if empty
- MaxTokens int // defaults to DefaultMaxTokens if zero
+ HTTPC *http.Client // defaults to http.DefaultClient if nil
+ URL string // defaults to DefaultURL if empty
+ APIKey string // must be non-empty
+ Model string // defaults to DefaultModel if empty
+ MaxTokens int // defaults to DefaultMaxTokens if zero
+ ThinkingLevel llm.ThinkingLevel // thinking level (ThinkingLevelOff disables, default is ThinkingLevelMedium)
}
var _ llm.Service = (*Service)(nil)
@@ -217,6 +218,12 @@ type systemContent struct {
}
// request represents the request payload for creating a message.
+// thinking configures extended thinking for Claude models.
+type thinking struct {
+ Type string `json:"type"` // "enabled"
+ BudgetTokens int `json:"budget_tokens,omitempty"` // Max tokens for thinking
+}
+
type request struct {
// Field order matters for JSON serialization - stable fields should come first
// to maximize prefix deduplication when storing LLM requests.
@@ -226,6 +233,7 @@ type request struct {
System []systemContent `json:"system,omitempty"`
Tools []*tool `json:"tools,omitempty"`
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
+ Thinking *thinking `json:"thinking,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopK int `json:"top_k,omitempty"`
TopP float64 `json:"top_p,omitempty"`
@@ -393,14 +401,27 @@ func fromLLMSystem(s llm.SystemContent) systemContent {
}
func (s *Service) fromLLMRequest(r *llm.Request) *request {
- return &request{
+ maxTokens := cmp.Or(s.MaxTokens, DefaultMaxTokens)
+
+ req := &request{
Model: cmp.Or(s.Model, DefaultModel),
Messages: mapped(r.Messages, fromLLMMessage),
- MaxTokens: cmp.Or(s.MaxTokens, DefaultMaxTokens),
+ MaxTokens: maxTokens,
ToolChoice: fromLLMToolChoice(r.ToolChoice),
Tools: mapped(r.Tools, fromLLMTool),
System: mapped(r.System, fromLLMSystem),
}
+
+ // Enable extended thinking if a thinking level is set
+ if s.ThinkingLevel != llm.ThinkingLevelOff {
+ budget := s.ThinkingLevel.ThinkingBudgetTokens()
+ // Ensure max_tokens > budget_tokens as required by Anthropic API
+ if maxTokens <= budget {
+ req.MaxTokens = budget + 1024
+ }
+ req.Thinking = &thinking{Type: "enabled", BudgetTokens: budget}
+ }
+ return req
}
func toLLMUsage(u usage) llm.Usage {
@@ -198,7 +198,7 @@ func ContentsAttr(contents []Content) slog.Attr {
attrs = append(attrs, slog.Any("tool_result", content.ToolResult))
attrs = append(attrs, slog.Bool("tool_error", content.ToolError))
case ContentTypeThinking:
- attrs = append(attrs, slog.String("thinking", content.Text))
+ attrs = append(attrs, slog.String("thinking", content.Thinking))
default:
attrs = append(attrs, slog.String("unknown_content_type", content.Type.String()))
attrs = append(attrs, slog.Any("text", content)) // just log it all raw, better to have too much than not enough
@@ -213,9 +213,10 @@ type (
ContentType int
ToolChoiceType int
StopReason int
+ ThinkingLevel int
)
-//go:generate go tool golang.org/x/tools/cmd/stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go
+//go:generate go tool golang.org/x/tools/cmd/stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason,ThinkingLevel -output=llm_string.go
const (
MessageRoleUser MessageRole = iota
@@ -239,6 +240,48 @@ const (
StopReasonRefusal
)
+// ThinkingLevel controls how much thinking/reasoning the model does.
+// ThinkingLevelOff is the zero value and disables thinking.
+const (
+ ThinkingLevelOff ThinkingLevel = iota // No thinking (zero value)
+ ThinkingLevelMinimal // Minimal thinking (1024 tokens / "minimal")
+ ThinkingLevelLow // Low thinking (2048 tokens / "low")
+ ThinkingLevelMedium // Medium thinking (8192 tokens / "medium")
+ ThinkingLevelHigh // High thinking (16384 tokens / "high")
+)
+
+// ThinkingBudgetTokens returns the recommended budget_tokens for Anthropic's extended thinking.
+func (t ThinkingLevel) ThinkingBudgetTokens() int {
+ switch t {
+ case ThinkingLevelMinimal:
+ return 1024
+ case ThinkingLevelLow:
+ return 2048
+ case ThinkingLevelMedium:
+ return 8192
+ case ThinkingLevelHigh:
+ return 16384
+ default:
+ return 0
+ }
+}
+
+// ThinkingEffort returns the reasoning effort string for OpenAI's reasoning API.
+func (t ThinkingLevel) ThinkingEffort() string {
+ switch t {
+ case ThinkingLevelMinimal:
+ return "minimal"
+ case ThinkingLevelLow:
+ return "low"
+ case ThinkingLevelMedium:
+ return "medium"
+ case ThinkingLevelHigh:
+ return "high"
+ default:
+ return ""
+ }
+}
+
type Response struct {
ID string
Type string
@@ -1,4 +1,4 @@
-// Code generated by "stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go"; DO NOT EDIT.
+// Code generated by "stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason,ThinkingLevel -output=llm_string.go"; DO NOT EDIT.
package llm
@@ -88,3 +88,25 @@ func (i StopReason) String() string {
}
return _StopReason_name[_StopReason_index[idx]:_StopReason_index[idx+1]]
}
+func _() {
+ // An "invalid array index" compiler error signifies that the constant values have changed.
+ // Re-run the stringer command to generate them again.
+ var x [1]struct{}
+ _ = x[ThinkingLevelOff-0]
+ _ = x[ThinkingLevelMinimal-1]
+ _ = x[ThinkingLevelLow-2]
+ _ = x[ThinkingLevelMedium-3]
+ _ = x[ThinkingLevelHigh-4]
+}
+
+const _ThinkingLevel_name = "ThinkingLevelOffThinkingLevelMinimalThinkingLevelLowThinkingLevelMediumThinkingLevelHigh"
+
+var _ThinkingLevel_index = [...]uint8{0, 16, 36, 52, 71, 88}
+
+func (i ThinkingLevel) String() string {
+ idx := int(i) - 0
+ if i < 0 || idx >= len(_ThinkingLevel_index)-1 {
+ return "ThinkingLevel(" + strconv.FormatInt(int64(i), 10) + ")"
+ }
+ return _ThinkingLevel_name[_ThinkingLevel_index[idx]:_ThinkingLevel_index[idx+1]]
+}
@@ -21,13 +21,14 @@ import (
// This API is required for models like gpt-5.1-codex.
// Fields should not be altered concurrently with calling any method on ResponsesService.
type ResponsesService struct {
- HTTPC *http.Client // defaults to http.DefaultClient if nil
- APIKey string // optional, if not set will try to load from env var
- Model Model // defaults to DefaultModel if zero value
- ModelURL string // optional, overrides Model.URL
- MaxTokens int // defaults to DefaultMaxTokens if zero
- Org string // optional - organization ID
- DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
+ HTTPC *http.Client // defaults to http.DefaultClient if nil
+ APIKey string // optional, if not set will try to load from env var
+ Model Model // defaults to DefaultModel if zero value
+ ModelURL string // optional, overrides Model.URL
+ MaxTokens int // defaults to DefaultMaxTokens if zero
+ Org string // optional - organization ID
+ DumpLLM bool // whether to dump request/response text to files for debugging; defaults to false
+ ThinkingLevel llm.ThinkingLevel // thinking level (ThinkingLevelOff disables reasoning)
}
var _ llm.Service = (*ResponsesService)(nil)
@@ -391,6 +392,14 @@ func (s *ResponsesService) Do(ctx context.Context, ir *llm.Request) (*llm.Respon
MaxOutputTokens: cmp.Or(s.MaxTokens, DefaultMaxTokens),
}
+ // Add reasoning if thinking is enabled
+ if s.ThinkingLevel != llm.ThinkingLevelOff {
+ effort := s.ThinkingLevel.ThinkingEffort()
+ if effort != "" {
+ req.Reasoning = &responsesReasoning{Effort: effort}
+ }
+ }
+
// Add tool choice if specified
if ir.ToolChoice != nil {
req.ToolChoice = fromLLMToolChoice(ir.ToolChoice)
@@ -1183,7 +1183,7 @@ func TestPredictableServiceMaxImageDimension(t *testing.T) {
}
}
-func TestPredictableServiceThinkTool(t *testing.T) {
+func TestPredictableServiceThinking(t *testing.T) {
service := NewPredictableService()
ctx := context.Background()
@@ -1195,34 +1195,30 @@ func TestPredictableServiceThinkTool(t *testing.T) {
resp, err := service.Do(ctx, req)
if err != nil {
- t.Fatalf("think tool test failed: %v", err)
+ t.Fatalf("thinking test failed: %v", err)
}
- if resp.StopReason != llm.StopReasonToolUse {
- t.Errorf("expected tool use stop reason, got %v", resp.StopReason)
+ // Now returns EndTurn since thinking is content, not a tool
+ if resp.StopReason != llm.StopReasonEndTurn {
+ t.Errorf("expected end turn stop reason, got %v", resp.StopReason)
}
- // Find the tool use content
- var toolUseContent *llm.Content
+ // Find the thinking content
+ var thinkingContent *llm.Content
for _, content := range resp.Content {
- if content.Type == llm.ContentTypeToolUse && content.ToolName == "think" {
- toolUseContent = &content
+ if content.Type == llm.ContentTypeThinking {
+ thinkingContent = &content
break
}
}
- if toolUseContent == nil {
- t.Fatal("no think tool use content found")
- }
-
- // Check tool input contains the thoughts
- var toolInput map[string]interface{}
- if err := json.Unmarshal(toolUseContent.ToolInput, &toolInput); err != nil {
- t.Fatalf("failed to parse tool input: %v", err)
+ if thinkingContent == nil {
+ t.Fatal("no thinking content found")
}
- if toolInput["thoughts"] != "This is a test thought" {
- t.Errorf("expected thoughts 'This is a test thought', got '%v'", toolInput["thoughts"])
+ // Check thinking content contains the thoughts
+ if thinkingContent.Thinking != "This is a test thought" {
+ t.Errorf("expected thinking 'This is a test thought', got '%v'", thinkingContent.Thinking)
}
}
@@ -20,7 +20,7 @@ import (
// Available patterns include:
// - "echo: <text>" - echoes the text back
// - "bash: <command>" - triggers bash tool with command
-// - "think: <thoughts>" - triggers think tool
+// - "think: <thoughts>" - returns response with extended thinking content
// - "subagent: <slug> <prompt>" - triggers subagent tool
// - "change_dir: <path>" - triggers change_dir tool
// - "delay: <seconds>" - delays response by specified seconds
@@ -112,7 +112,7 @@ func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Res
return s.makeResponse("Hello! I'm Shelley, your AI assistant. How can I help you today?", inputTokens), nil
case "Create an example":
- return s.makeThinkToolResponse("I'll create a simple example for you.", inputTokens), nil
+ return s.makeThinkingResponse("I'll create a simple example for you.", inputTokens), nil
case "screenshot":
// Trigger a screenshot of the current page
@@ -155,7 +155,7 @@ func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Res
if strings.HasPrefix(inputText, "think: ") {
thoughts := strings.TrimPrefix(inputText, "think: ")
- return s.makeThinkToolResponse(thoughts, inputTokens), nil
+ return s.makeThinkingResponse(thoughts, inputTokens), nil
}
if strings.HasPrefix(inputText, "patch: ") {
@@ -288,32 +288,23 @@ func (s *PredictableService) makeBashToolResponse(command string, inputTokens ui
}
}
-// makeThinkToolResponse creates a response that calls the think tool
-func (s *PredictableService) makeThinkToolResponse(thoughts string, inputTokens uint64) *llm.Response {
- // Properly marshal the thoughts to avoid JSON escaping issues
- toolInputData := map[string]string{"thoughts": thoughts}
- toolInputBytes, _ := json.Marshal(toolInputData)
- toolInput := json.RawMessage(toolInputBytes)
- responseText := "Let me think about this."
- outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
+// makeThinkingResponse creates a response with extended thinking content
+func (s *PredictableService) makeThinkingResponse(thoughts string, inputTokens uint64) *llm.Response {
+ responseText := "I've considered my approach."
+ outputTokens := uint64(len(responseText)/4 + len(thoughts)/4)
if outputTokens == 0 {
outputTokens = 1
}
return &llm.Response{
- ID: fmt.Sprintf("pred-think-%d", time.Now().UnixNano()),
+ ID: fmt.Sprintf("pred-thinking-%d", time.Now().UnixNano()),
Type: "message",
Role: llm.MessageRoleAssistant,
Model: "predictable-v1",
Content: []llm.Content{
+ {Type: llm.ContentTypeThinking, Thinking: thoughts},
{Type: llm.ContentTypeText, Text: responseText},
- {
- ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
- Type: llm.ContentTypeToolUse,
- ToolName: "think",
- ToolInput: toolInput,
- },
},
- StopReason: llm.StopReasonToolUse,
+ StopReason: llm.StopReasonEndTurn,
Usage: llm.Usage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
@@ -629,13 +620,10 @@ func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *ll
ToolInput: json.RawMessage(bashInput),
})
- // think tool
- thinkInput, _ := json.Marshal(map[string]string{"thoughts": "I'm thinking about the best approach for this task. Let me consider all the options available."})
+ // extended thinking content (not a tool)
content = append(content, llm.Content{
- ID: fmt.Sprintf("tool_think_%d", (baseNano+1)%1000),
- Type: llm.ContentTypeToolUse,
- ToolName: "think",
- ToolInput: json.RawMessage(thinkInput),
+ Type: llm.ContentTypeThinking,
+ Thinking: "I'm thinking about the best approach for this task. Let me consider all the options available.",
})
// patch tool
@@ -166,7 +166,7 @@ func All() []Model {
if config.AnthropicAPIKey == "" {
return nil, fmt.Errorf("claude-opus-4.5 requires ANTHROPIC_API_KEY")
}
- svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Opus, HTTPC: httpc}
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Opus, HTTPC: httpc, ThinkingLevel: llm.ThinkingLevelMedium}
if url := config.getAnthropicURL(); url != "" {
svc.URL = url
}
@@ -183,7 +183,7 @@ func All() []Model {
if config.AnthropicAPIKey == "" {
return nil, fmt.Errorf("claude-sonnet-4.5 requires ANTHROPIC_API_KEY")
}
- svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Sonnet, HTTPC: httpc}
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Sonnet, HTTPC: httpc, ThinkingLevel: llm.ThinkingLevelMedium}
if url := config.getAnthropicURL(); url != "" {
svc.URL = url
}
@@ -200,7 +200,7 @@ func All() []Model {
if config.AnthropicAPIKey == "" {
return nil, fmt.Errorf("claude-haiku-4.5 requires ANTHROPIC_API_KEY")
}
- svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Haiku, HTTPC: httpc}
+ svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Haiku, HTTPC: httpc, ThinkingLevel: llm.ThinkingLevelMedium}
if url := config.getAnthropicURL(); url != "" {
svc.URL = url
}
@@ -234,7 +234,7 @@ func All() []Model {
if config.OpenAIAPIKey == "" {
return nil, fmt.Errorf("gpt-5.2-codex requires OPENAI_API_KEY")
}
- svc := &oai.ResponsesService{Model: oai.GPT52Codex, APIKey: config.OpenAIAPIKey, HTTPC: httpc}
+ svc := &oai.ResponsesService{Model: oai.GPT52Codex, APIKey: config.OpenAIAPIKey, HTTPC: httpc, ThinkingLevel: llm.ThinkingLevelMedium}
if url := config.getOpenAIURL(); url != "" {
svc.ModelURL = url
}
@@ -682,10 +682,11 @@ func (m *Manager) createServiceFromModel(model *generated.Model) llm.Service {
switch model.ProviderType {
case "anthropic":
return &ant.Service{
- APIKey: model.ApiKey,
- URL: model.Endpoint,
- Model: model.ModelName,
- HTTPC: m.httpc,
+ APIKey: model.ApiKey,
+ URL: model.Endpoint,
+ Model: model.ModelName,
+ HTTPC: m.httpc,
+ ThinkingLevel: llm.ThinkingLevelMedium,
}
case "openai":
return &oai.Service{
@@ -706,8 +707,9 @@ func (m *Manager) createServiceFromModel(model *generated.Model) llm.Service {
ModelName: model.ModelName,
URL: model.Endpoint,
},
- MaxTokens: int(model.MaxTokens),
- HTTPC: m.httpc,
+ MaxTokens: int(model.MaxTokens),
+ HTTPC: m.httpc,
+ ThinkingLevel: llm.ThinkingLevelMedium,
}
case "gemini":
return &gem.Service{
@@ -349,9 +349,10 @@ func (s *Server) handleTestModel(w http.ResponseWriter, r *http.Request) {
switch req.ProviderType {
case "anthropic":
service = &ant.Service{
- APIKey: req.APIKey,
- URL: req.Endpoint,
- Model: req.ModelName,
+ APIKey: req.APIKey,
+ URL: req.Endpoint,
+ Model: req.ModelName,
+ ThinkingLevel: llm.ThinkingLevelMedium,
}
case "openai":
service = &oai.Service{
@@ -333,10 +333,10 @@ func TestPredictableServiceWithTools(t *testing.T) {
t.Fatal("Expected greeting to mention Shelley")
}
- // Second call should return tool use
+ // Second call should return tool use (bash command)
resp2, err := service.Do(context.Background(), &llm.Request{
Messages: []llm.Message{
- {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "Create an example"}}},
+ {Role: llm.MessageRoleUser, Content: []llm.Content{{Type: llm.ContentTypeText, Text: "bash: echo hello"}}},
},
})
if err != nil {
@@ -364,8 +364,8 @@ func TestPredictableServiceWithTools(t *testing.T) {
t.Fatal("Expected tool use content")
}
- if toolUse.ToolName != "think" {
- t.Fatalf("Expected think tool, got %s", toolUse.ToolName)
+ if toolUse.ToolName != "bash" {
+ t.Fatalf("Expected bash tool, got %s", toolUse.ToolName)
}
}
@@ -15,7 +15,7 @@ import DiffViewer from "./DiffViewer";
import BashTool from "./BashTool";
import PatchTool from "./PatchTool";
import ScreenshotTool from "./ScreenshotTool";
-import ThinkTool from "./ThinkTool";
+
import KeywordSearchTool from "./KeywordSearchTool";
import BrowserNavigateTool from "./BrowserNavigateTool";
import BrowserEvalTool from "./BrowserEvalTool";
@@ -227,7 +227,7 @@ const TOOL_COMPONENTS: Record<string, React.ComponentType<any>> = {
patch: PatchTool,
screenshot: ScreenshotTool,
browser_take_screenshot: ScreenshotTool,
- think: ThinkTool,
+
keyword_search: KeywordSearchTool,
browser_navigate: BrowserNavigateTool,
browser_eval: BrowserEvalTool,
@@ -1192,6 +1192,9 @@ function ChatInterface({
coalescedItems.push({ type: "message", message });
}
+ // Check if this message was truncated (tool calls lost)
+ const wasTruncated = llmData.ExcludedFromContext === true;
+
// Add tool uses as separate items
toolUses.forEach((toolUse) => {
const resultData = toolUse.ID ? toolResultMap[toolUse.ID] : undefined;
@@ -1203,10 +1206,12 @@ function ChatInterface({
toolName: toolUse.ToolName,
toolInput: toolUse.ToolInput,
toolResult: resultData?.result,
- toolError: resultData?.error,
+ // Mark as error if truncated and no result
+ toolError: resultData?.error || (wasTruncated && !resultData),
toolStartTime: resultData?.startTime,
toolEndTime: resultData?.endTime,
- hasResult: !!resultData || completedViaDisplay,
+ // Mark as complete if truncated (tool was lost, not running)
+ hasResult: !!resultData || completedViaDisplay || wasTruncated,
display: displayData,
});
});
@@ -5,7 +5,7 @@ import BashTool from "./BashTool";
import PatchTool from "./PatchTool";
import ScreenshotTool from "./ScreenshotTool";
import GenericTool from "./GenericTool";
-import ThinkTool from "./ThinkTool";
+
import KeywordSearchTool from "./KeywordSearchTool";
import BrowserNavigateTool from "./BrowserNavigateTool";
import BrowserEvalTool from "./BrowserEvalTool";
@@ -15,6 +15,7 @@ import ChangeDirTool from "./ChangeDirTool";
import BrowserResizeTool from "./BrowserResizeTool";
import SubagentTool from "./SubagentTool";
import OutputIframeTool from "./OutputIframeTool";
+import ThinkingContent from "./ThinkingContent";
import UsageDetailModal from "./UsageDetailModal";
import MessageActionBar from "./MessageActionBar";
@@ -283,7 +284,7 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
}
};
- // Get text content from message for copying (includes tool results)
+ // Get text content from message for copying (includes tool results and thinking)
const getMessageText = (): string => {
if (!llmMessage?.Content) return "";
@@ -292,6 +293,12 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
const contentType = getContentType(content.Type);
if (contentType === "text" && content.Text) {
textParts.push(content.Text);
+ } else if (contentType === "thinking") {
+ // Include thinking content
+ const thinkingText = content.Thinking || content.Text;
+ if (thinkingText) {
+ textParts.push(`[Thinking]\n${thinkingText}`);
+ }
} else if (contentType === "tool_result" && content.ToolResult) {
// Extract text from tool result content
content.ToolResult.forEach((result) => {
@@ -459,10 +466,7 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
if (content.ToolName === "screenshot" || content.ToolName === "browser_take_screenshot") {
return <ScreenshotTool toolInput={content.ToolInput} isRunning={true} />;
}
- // Use specialized component for think tool
- if (content.ToolName === "think") {
- return <ThinkTool toolInput={content.ToolInput} isRunning={true} />;
- }
+
// Use specialized component for change_dir tool
if (content.ToolName === "change_dir") {
return <ChangeDirTool toolInput={content.ToolInput} isRunning={true} />;
@@ -598,20 +602,6 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
toolResult={content.ToolResult}
hasError={hasError}
executionTime={executionTime}
- display={content.Display}
- />
- );
- }
-
- // Use specialized component for think tool
- if (toolName === "think") {
- return (
- <ThinkTool
- toolInput={toolInput}
- isRunning={false}
- toolResult={content.ToolResult}
- hasError={hasError}
- executionTime={executionTime}
/>
);
}
@@ -753,9 +743,11 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
}
case "redacted_thinking":
return <div className="text-tertiary italic text-sm">[Thinking content hidden]</div>;
- case "thinking":
- // Hide thinking content by default in main flow, but could be made expandable
- return null;
+ case "thinking": {
+ const thinkingText = content.Thinking || content.Text || "";
+ if (!thinkingText) return null;
+ return <ThinkingContent thinking={thinkingText} />;
+ }
default: {
// For unknown content types, show the type and try to display useful content
const displayText = content.Text || content.Data || "";
@@ -989,18 +981,22 @@ function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProp
return null;
}
- // Filter out thinking content, empty content, tool_use, and tool_result
+ // Filter out redacted thinking, empty content, tool_use, and tool_result
+ // Keep thinking content (3) for display
const meaningfulContent =
llmMessage?.Content?.filter((c) => {
const contentType = c.Type;
- // Filter out thinking (3), redacted thinking (4), tool_use (5), tool_result (6), and empty text content
+ // Filter out redacted thinking (4), tool_use (5), tool_result (6), and empty text content
+ // Keep thinking (3) if it has content
+ if (contentType === 3) {
+ return !!(c.Thinking || c.Text);
+ }
return (
- contentType !== 3 &&
contentType !== 4 &&
contentType !== 5 &&
contentType !== 6 &&
(c.Text?.trim() || contentType !== 2)
- ); // 3 = thinking, 4 = redacted_thinking, 5 = tool_use, 6 = tool_result, 2 = text
+ ); // 4 = redacted_thinking, 5 = tool_use, 6 = tool_result, 2 = text
}) || [];
// Don't filter out messages that contain operation status like "[Operation cancelled]"
@@ -1,94 +0,0 @@
-import React, { useState } from "react";
-import { LLMContent } from "../types";
-
-interface ThinkToolProps {
- // For tool_use (pending state)
- toolInput?: unknown; // { thoughts: string }
- isRunning?: boolean;
-
- // For tool_result (completed state)
- toolResult?: LLMContent[];
- hasError?: boolean;
- executionTime?: string;
-}
-
-function ThinkTool({ toolInput, isRunning, toolResult, hasError, executionTime }: ThinkToolProps) {
- const [isExpanded, setIsExpanded] = useState(false);
-
- // Extract thoughts from toolInput
- const thoughts =
- typeof toolInput === "object" &&
- toolInput !== null &&
- "thoughts" in toolInput &&
- typeof toolInput.thoughts === "string"
- ? toolInput.thoughts
- : typeof toolInput === "string"
- ? toolInput
- : "";
-
- // Truncate thoughts for display - get first 50 chars
- const truncateThoughts = (text: string, maxLen: number = 50) => {
- if (!text) return "";
- if (text.length <= maxLen) return text;
- return text.substring(0, maxLen) + "...";
- };
-
- const displayThoughts = truncateThoughts(thoughts);
- const isComplete = !isRunning && toolResult !== undefined;
-
- return (
- <div className="tool" data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}>
- <div className="tool-header" onClick={() => setIsExpanded(!isExpanded)}>
- <div className="tool-summary">
- <span className={`tool-emoji ${isRunning ? "running" : ""}`}>💭</span>
- <span className="tool-command">
- {displayThoughts || (isRunning ? "thinking..." : "thinking...")}
- </span>
- {isComplete && hasError && <span className="tool-error">✗</span>}
- {isComplete && !hasError && <span className="tool-success">✓</span>}
- </div>
- <button
- className="tool-toggle"
- aria-label={isExpanded ? "Collapse" : "Expand"}
- aria-expanded={isExpanded}
- >
- <svg
- width="12"
- height="12"
- viewBox="0 0 12 12"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- style={{
- transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
- transition: "transform 0.2s",
- }}
- >
- <path
- d="M4.5 3L7.5 6L4.5 9"
- stroke="currentColor"
- strokeWidth="1.5"
- strokeLinecap="round"
- strokeLinejoin="round"
- />
- </svg>
- </button>
- </div>
-
- {isExpanded && (
- <div className="tool-details">
- <div className="tool-section">
- <div className="tool-label">
- Thoughts:
- {executionTime && <span className="tool-time">{executionTime}</span>}
- </div>
- <div className={`tool-code ${hasError ? "error" : ""}`}>
- {thoughts || "(no thoughts)"}
- </div>
- </div>
- </div>
- )}
- </div>
- );
-}
-
-export default ThinkTool;
@@ -0,0 +1,90 @@
+import React, { useState } from "react";
+
+interface ThinkingContentProps {
+ thinking: string;
+}
+
+function ThinkingContent({ thinking }: ThinkingContentProps) {
+ const [isExpanded, setIsExpanded] = useState(true);
+
+ // Truncate thinking for display - get first 80 chars
+ const truncateThinking = (text: string, maxLen: number = 80) => {
+ if (!text) return "";
+ const firstLine = text.split("\n")[0];
+ if (firstLine.length <= maxLen) return firstLine;
+ return firstLine.substring(0, maxLen) + "...";
+ };
+
+ const preview = truncateThinking(thinking);
+
+ return (
+ <div
+ className="thinking-content"
+ data-testid="thinking-content"
+ style={{
+ marginBottom: "0.5rem",
+ }}
+ >
+ <div
+ onClick={() => setIsExpanded(!isExpanded)}
+ style={{
+ cursor: "pointer",
+ display: "flex",
+ alignItems: "flex-start",
+ gap: "0.5rem",
+ marginLeft: 0,
+ }}
+ >
+ <span style={{ flexShrink: 0 }}>💭</span>
+ <div
+ style={{
+ flex: 1,
+ fontStyle: "italic",
+ color: "var(--text-secondary)",
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ }}
+ >
+ {isExpanded ? thinking : preview}
+ </div>
+ <button
+ className="thinking-toggle"
+ aria-label={isExpanded ? "Collapse" : "Expand"}
+ aria-expanded={isExpanded}
+ style={{
+ background: "none",
+ border: "none",
+ padding: "0.25rem",
+ cursor: "pointer",
+ color: "var(--text-tertiary)",
+ display: "flex",
+ alignItems: "center",
+ flexShrink: 0,
+ }}
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ style={{
+ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
+ transition: "transform 0.2s",
+ }}
+ >
+ <path
+ d="M4.5 3L7.5 6L4.5 9"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+ );
+}
+
+export default ThinkingContent;