diff --git a/packages/opencode/src/cli/cmd/tui/context/thinking.ts b/packages/opencode/src/cli/cmd/tui/context/thinking.ts
index 55e995df11..bb1c2a6929 100644
--- a/packages/opencode/src/cli/cmd/tui/context/thinking.ts
+++ b/packages/opencode/src/cli/cmd/tui/context/thinking.ts
@@ -6,13 +6,14 @@ export type ThinkingMode = "show" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
-// title line: "**Inspecting PR workflow**\n\n
". GitHub Copilot routes
-// through the same shape, and the opencode provider relays it too. Pull the
-// title out for a nicer label; return null for providers that don't follow
-// this convention so the caller can fall back to a generic "Thinking" string.
-export function reasoningTitle(text: string): string | null {
- const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/)
- return match ? match[1].trim() : null
+// title block: "**Inspecting PR workflow**\n\n". Treat that first block,
+// or a complete title still awaiting its body while streaming, as disclosure
+// metadata so the TUI can style its header independently from the markdown body.
+export function reasoningSummary(text: string) {
+ const content = text.trim()
+ const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
+ if (!match) return { title: null, body: content }
+ return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
}
export function isThinkingMode(value: unknown): value is ThinkingMode {
diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
index 465b490fc1..8b9b088805 100644
--- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
+++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
@@ -5,9 +5,9 @@ import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
-import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
+import { reasoningSummary, useThinkingMode } from "@tui/context/thinking"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
-import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
+import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap"
import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
@@ -398,7 +398,7 @@ function AssistantReasoning(props: {
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
// in the v2 SDK); we settle on parent-message completion instead.
const isDone = createMemo(() => props.completedAt() !== undefined)
- const title = createMemo(() => reasoningTitle(content()))
+ const summary = createMemo(() => reasoningSummary(content()))
const toggle = () => {
if (!inMinimal()) return
@@ -407,41 +407,50 @@ function AssistantReasoning(props: {
return (
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- {title() ? "Thinking: " + title() : "Thinking"}
-
-
-
+
+
)
}
-function CollapsedReasoningText(props: { title: string | null }) {
+function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
const { theme } = useTheme()
+ const fg = () =>
+ props.open
+ ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
+ : theme.warning
return (
-
- {props.title ? "+ Thought: " + props.title : "+ Thought"}
+
+
+ {props.open ? "- " : "+ "}
+
+ {props.done ? "Thought" : "Thinking"}
+
+ :
+ {props.title}
+
)
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 8a10bea932..95d749f382 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -81,7 +81,7 @@ import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
-import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
+import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
@@ -1528,12 +1528,8 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
})
- // OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n` summary
- // blocks. Surface the title both while streaming and after settling so the
- // collapsed line carries real signal, not just a duration.
- const title = createMemo(() => reasoningTitle(content()))
- // Keep markdown emphasis for the existing thinking color/concealment, but render it without italics.
- const syntax = createMemo(() => generateSubtleSyntax(theme, { "markup.italic": { italic: false } }))
+ const summary = createMemo(() => reasoningSummary(content()))
+ const syntax = createMemo(() => generateSubtleSyntax(theme))
const toggle = () => {
if (!inMinimal()) return
@@ -1542,47 +1538,65 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
return (
-
-
- {/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- {title() ? "Thinking: " + title() : "Thinking"}
-
-
-
+
+
)
}
-function CollapsedReasoningText(props: { title: string | null; duration: number }) {
+function ReasoningHeader(props: {
+ toggleable: boolean
+ open: boolean
+ done: boolean
+ title: string | null
+ duration?: string
+}) {
const { theme } = useTheme()
- const duration = () => Locale.duration(props.duration)
+ const fg = () =>
+ props.open
+ ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
+ : theme.warning
return (
-
-
- {props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
-
+
+
+ {props.open ? "- " : "+ "}
+
+ {props.done ? "Thought" : "Thinking"}
+
+ :
+
+
+ {props.title}
+
+
+
+ {props.title ? " · " : ""}
+ {props.duration}
+
+
)
}
diff --git a/packages/opencode/test/cli/tui/thinking.test.ts b/packages/opencode/test/cli/tui/thinking.test.ts
new file mode 100644
index 0000000000..7f8af1a5f3
--- /dev/null
+++ b/packages/opencode/test/cli/tui/thinking.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test"
+import { reasoningSummary } from "../../../src/cli/cmd/tui/context/thinking"
+
+describe("reasoningSummary", () => {
+ test("extracts a leading summary title and leaves markdown body", () => {
+ expect(reasoningSummary("**Continuing Quality Review**\n\nDetails.\n\n**Next section**\n\nMore.")).toEqual({
+ title: "Continuing Quality Review",
+ body: "Details.\n\n**Next section**\n\nMore.",
+ })
+ })
+
+ test("extracts a completed title before its streamed body arrives", () => {
+ expect(reasoningSummary("**Continuing Quality Review**")).toEqual({
+ title: "Continuing Quality Review",
+ body: "",
+ })
+ })
+
+ test("preserves markdown-significant indentation in the extracted body", () => {
+ expect(reasoningSummary("**Continuing Quality Review**\n\n const value = true\n")).toEqual({
+ title: "Continuing Quality Review",
+ body: " const value = true",
+ })
+ })
+
+ test("does not consume ordinary leading bold content", () => {
+ expect(reasoningSummary("**Important:** keep this in the body.")).toEqual({
+ title: null,
+ body: "**Important:** keep this in the body.",
+ })
+ })
+
+ test("leaves content without a leading title in its body", () => {
+ expect(reasoningSummary("Details only.")).toEqual({ title: null, body: "Details only." })
+ })
+})