summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-20 06:46:03 -0600
committerAdam <[email protected]>2026-02-20 10:05:09 -0600
commit2a904ec56f202641d3172fc87842e2b73d6fb149 (patch)
treed72e4aec4c3b621d0b69213681b6cc285cf7cfa9
parent0ce61c817b74e31e08bd140611e2f7ae6ba1684c (diff)
downloadopencode-2a904ec56f202641d3172fc87842e2b73d6fb149.tar.gz
opencode-2a904ec56f202641d3172fc87842e2b73d6fb149.zip
feat(app): show/hide reasoning summaries
-rw-r--r--packages/app/src/components/settings-general.tsx12
-rw-r--r--packages/app/src/context/settings.tsx9
-rw-r--r--packages/app/src/i18n/en.ts2
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx3
-rw-r--r--packages/ui/src/components/message-part.tsx12
-rw-r--r--packages/ui/src/components/session-turn.css12
-rw-r--r--packages/ui/src/components/session-turn.tsx98
7 files changed, 130 insertions, 18 deletions
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index df71fd77e..beb39b355 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -250,6 +250,18 @@ export const SettingsGeneral: Component = () => {
)}
</Select>
</SettingsRow>
+
+ <SettingsRow
+ title={language.t("settings.general.row.reasoningSummaries.title")}
+ description={language.t("settings.general.row.reasoningSummaries.description")}
+ >
+ <div data-action="settings-reasoning-summaries">
+ <Switch
+ checked={settings.general.showReasoningSummaries()}
+ onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
+ />
+ </div>
+ </SettingsRow>
</div>
</div>
)
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index fbcd0a851..d279a7f32 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -22,6 +22,7 @@ export interface Settings {
general: {
autoSave: boolean
releaseNotes: boolean
+ showReasoningSummaries: boolean
}
updates: {
startup: boolean
@@ -42,6 +43,7 @@ const defaultSettings: Settings = {
general: {
autoSave: true,
releaseNotes: true,
+ showReasoningSummaries: false,
},
updates: {
startup: true,
@@ -120,6 +122,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
+ showReasoningSummaries: withFallback(
+ () => store.general?.showReasoningSummaries,
+ defaultSettings.general.showReasoningSummaries,
+ ),
+ setShowReasoningSummaries(value: boolean) {
+ setStore("general", "showReasoningSummaries", value)
+ },
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 8837dcbad..a8c27cc63 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -610,6 +610,8 @@ export const dict = {
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Customise the mono font used in code blocks",
+ "settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
+ "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 567ef5fc8..6ac89a3a7 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -14,6 +14,7 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
+import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -80,6 +81,7 @@ export function MessageTimeline(props: {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
+ const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
@@ -535,6 +537,7 @@ export function MessageTimeline(props: {
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
+ showReasoningSummaries={settings.general.showReasoningSummaries()}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 6421985e0..828ddbe87 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -96,6 +96,7 @@ export interface MessageProps {
parts: PartType[]
showAssistantCopyPartID?: string | null
interrupted?: boolean
+ showReasoningSummaries?: boolean
}
export interface MessagePartProps {
@@ -264,14 +265,14 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
return fallback
}
-function renderable(part: PartType) {
+function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
return true
}
if (part.type === "text") return !!part.text?.trim()
- if (part.type === "reasoning") return !!part.text?.trim()
+ if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim()
return !!PART_MAPPING[part.type]
}
@@ -280,6 +281,7 @@ export function AssistantParts(props: {
showAssistantCopyPartID?: string | null
turnDurationMs?: number
working?: boolean
+ showReasoningSummaries?: boolean
}) {
const data = useData()
const emptyParts: PartType[] = []
@@ -300,7 +302,7 @@ export function AssistantParts(props: {
const parts = props.messages.flatMap((message) =>
list(data.store.part?.[message.id], emptyParts)
- .filter(renderable)
+ .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
.map((part) => ({ message, part })),
)
@@ -480,6 +482,7 @@ export function Message(props: MessageProps) {
message={assistantMessage() as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
+ showReasoningSummaries={props.showReasoningSummaries}
/>
)}
</Match>
@@ -491,6 +494,7 @@ export function AssistantMessageDisplay(props: {
message: AssistantMessage
parts: PartType[]
showAssistantCopyPartID?: string | null
+ showReasoningSummaries?: boolean
}) {
const grouped = createMemo(() => {
const keys: string[] = []
@@ -519,7 +523,7 @@ export function AssistantMessageDisplay(props: {
}
parts.forEach((part, index) => {
- if (!renderable(part)) return
+ if (!renderable(part, props.showReasoningSummaries ?? true)) return
if (isContextGroupTool(part)) {
if (start < 0) start = index
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index bf1258d2e..9639e6635 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -41,6 +41,8 @@
display: flex;
align-items: center;
gap: 8px;
+ width: 100%;
+ min-width: 0;
color: var(--text-weak);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
@@ -52,6 +54,16 @@
width: 16px;
height: 16px;
}
+
+ [data-slot="session-turn-thinking-heading"] {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-weaker);
+ font-weight: var(--font-weight-regular);
+ }
}
.error-card {
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index aa2769280..33e72fb1e 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -6,7 +6,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
-import { AssistantParts, Message } from "./message-part"
+import { AssistantParts, Message, PART_MAPPING } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -83,15 +83,55 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
const hidden = new Set(["todowrite", "todoread"])
-function visible(part: PartType) {
+function partState(part: PartType, showReasoningSummaries: boolean) {
if (part.type === "tool") {
- if (hidden.has(part.tool)) return false
- if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
- return true
+ if (hidden.has(part.tool)) return
+ if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
+ return "visible" as const
+ }
+ if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
+ if (part.type === "reasoning") {
+ if (showReasoningSummaries) return "visible" as const
+ return
+ }
+ if (PART_MAPPING[part.type]) return "visible" as const
+ return
+}
+
+function clean(value: string) {
+ return value
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
+ .replace(/[*_~]+/g, "")
+ .trim()
+}
+
+function heading(text: string) {
+ const markdown = text.replace(/\r\n?/g, "\n")
+
+ const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
+ if (html?.[1]) {
+ const value = clean(html[1].replace(/<[^>]+>/g, " "))
+ if (value) return value
+ }
+
+ const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
+ if (atx?.[1]) {
+ const value = clean(atx[1])
+ if (value) return value
+ }
+
+ const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
+ if (setext?.[1]) {
+ const value = clean(setext[1])
+ if (value) return value
+ }
+
+ const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
+ if (strong?.[1]) {
+ const value = clean(strong[1])
+ if (value) return value
}
- if (part.type === "text") return !!part.text?.trim()
- if (part.type === "reasoning") return !!part.text?.trim()
- return false
}
export function SessionTurn(
@@ -99,6 +139,7 @@ export function SessionTurn(
sessionID: string
messageID: string
lastUserMessageID?: string
+ showReasoningSummaries?: boolean
onUserInteracted?: () => void
classes?: {
root?: string
@@ -242,6 +283,7 @@ export function SessionTurn(
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
+ const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
const assistantCopyPartID = createMemo(() => {
if (working()) return null
@@ -265,9 +307,33 @@ export function SessionTurn(
const assistantVisible = createMemo(() =>
assistantMessages().reduce((count, message) => {
const parts = list(data.store.part?.[message.id], emptyParts)
- return count + parts.filter(visible).length
+ return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length
}, 0),
)
+ const assistantTailVisible = createMemo(() =>
+ assistantMessages()
+ .flatMap((message) => list(data.store.part?.[message.id], emptyParts))
+ .flatMap((part) => {
+ if (partState(part, showReasoningSummaries()) !== "visible") return []
+ if (part.type === "text") return ["text" as const]
+ return ["other" as const]
+ })
+ .at(-1),
+ )
+ const reasoningHeading = createMemo(() =>
+ assistantMessages()
+ .flatMap((message) => list(data.store.part?.[message.id], emptyParts))
+ .filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning")
+ .map((part) => heading(part.text))
+ .filter((text): text is string => !!text)
+ .at(-1),
+ )
+ const showThinking = createMemo(() => {
+ if (!working() || !!error()) return false
+ if (showReasoningSummaries()) return assistantVisible() === 0
+ if (assistantTailVisible() === "text") return false
+ return true
+ })
const autoScroll = createAutoScroll({
working,
@@ -295,11 +361,6 @@ export function SessionTurn(
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={parts()} interrupted={interrupted()} />
</div>
- <Show when={working() && assistantVisible() === 0 && !error()}>
- <div data-slot="session-turn-thinking">
- <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
- </div>
- </Show>
<Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<AssistantParts
@@ -307,9 +368,18 @@ export function SessionTurn(
showAssistantCopyPartID={assistantCopyPartID()}
turnDurationMs={turnDurationMs()}
working={working()}
+ showReasoningSummaries={showReasoningSummaries()}
/>
</div>
</Show>
+ <Show when={showThinking()}>
+ <div data-slot="session-turn-thinking">
+ <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
+ <Show when={!showReasoningSummaries() && reasoningHeading()}>
+ {(text) => <span data-slot="session-turn-thinking-heading">{text()}</span>}
+ </Show>
+ </div>
+ </Show>
<Show when={edited() > 0 && !working()}>
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">