feat: thinking blocks rendered in tui and share page

This commit is contained in:
adamdotdevin 2025-08-10 19:24:16 -05:00
parent 20e818ad05
commit b8d2aebf09
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
17 changed files with 324 additions and 55 deletions

View file

@ -87,7 +87,22 @@ export namespace ProviderTransform {
return { return {
reasoningEffort: "minimal", reasoningEffort: "minimal",
textVerbosity: "low", 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 },
// }
// }
} }
} }

View file

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

View file

@ -118,6 +118,21 @@ export namespace MessageV2 {
}) })
export type TextPart = z.infer<typeof TextPart> 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({ export const ToolPart = PartBase.extend({
type: z.literal("tool"), type: z.literal("tool"),
callID: z.string(), callID: z.string(),
@ -229,6 +244,7 @@ export namespace MessageV2 {
export const Part = z export const Part = z
.discriminatedUnion("type", [ .discriminatedUnion("type", [
TextPart, TextPart,
ReasoningPart,
FilePart, FilePart,
ToolPart, ToolPart,
StepStartPart, StepStartPart,

View file

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

View file

@ -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#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#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#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#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#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> - <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>

View file

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

View file

@ -21,7 +21,7 @@ echo "==> Starting mock server with URL ${URL}"
# Run prism mock on the given spec # Run prism mock on the given spec
if [ "$1" == "--daemon" ]; then 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 # Wait for server to come online
echo -n "Waiting for server" echo -n "Waiting for server"
@ -37,5 +37,5 @@ if [ "$1" == "--daemon" ]; then
echo echo
else 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 fi

View file

@ -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 "To run the server, pass in the path or url of your OpenAPI"
echo -e "spec to the prism command:" echo -e "spec to the prism command:"
echo 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/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}"
echo echo
exit 1 exit 1

View file

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

View file

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

View file

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

View file

@ -369,6 +369,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
m.showToolDetails, m.showToolDetails,
width, width,
files, files,
false,
fileParts, fileParts,
agentParts, agentParts,
) )
@ -448,6 +449,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
m.showToolDetails, m.showToolDetails,
width, width,
"", "",
false,
[]opencode.FilePart{}, []opencode.FilePart{},
[]opencode.AgentPart{}, []opencode.AgentPart{},
toolCallParts..., toolCallParts...,
@ -469,6 +471,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
m.showToolDetails, m.showToolDetails,
width, width,
"", "",
false,
[]opencode.FilePart{}, []opencode.FilePart{},
[]opencode.AgentPart{}, []opencode.AgentPart{},
toolCallParts..., toolCallParts...,
@ -546,6 +549,35 @@ func (m *messagesComponent) renderView() tea.Cmd {
lineCount += lipgloss.Height(content) + 1 lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content) 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)
} }
} }
} }

View file

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

View file

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

View file

@ -54,7 +54,10 @@ export function IconOpencode(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
export function IconMeta(props: JSX.SvgSVGAttributes<SVGSVGElement>) { export function IconMeta(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return ( return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <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> </svg>
) )
} }
@ -63,6 +66,22 @@ export function IconMeta(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
export function IconRobot(props: JSX.SvgSVGAttributes<SVGSVGElement>) { export function IconRobot(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return ( return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <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>
) )
} }

View file

@ -128,6 +128,29 @@
max-width: var(--md-tool-width); 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"] { [data-component="assistant-text"] {
min-width: 0; min-width: 0;
display: flex; display: flex;

View file

@ -19,7 +19,7 @@ import {
IconMagnifyingGlass, IconMagnifyingGlass,
IconDocumentMagnifyingGlass, IconDocumentMagnifyingGlass,
} from "../icons" } 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 { ContentCode } from "./content-code"
import { ContentDiff } from "./content-diff" import { ContentDiff } from "./content-diff"
import { ContentText } from "./content-text" import { ContentText } from "./content-text"
@ -83,6 +83,9 @@ export function Part(props: PartProps) {
> >
{(model) => <ProviderIcon model={model()} size={18} />} {(model) => <ProviderIcon model={model()} size={18} />}
</Match> </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"}> <Match when={props.part.type === "tool" && props.part.tool === "todowrite"}>
<IconQueueList width={18} height={18} /> <IconQueueList width={18} height={18} />
</Match> </Match>
@ -157,6 +160,13 @@ export function Part(props: PartProps) {
)} )}
</div> </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" && ( {props.message.role === "user" && props.part.type === "file" && (
<div data-component="attachment"> <div data-component="attachment">
<div data-slot="copy">Attachment</div> <div data-slot="copy">Attachment</div>