Skip to content

Commit 2a904ec

Browse files
committed
feat(app): show/hide reasoning summaries
1 parent 0ce61c8 commit 2a904ec

7 files changed

Lines changed: 130 additions & 18 deletions

File tree

packages/app/src/components/settings-general.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,18 @@ export const SettingsGeneral: Component = () => {
250250
)}
251251
</Select>
252252
</SettingsRow>
253+
254+
<SettingsRow
255+
title={language.t("settings.general.row.reasoningSummaries.title")}
256+
description={language.t("settings.general.row.reasoningSummaries.description")}
257+
>
258+
<div data-action="settings-reasoning-summaries">
259+
<Switch
260+
checked={settings.general.showReasoningSummaries()}
261+
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
262+
/>
263+
</div>
264+
</SettingsRow>
253265
</div>
254266
</div>
255267
)

packages/app/src/context/settings.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface Settings {
2222
general: {
2323
autoSave: boolean
2424
releaseNotes: boolean
25+
showReasoningSummaries: boolean
2526
}
2627
updates: {
2728
startup: boolean
@@ -42,6 +43,7 @@ const defaultSettings: Settings = {
4243
general: {
4344
autoSave: true,
4445
releaseNotes: true,
46+
showReasoningSummaries: false,
4547
},
4648
updates: {
4749
startup: true,
@@ -120,6 +122,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
120122
setReleaseNotes(value: boolean) {
121123
setStore("general", "releaseNotes", value)
122124
},
125+
showReasoningSummaries: withFallback(
126+
() => store.general?.showReasoningSummaries,
127+
defaultSettings.general.showReasoningSummaries,
128+
),
129+
setShowReasoningSummaries(value: boolean) {
130+
setStore("general", "showReasoningSummaries", value)
131+
},
123132
},
124133
updates: {
125134
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),

packages/app/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,8 @@ export const dict = {
610610
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
611611
"settings.general.row.font.title": "Font",
612612
"settings.general.row.font.description": "Customise the mono font used in code blocks",
613+
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
614+
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
613615

614616
"settings.general.row.wayland.title": "Use native Wayland",
615617
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",

packages/app/src/pages/session/message-timeline.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/
1414
import { SessionContextUsage } from "@/components/session-context-usage"
1515
import { useDialog } from "@opencode-ai/ui/context/dialog"
1616
import { useLanguage } from "@/context/language"
17+
import { useSettings } from "@/context/settings"
1718
import { useSDK } from "@/context/sdk"
1819
import { useSync } from "@/context/sync"
1920

@@ -80,6 +81,7 @@ export function MessageTimeline(props: {
8081
const navigate = useNavigate()
8182
const sdk = useSDK()
8283
const sync = useSync()
84+
const settings = useSettings()
8385
const dialog = useDialog()
8486
const language = useLanguage()
8587

@@ -535,6 +537,7 @@ export function MessageTimeline(props: {
535537
sessionID={sessionID() ?? ""}
536538
messageID={message.id}
537539
lastUserMessageID={props.lastUserMessageID}
540+
showReasoningSummaries={settings.general.showReasoningSummaries()}
538541
classes={{
539542
root: "min-w-0 w-full relative",
540543
content: "flex flex-col justify-between !overflow-visible",

packages/ui/src/components/message-part.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface MessageProps {
9696
parts: PartType[]
9797
showAssistantCopyPartID?: string | null
9898
interrupted?: boolean
99+
showReasoningSummaries?: boolean
99100
}
100101

101102
export interface MessagePartProps {
@@ -264,14 +265,14 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
264265
return fallback
265266
}
266267

267-
function renderable(part: PartType) {
268+
function renderable(part: PartType, showReasoningSummaries = true) {
268269
if (part.type === "tool") {
269270
if (HIDDEN_TOOLS.has(part.tool)) return false
270271
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
271272
return true
272273
}
273274
if (part.type === "text") return !!part.text?.trim()
274-
if (part.type === "reasoning") return !!part.text?.trim()
275+
if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim()
275276
return !!PART_MAPPING[part.type]
276277
}
277278

@@ -280,6 +281,7 @@ export function AssistantParts(props: {
280281
showAssistantCopyPartID?: string | null
281282
turnDurationMs?: number
282283
working?: boolean
284+
showReasoningSummaries?: boolean
283285
}) {
284286
const data = useData()
285287
const emptyParts: PartType[] = []
@@ -300,7 +302,7 @@ export function AssistantParts(props: {
300302

301303
const parts = props.messages.flatMap((message) =>
302304
list(data.store.part?.[message.id], emptyParts)
303-
.filter(renderable)
305+
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
304306
.map((part) => ({ message, part })),
305307
)
306308

@@ -480,6 +482,7 @@ export function Message(props: MessageProps) {
480482
message={assistantMessage() as AssistantMessage}
481483
parts={props.parts}
482484
showAssistantCopyPartID={props.showAssistantCopyPartID}
485+
showReasoningSummaries={props.showReasoningSummaries}
483486
/>
484487
)}
485488
</Match>
@@ -491,6 +494,7 @@ export function AssistantMessageDisplay(props: {
491494
message: AssistantMessage
492495
parts: PartType[]
493496
showAssistantCopyPartID?: string | null
497+
showReasoningSummaries?: boolean
494498
}) {
495499
const grouped = createMemo(() => {
496500
const keys: string[] = []
@@ -519,7 +523,7 @@ export function AssistantMessageDisplay(props: {
519523
}
520524

521525
parts.forEach((part, index) => {
522-
if (!renderable(part)) return
526+
if (!renderable(part, props.showReasoningSummaries ?? true)) return
523527

524528
if (isContextGroupTool(part)) {
525529
if (start < 0) start = index

packages/ui/src/components/session-turn.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
display: flex;
4242
align-items: center;
4343
gap: 8px;
44+
width: 100%;
45+
min-width: 0;
4446
color: var(--text-weak);
4547
font-family: var(--font-family-sans);
4648
font-size: var(--font-size-base);
@@ -52,6 +54,16 @@
5254
width: 16px;
5355
height: 16px;
5456
}
57+
58+
[data-slot="session-turn-thinking-heading"] {
59+
flex: 1 1 auto;
60+
min-width: 0;
61+
overflow: hidden;
62+
text-overflow: ellipsis;
63+
white-space: nowrap;
64+
color: var(--text-weaker);
65+
font-weight: var(--font-weight-regular);
66+
}
5567
}
5668

5769
.error-card {

packages/ui/src/components/session-turn.tsx

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Binary } from "@opencode-ai/util/binary"
66
import { getDirectory, getFilename } from "@opencode-ai/util/path"
77
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
88
import { Dynamic } from "solid-js/web"
9-
import { AssistantParts, Message } from "./message-part"
9+
import { AssistantParts, Message, PART_MAPPING } from "./message-part"
1010
import { Card } from "./card"
1111
import { Accordion } from "./accordion"
1212
import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -83,22 +83,63 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
8383

8484
const hidden = new Set(["todowrite", "todoread"])
8585

86-
function visible(part: PartType) {
86+
function partState(part: PartType, showReasoningSummaries: boolean) {
8787
if (part.type === "tool") {
88-
if (hidden.has(part.tool)) return false
89-
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
90-
return true
88+
if (hidden.has(part.tool)) return
89+
if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
90+
return "visible" as const
91+
}
92+
if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
93+
if (part.type === "reasoning") {
94+
if (showReasoningSummaries) return "visible" as const
95+
return
96+
}
97+
if (PART_MAPPING[part.type]) return "visible" as const
98+
return
99+
}
100+
101+
function clean(value: string) {
102+
return value
103+
.replace(/`([^`]+)`/g, "$1")
104+
.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
105+
.replace(/[*_~]+/g, "")
106+
.trim()
107+
}
108+
109+
function heading(text: string) {
110+
const markdown = text.replace(/\r\n?/g, "\n")
111+
112+
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
113+
if (html?.[1]) {
114+
const value = clean(html[1].replace(/<[^>]+>/g, " "))
115+
if (value) return value
116+
}
117+
118+
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
119+
if (atx?.[1]) {
120+
const value = clean(atx[1])
121+
if (value) return value
122+
}
123+
124+
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
125+
if (setext?.[1]) {
126+
const value = clean(setext[1])
127+
if (value) return value
128+
}
129+
130+
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
131+
if (strong?.[1]) {
132+
const value = clean(strong[1])
133+
if (value) return value
91134
}
92-
if (part.type === "text") return !!part.text?.trim()
93-
if (part.type === "reasoning") return !!part.text?.trim()
94-
return false
95135
}
96136

97137
export function SessionTurn(
98138
props: ParentProps<{
99139
sessionID: string
100140
messageID: string
101141
lastUserMessageID?: string
142+
showReasoningSummaries?: boolean
102143
onUserInteracted?: () => void
103144
classes?: {
104145
root?: string
@@ -242,6 +283,7 @@ export function SessionTurn(
242283

243284
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
244285
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
286+
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
245287

246288
const assistantCopyPartID = createMemo(() => {
247289
if (working()) return null
@@ -265,9 +307,33 @@ export function SessionTurn(
265307
const assistantVisible = createMemo(() =>
266308
assistantMessages().reduce((count, message) => {
267309
const parts = list(data.store.part?.[message.id], emptyParts)
268-
return count + parts.filter(visible).length
310+
return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length
269311
}, 0),
270312
)
313+
const assistantTailVisible = createMemo(() =>
314+
assistantMessages()
315+
.flatMap((message) => list(data.store.part?.[message.id], emptyParts))
316+
.flatMap((part) => {
317+
if (partState(part, showReasoningSummaries()) !== "visible") return []
318+
if (part.type === "text") return ["text" as const]
319+
return ["other" as const]
320+
})
321+
.at(-1),
322+
)
323+
const reasoningHeading = createMemo(() =>
324+
assistantMessages()
325+
.flatMap((message) => list(data.store.part?.[message.id], emptyParts))
326+
.filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning")
327+
.map((part) => heading(part.text))
328+
.filter((text): text is string => !!text)
329+
.at(-1),
330+
)
331+
const showThinking = createMemo(() => {
332+
if (!working() || !!error()) return false
333+
if (showReasoningSummaries()) return assistantVisible() === 0
334+
if (assistantTailVisible() === "text") return false
335+
return true
336+
})
271337

272338
const autoScroll = createAutoScroll({
273339
working,
@@ -295,21 +361,25 @@ export function SessionTurn(
295361
<div data-slot="session-turn-message-content" aria-live="off">
296362
<Message message={msg()} parts={parts()} interrupted={interrupted()} />
297363
</div>
298-
<Show when={working() && assistantVisible() === 0 && !error()}>
299-
<div data-slot="session-turn-thinking">
300-
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
301-
</div>
302-
</Show>
303364
<Show when={assistantMessages().length > 0}>
304365
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
305366
<AssistantParts
306367
messages={assistantMessages()}
307368
showAssistantCopyPartID={assistantCopyPartID()}
308369
turnDurationMs={turnDurationMs()}
309370
working={working()}
371+
showReasoningSummaries={showReasoningSummaries()}
310372
/>
311373
</div>
312374
</Show>
375+
<Show when={showThinking()}>
376+
<div data-slot="session-turn-thinking">
377+
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
378+
<Show when={!showReasoningSummaries() && reasoningHeading()}>
379+
{(text) => <span data-slot="session-turn-thinking-heading">{text()}</span>}
380+
</Show>
381+
</div>
382+
</Show>
313383
<Show when={edited() > 0 && !working()}>
314384
<div data-slot="session-turn-diffs">
315385
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">

0 commit comments

Comments
 (0)