Browse Source

feat: thinking blocks rendered in tui and share page

adamdotdevin 6 months ago
parent
commit
b8d2aebf09

+ 15 - 0
packages/opencode/src/provider/transform.ts

@@ -87,7 +87,22 @@ export namespace ProviderTransform {
       return {
         reasoningEffort: "minimal",
         textVerbosity: "low",
+        // reasoningSummary: "auto",
+        // include: ["reasoning.encrypted_content"],
       }
     }
+    // if (modelID.includes("claude")) {
+    //   return {
+    //     thinking: {
+    //       type: "enabled",
+    //       budgetTokens: 32000,
+    //     },
+    //   }
+    // }
+    // if (_providerID === "bedrock") {
+    //   return {
+    //     reasoningConfig: { type: "enabled", budgetTokens: 32000 },
+    //   }
+    // }
   }
 }

+ 30 - 0
packages/opencode/src/session/index.ts

@@ -1006,6 +1006,7 @@ export namespace Session {
       async process(stream: StreamTextResult<Record<string, AITool>, never>) {
         try {
           let currentText: MessageV2.TextPart | undefined
+          let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
 
           for await (const value of stream.fullStream) {
             log.info("part", {
@@ -1016,12 +1017,41 @@ export namespace Session {
                 break
 
               case "reasoning-start":
+                if (value.id in reasoningMap) {
+                  continue
+                }
+                reasoningMap[value.id] = {
+                  id: Identifier.ascending("part"),
+                  messageID: assistantMsg.id,
+                  sessionID: assistantMsg.sessionID,
+                  type: "reasoning",
+                  text: "",
+                  time: {
+                    start: Date.now(),
+                  },
+                }
                 break
 
               case "reasoning-delta":
+                if (value.id in reasoningMap) {
+                  const part = reasoningMap[value.id]
+                  part.text += value.text
+                  if (part.text) await updatePart(part)
+                }
                 break
 
               case "reasoning-end":
+                if (value.id in reasoningMap) {
+                  const part = reasoningMap[value.id]
+                  part.text = part.text.trimEnd()
+                  part.providerMetadata = value.providerMetadata
+                  part.time = {
+                    start: Date.now(),
+                    end: Date.now(),
+                  }
+                  await updatePart(part)
+                  delete reasoningMap[value.id]
+                }
                 break
 
               case "tool-input-start":

+ 16 - 0
packages/opencode/src/session/message-v2.ts

@@ -118,6 +118,21 @@ export namespace MessageV2 {
   })
   export type TextPart = z.infer<typeof TextPart>
 
+  export const ReasoningPart = PartBase.extend({
+    type: z.literal("reasoning"),
+    text: z.string(),
+    providerMetadata: z.record(z.any()).optional(),
+    time: z
+      .object({
+        start: z.number(),
+        end: z.number().optional(),
+      })
+      .optional(),
+  }).openapi({
+    ref: "ReasoningPart",
+  })
+  export type ReasoningPart = z.infer<typeof ReasoningPart>
+
   export const ToolPart = PartBase.extend({
     type: z.literal("tool"),
     callID: z.string(),
@@ -229,6 +244,7 @@ export namespace MessageV2 {
   export const Part = z
     .discriminatedUnion("type", [
       TextPart,
+      ReasoningPart,
       FilePart,
       ToolPart,
       StepStartPart,

+ 3 - 3
packages/sdk/go/.stats.yml

@@ -1,4 +1,4 @@
 configured_endpoints: 34
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-da1c340135c3dd6b1edb4e00e7039d2ac54d59271683a8b6ed528e51137ce41a.yml
-openapi_spec_hash: 0cdd9b6273d72f5a6f484a2999ff0632
-config_hash: 7581d5948150d4ef7dd7b13d0845dbeb
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-b86cf7bb8df4f60ebe8b8f51e281c8076cfdccc8554178c1b78beca4b025f0ff.yml
+openapi_spec_hash: 47633b7481d91708643ea7b43fffffe6
+config_hash: bd7f6435ed0c0005f373b5526c07a055

+ 1 - 0
packages/sdk/go/api.md

@@ -92,6 +92,7 @@ Response Types:
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#FileSource">FileSource</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Message">Message</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Part">Part</a>
+- <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#ReasoningPart">ReasoningPart</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#Session">Session</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#SnapshotPart">SnapshotPart</a>
 - <a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go">opencode</a>.<a href="https://pkg.go.dev/github.com/sst/opencode-sdk-go#StepFinishPart">StepFinishPart</a>

+ 25 - 5
packages/sdk/go/config.go

@@ -74,9 +74,10 @@ type Config struct {
 	// Control sharing behavior:'manual' allows manual sharing via commands, 'auto'
 	// enables automatic sharing, 'disabled' disables all sharing
 	Share ConfigShare `json:"share"`
-	// Small model to use for tasks like title generation in the
-	// format of provider/model
+	// Small model to use for tasks like title generation in the format of
+	// provider/model
 	SmallModel string `json:"small_model"`
+	Snapshot   bool   `json:"snapshot"`
 	// Theme name to use for the interface
 	Theme string `json:"theme"`
 	// Custom username to display in conversations instead of system username
@@ -105,6 +106,7 @@ type configJSON struct {
 	Provider          apijson.Field
 	Share             apijson.Field
 	SmallModel        apijson.Field
+	Snapshot          apijson.Field
 	Theme             apijson.Field
 	Username          apijson.Field
 	raw               string
@@ -780,9 +782,10 @@ func (r ConfigModePlanMode) IsKnown() bool {
 }
 
 type ConfigPermission struct {
-	Bash ConfigPermissionBashUnion `json:"bash"`
-	Edit ConfigPermissionEdit      `json:"edit"`
-	JSON configPermissionJSON      `json:"-"`
+	Bash     ConfigPermissionBashUnion `json:"bash"`
+	Edit     ConfigPermissionEdit      `json:"edit"`
+	Webfetch ConfigPermissionWebfetch  `json:"webfetch"`
+	JSON     configPermissionJSON      `json:"-"`
 }
 
 // configPermissionJSON contains the JSON metadata for the struct
@@ -790,6 +793,7 @@ type ConfigPermission struct {
 type configPermissionJSON struct {
 	Bash        apijson.Field
 	Edit        apijson.Field
+	Webfetch    apijson.Field
 	raw         string
 	ExtraFields map[string]apijson.Field
 }
@@ -876,6 +880,22 @@ func (r ConfigPermissionEdit) IsKnown() bool {
 	return false
 }
 
+type ConfigPermissionWebfetch string
+
+const (
+	ConfigPermissionWebfetchAsk   ConfigPermissionWebfetch = "ask"
+	ConfigPermissionWebfetchAllow ConfigPermissionWebfetch = "allow"
+	ConfigPermissionWebfetchDeny  ConfigPermissionWebfetch = "deny"
+)
+
+func (r ConfigPermissionWebfetch) IsKnown() bool {
+	switch r {
+	case ConfigPermissionWebfetchAsk, ConfigPermissionWebfetchAllow, ConfigPermissionWebfetchDeny:
+		return true
+	}
+	return false
+}
+
 type ConfigProvider struct {
 	Models  map[string]ConfigProviderModel `json:"models,required"`
 	ID      string                         `json:"id"`

+ 2 - 2
packages/sdk/go/scripts/mock

@@ -21,7 +21,7 @@ echo "==> Starting mock server with URL ${URL}"
 
 # Run prism mock on the given spec
 if [ "$1" == "--daemon" ]; then
-  npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" &> .prism.log &
+  npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log &
 
   # Wait for server to come online
   echo -n "Waiting for server"
@@ -37,5 +37,5 @@ if [ "$1" == "--daemon" ]; then
 
   echo
 else
-  npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL"
+  npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL"
 fi

+ 1 - 1
packages/sdk/go/scripts/test

@@ -43,7 +43,7 @@ elif ! prism_is_running ; then
   echo -e "To run the server, pass in the path or url of your OpenAPI"
   echo -e "spec to the prism command:"
   echo
-  echo -e "  \$ ${YELLOW}npm exec --package=@stoplight/prism-cli@~5.3.2 -- prism mock path/to/your.openapi.yml${NC}"
+  echo -e "  \$ ${YELLOW}npm exec --package=@stainless-api/[email protected] -- prism mock path/to/your.openapi.yml${NC}"
   echo
 
   exit 1

+ 115 - 33
packages/sdk/go/session.go

@@ -962,18 +962,20 @@ type Part struct {
 	Cost      float64  `json:"cost"`
 	Filename  string   `json:"filename"`
 	// This field can have the runtime type of [[]string].
-	Files    interface{} `json:"files"`
-	Hash     string      `json:"hash"`
-	Mime     string      `json:"mime"`
-	Name     string      `json:"name"`
-	Snapshot string      `json:"snapshot"`
+	Files interface{} `json:"files"`
+	Hash  string      `json:"hash"`
+	Mime  string      `json:"mime"`
+	Name  string      `json:"name"`
+	// This field can have the runtime type of [map[string]interface{}].
+	ProviderMetadata interface{} `json:"providerMetadata"`
+	Snapshot         string      `json:"snapshot"`
 	// This field can have the runtime type of [FilePartSource], [AgentPartSource].
 	Source interface{} `json:"source"`
 	// This field can have the runtime type of [ToolPartState].
 	State     interface{} `json:"state"`
 	Synthetic bool        `json:"synthetic"`
 	Text      string      `json:"text"`
-	// This field can have the runtime type of [TextPartTime].
+	// This field can have the runtime type of [TextPartTime], [ReasoningPartTime].
 	Time interface{} `json:"time"`
 	// This field can have the runtime type of [StepFinishPartTokens].
 	Tokens interface{} `json:"tokens"`
@@ -985,28 +987,29 @@ type Part struct {
 
 // partJSON contains the JSON metadata for the struct [Part]
 type partJSON struct {
-	ID          apijson.Field
-	MessageID   apijson.Field
-	SessionID   apijson.Field
-	Type        apijson.Field
-	CallID      apijson.Field
-	Cost        apijson.Field
-	Filename    apijson.Field
-	Files       apijson.Field
-	Hash        apijson.Field
-	Mime        apijson.Field
-	Name        apijson.Field
-	Snapshot    apijson.Field
-	Source      apijson.Field
-	State       apijson.Field
-	Synthetic   apijson.Field
-	Text        apijson.Field
-	Time        apijson.Field
-	Tokens      apijson.Field
-	Tool        apijson.Field
-	URL         apijson.Field
-	raw         string
-	ExtraFields map[string]apijson.Field
+	ID               apijson.Field
+	MessageID        apijson.Field
+	SessionID        apijson.Field
+	Type             apijson.Field
+	CallID           apijson.Field
+	Cost             apijson.Field
+	Filename         apijson.Field
+	Files            apijson.Field
+	Hash             apijson.Field
+	Mime             apijson.Field
+	Name             apijson.Field
+	ProviderMetadata apijson.Field
+	Snapshot         apijson.Field
+	Source           apijson.Field
+	State            apijson.Field
+	Synthetic        apijson.Field
+	Text             apijson.Field
+	Time             apijson.Field
+	Tokens           apijson.Field
+	Tool             apijson.Field
+	URL              apijson.Field
+	raw              string
+	ExtraFields      map[string]apijson.Field
 }
 
 func (r partJSON) RawJSON() string {
@@ -1025,14 +1028,16 @@ func (r *Part) UnmarshalJSON(data []byte) (err error) {
 // AsUnion returns a [PartUnion] interface which you can cast to the specific types
 // for more type safety.
 //
-// Possible runtime types of the union are [TextPart], [FilePart], [ToolPart],
-// [StepStartPart], [StepFinishPart], [SnapshotPart], [PartPatchPart], [AgentPart].
+// Possible runtime types of the union are [TextPart], [ReasoningPart], [FilePart],
+// [ToolPart], [StepStartPart], [StepFinishPart], [SnapshotPart], [PartPatchPart],
+// [AgentPart].
 func (r Part) AsUnion() PartUnion {
 	return r.union
 }
 
-// Union satisfied by [TextPart], [FilePart], [ToolPart], [StepStartPart],
-// [StepFinishPart], [SnapshotPart], [PartPatchPart] or [AgentPart].
+// Union satisfied by [TextPart], [ReasoningPart], [FilePart], [ToolPart],
+// [StepStartPart], [StepFinishPart], [SnapshotPart], [PartPatchPart] or
+// [AgentPart].
 type PartUnion interface {
 	implementsPart()
 }
@@ -1046,6 +1051,11 @@ func init() {
 			Type:               reflect.TypeOf(TextPart{}),
 			DiscriminatorValue: "text",
 		},
+		apijson.UnionVariant{
+			TypeFilter:         gjson.JSON,
+			Type:               reflect.TypeOf(ReasoningPart{}),
+			DiscriminatorValue: "reasoning",
+		},
 		apijson.UnionVariant{
 			TypeFilter:         gjson.JSON,
 			Type:               reflect.TypeOf(FilePart{}),
@@ -1134,6 +1144,7 @@ type PartType string
 
 const (
 	PartTypeText       PartType = "text"
+	PartTypeReasoning  PartType = "reasoning"
 	PartTypeFile       PartType = "file"
 	PartTypeTool       PartType = "tool"
 	PartTypeStepStart  PartType = "step-start"
@@ -1145,12 +1156,83 @@ const (
 
 func (r PartType) IsKnown() bool {
 	switch r {
-	case PartTypeText, PartTypeFile, PartTypeTool, PartTypeStepStart, PartTypeStepFinish, PartTypeSnapshot, PartTypePatch, PartTypeAgent:
+	case PartTypeText, PartTypeReasoning, PartTypeFile, PartTypeTool, PartTypeStepStart, PartTypeStepFinish, PartTypeSnapshot, PartTypePatch, PartTypeAgent:
 		return true
 	}
 	return false
 }
 
+type ReasoningPart struct {
+	ID               string                 `json:"id,required"`
+	MessageID        string                 `json:"messageID,required"`
+	SessionID        string                 `json:"sessionID,required"`
+	Text             string                 `json:"text,required"`
+	Type             ReasoningPartType      `json:"type,required"`
+	ProviderMetadata map[string]interface{} `json:"providerMetadata"`
+	Time             ReasoningPartTime      `json:"time"`
+	JSON             reasoningPartJSON      `json:"-"`
+}
+
+// reasoningPartJSON contains the JSON metadata for the struct [ReasoningPart]
+type reasoningPartJSON struct {
+	ID               apijson.Field
+	MessageID        apijson.Field
+	SessionID        apijson.Field
+	Text             apijson.Field
+	Type             apijson.Field
+	ProviderMetadata apijson.Field
+	Time             apijson.Field
+	raw              string
+	ExtraFields      map[string]apijson.Field
+}
+
+func (r *ReasoningPart) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r reasoningPartJSON) RawJSON() string {
+	return r.raw
+}
+
+func (r ReasoningPart) implementsPart() {}
+
+type ReasoningPartType string
+
+const (
+	ReasoningPartTypeReasoning ReasoningPartType = "reasoning"
+)
+
+func (r ReasoningPartType) IsKnown() bool {
+	switch r {
+	case ReasoningPartTypeReasoning:
+		return true
+	}
+	return false
+}
+
+type ReasoningPartTime struct {
+	Start float64               `json:"start,required"`
+	End   float64               `json:"end"`
+	JSON  reasoningPartTimeJSON `json:"-"`
+}
+
+// reasoningPartTimeJSON contains the JSON metadata for the struct
+// [ReasoningPartTime]
+type reasoningPartTimeJSON struct {
+	Start       apijson.Field
+	End         apijson.Field
+	raw         string
+	ExtraFields map[string]apijson.Field
+}
+
+func (r *ReasoningPartTime) UnmarshalJSON(data []byte) (err error) {
+	return apijson.UnmarshalRoot(data, r)
+}
+
+func (r reasoningPartTimeJSON) RawJSON() string {
+	return r.raw
+}
+
 type Session struct {
 	ID       string        `json:"id,required"`
 	Time     SessionTime   `json:"time,required"`

+ 1 - 0
packages/sdk/stainless/stainless.yml

@@ -101,6 +101,7 @@ resources:
       toolPart: ToolPart
       agentPart: AgentPart
       agentPartInput: AgentPartInput
+      reasoningPart: ReasoningPart
       stepStartPart: StepStartPart
       stepFinishPart: StepFinishPart
       snapshotPart: SnapshotPart

+ 19 - 1
packages/tui/internal/components/chat/message.go

@@ -208,6 +208,7 @@ func renderText(
 	showToolDetails bool,
 	width int,
 	extra string,
+	isThinking bool,
 	fileParts []opencode.FilePart,
 	agentParts []opencode.AgentPart,
 	toolCalls ...opencode.ToolPart,
@@ -219,8 +220,15 @@ func renderText(
 	var content string
 	switch casted := message.(type) {
 	case opencode.AssistantMessage:
+		bg := t.Background()
+		if isThinking {
+			bg = t.BackgroundPanel()
+		}
 		ts = time.UnixMilli(int64(casted.Time.Created))
-		content = util.ToMarkdown(text, width, t.Background())
+		content = util.ToMarkdown(text, width, bg)
+		if isThinking {
+			content = styles.NewStyle().Background(bg).Foreground(t.TextMuted()).Render("Thinking") + "\n\n" + content
+		}
 	case opencode.UserMessage:
 		ts = time.UnixMilli(int64(casted.Time.Created))
 		base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
@@ -385,6 +393,16 @@ func renderText(
 			WithBorderColor(t.Secondary()),
 		)
 	case opencode.AssistantMessage:
+		if isThinking {
+			return renderContentBlock(
+				app,
+				content,
+				width,
+				WithTextColor(t.Text()),
+				WithBackgroundColor(t.BackgroundPanel()),
+				WithBorderColor(t.BackgroundPanel()),
+			)
+		}
 		return renderContentBlock(
 			app,
 			content,

+ 32 - 0
packages/tui/internal/components/chat/messages.go

@@ -369,6 +369,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
 								m.showToolDetails,
 								width,
 								files,
+								false,
 								fileParts,
 								agentParts,
 							)
@@ -448,6 +449,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
 									m.showToolDetails,
 									width,
 									"",
+									false,
 									[]opencode.FilePart{},
 									[]opencode.AgentPart{},
 									toolCallParts...,
@@ -469,6 +471,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
 								m.showToolDetails,
 								width,
 								"",
+								false,
 								[]opencode.FilePart{},
 								[]opencode.AgentPart{},
 								toolCallParts...,
@@ -546,6 +549,35 @@ func (m *messagesComponent) renderView() tea.Cmd {
 							lineCount += lipgloss.Height(content) + 1
 							blocks = append(blocks, content)
 						}
+					case opencode.ReasoningPart:
+						if reverted {
+							continue
+						}
+						text := "..."
+						if part.Text != "" {
+							text = part.Text
+						}
+						content = renderText(
+							m.app,
+							message.Info,
+							text,
+							casted.ModelID,
+							m.showToolDetails,
+							width,
+							"",
+							true,
+							[]opencode.FilePart{},
+							[]opencode.AgentPart{},
+						)
+						content = lipgloss.PlaceHorizontal(
+							m.width,
+							lipgloss.Center,
+							content,
+							styles.WhitespaceStyle(t.Background()),
+						)
+						partCount++
+						lineCount += lipgloss.Height(content) + 1
+						blocks = append(blocks, content)
 					}
 				}
 			}

+ 4 - 0
packages/tui/internal/tui/tui.go

@@ -423,6 +423,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					switch casted := p.(type) {
 					case opencode.TextPart:
 						return casted.ID == msg.Properties.Part.ID
+					case opencode.ReasoningPart:
+						return casted.ID == msg.Properties.Part.ID
 					case opencode.FilePart:
 						return casted.ID == msg.Properties.Part.ID
 					case opencode.ToolPart:
@@ -461,6 +463,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					switch casted := p.(type) {
 					case opencode.TextPart:
 						return casted.ID == msg.Properties.PartID
+					case opencode.ReasoningPart:
+						return casted.ID == msg.Properties.PartID
 					case opencode.FilePart:
 						return casted.ID == msg.Properties.PartID
 					case opencode.ToolPart:

+ 5 - 7
packages/web/src/components/Share.tsx

@@ -61,7 +61,7 @@ export default function Share(props: {
   const [store, setStore] = createStore<{
     info?: Session.Info
     messages: Record<string, MessageWithParts>
-  }>({ info: props.info, messages: mapValues(props.messages, (x: any) => "metadata" in x ? fromV1(x) : x) })
+  }>({ info: props.info, messages: mapValues(props.messages, (x: any) => ("metadata" in x ? fromV1(x) : x)) })
   const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id)))
   const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"])
   createEffect(() => {
@@ -128,12 +128,10 @@ export default function Share(props: {
             setStore("messages", messageID, reconcile(d.content))
           }
           if (type === "part") {
-            setStore("messages", d.content.messageID, "parts", arr => {
+            setStore("messages", d.content.messageID, "parts", (arr) => {
               const index = arr.findIndex((x) => x.id === d.content.id)
-              if (index === -1)
-                arr.push(d.content)
-              if (index > -1)
-                arr[index] = d.content
+              if (index === -1) arr.push(d.content)
+              if (index > -1) arr[index] = d.content
               return [...arr]
             })
           }
@@ -350,7 +348,7 @@ export default function Share(props: {
                       if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
                         return false
                       return true
-                    })
+                    }),
                   )
 
                   return (

+ 21 - 2
packages/web/src/components/icons/custom.tsx

@@ -54,7 +54,10 @@ export function IconOpencode(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
 export function IconMeta(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
   return (
     <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-      <path fill="currentColor" d="M16.92 4.5c-1.851 0-3.298 1.394-4.608 3.165C10.512 5.373 9.007 4.5 7.206 4.5C3.534 4.5.72 9.28.72 14.338c0 3.165 1.531 5.162 4.096 5.162c1.846 0 3.174-.87 5.535-4.997c0 0 .984-1.737 1.66-2.934q.356.574.75 1.238l1.107 1.862c2.156 3.608 3.358 4.831 5.534 4.831c2.5 0 3.89-2.024 3.89-5.255c0-5.297-2.877-9.745-6.372-9.745m-8.37 8.886c-1.913 3-2.575 3.673-3.64 3.673c-1.097 0-1.749-.963-1.749-2.68c0-3.672 1.831-7.427 4.014-7.427c1.182 0 2.17.682 3.683 2.848c-1.437 2.204-2.307 3.586-2.307 3.586m7.224-.377L14.45 10.8a45 45 0 0 0-1.032-1.608c1.193-1.841 2.176-2.759 3.347-2.759c2.43 0 4.375 3.58 4.375 7.976c0 1.676-.549 2.649-1.686 2.649c-1.09 0-1.61-.72-3.68-4.05" />
+      <path
+        fill="currentColor"
+        d="M16.92 4.5c-1.851 0-3.298 1.394-4.608 3.165C10.512 5.373 9.007 4.5 7.206 4.5C3.534 4.5.72 9.28.72 14.338c0 3.165 1.531 5.162 4.096 5.162c1.846 0 3.174-.87 5.535-4.997c0 0 .984-1.737 1.66-2.934q.356.574.75 1.238l1.107 1.862c2.156 3.608 3.358 4.831 5.534 4.831c2.5 0 3.89-2.024 3.89-5.255c0-5.297-2.877-9.745-6.372-9.745m-8.37 8.886c-1.913 3-2.575 3.673-3.64 3.673c-1.097 0-1.749-.963-1.749-2.68c0-3.672 1.831-7.427 4.014-7.427c1.182 0 2.17.682 3.683 2.848c-1.437 2.204-2.307 3.586-2.307 3.586m7.224-.377L14.45 10.8a45 45 0 0 0-1.032-1.608c1.193-1.841 2.176-2.759 3.347-2.759c2.43 0 4.375 3.58 4.375 7.976c0 1.676-.549 2.649-1.686 2.649c-1.09 0-1.61-.72-3.68-4.05"
+      />
     </svg>
   )
 }
@@ -63,6 +66,22 @@ export function IconMeta(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
 export function IconRobot(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
   return (
     <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-      <path fill="currentColor" d="M13.5 2c0 .444-.193.843-.5 1.118V5h5a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h5V3.118A1.5 1.5 0 1 1 13.5 2M6 7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1zm-4 3H0v6h2zm20 0h2v6h-2zM9 14.5a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3m6 0a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3" /></svg>
+      <path
+        fill="currentColor"
+        d="M13.5 2c0 .444-.193.843-.5 1.118V5h5a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h5V3.118A1.5 1.5 0 1 1 13.5 2M6 7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1zm-4 3H0v6h2zm20 0h2v6h-2zM9 14.5a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3m6 0a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3"
+      />
+    </svg>
+  )
+}
+
+// https://icones.js.org/collection/ri?s=brain&icon=ri:brain-2-line
+export function IconBrain(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+  return (
+    <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+      <path
+        fill="currentColor"
+        d="M7 6q.001.357.115.67a1 1 0 0 1-1 1.333L6 8a2 2 0 0 0-1.491 3.333a1 1 0 0 1 0 1.334a2 2 0 0 0 .864 3.233a1 1 0 0 1 .67 1.135a2.5 2.5 0 1 0 4.932.824q.009-.063.025-.123V6a2 2 0 1 0-4 0m6 11.736q.016.06.025.122a2.5 2.5 0 1 0 4.932-.823a1 1 0 0 1 .67-1.135a2 2 0 0 0 .864-3.233a1 1 0 0 1 0-1.334a2 2 0 0 0-1.607-3.33a1 1 0 0 1-.999-1.333q.113-.313.115-.67a2 2 0 1 0-4 0zM9 2a4 4 0 0 1 3 1.354a4 4 0 0 1 6.998 2.771A4.002 4.002 0 0 1 21.465 12A3.997 3.997 0 0 1 20 17.465v.035a4.5 4.5 0 0 1-8 2.828A4.5 4.5 0 0 1 4 17.5v-.035A3.997 3.997 0 0 1 2.535 12a4.002 4.002 0 0 1 2.467-5.874L5 6a4 4 0 0 1 4-4"
+      />
+    </svg>
   )
 }

+ 23 - 0
packages/web/src/components/share/part.module.css

@@ -128,6 +128,29 @@
     max-width: var(--md-tool-width);
   }
 
+  [data-component="assistant-reasoning"] {
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 1rem;
+    flex-grow: 1;
+    max-width: var(--md-tool-width);
+
+    & > [data-component="assistant-reasoning-markdown"] {
+      align-self: flex-start;
+      font-size: 0.875rem;
+      border: 1px solid var(--sl-color-blue-high);
+      padding: 0.5rem calc(0.5rem + 3px);
+      border-radius: 0.25rem;
+      position: relative;
+
+      [data-component="copy-button"] {
+        top: 0.5rem;
+        right: calc(0.5rem - 1px);
+      }
+    }
+  }
+
   [data-component="assistant-text"] {
     min-width: 0;
     display: flex;

+ 11 - 1
packages/web/src/components/share/part.tsx

@@ -19,7 +19,7 @@ import {
   IconMagnifyingGlass,
   IconDocumentMagnifyingGlass,
 } from "../icons"
-import { IconMeta, IconRobot, IconOpenAI, IconGemini, IconAnthropic } from "../icons/custom"
+import { IconMeta, IconRobot, IconOpenAI, IconGemini, IconAnthropic, IconBrain } from "../icons/custom"
 import { ContentCode } from "./content-code"
 import { ContentDiff } from "./content-diff"
 import { ContentText } from "./content-text"
@@ -83,6 +83,9 @@ export function Part(props: PartProps) {
               >
                 {(model) => <ProviderIcon model={model()} size={18} />}
               </Match>
+              <Match when={props.part.type === "reasoning" && props.message.role === "assistant"}>
+                <IconBrain width={18} height={18} />
+              </Match>
               <Match when={props.part.type === "tool" && props.part.tool === "todowrite"}>
                 <IconQueueList width={18} height={18} />
               </Match>
@@ -157,6 +160,13 @@ export function Part(props: PartProps) {
             )}
           </div>
         )}
+        {props.message.role === "assistant" && props.part.type === "reasoning" && (
+          <div data-component="assistant-reasoning">
+            <div data-component="assistant-reasoning-markdown">
+              <ContentMarkdown expand={props.last} text={props.part.text || "Thinking..."} />
+            </div>
+          </div>
+        )}
         {props.message.role === "user" && props.part.type === "file" && (
           <div data-component="attachment">
             <div data-slot="copy">Attachment</div>