diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts
index 3ed67bb785..88270e3c20 100644
--- a/packages/core/src/flag/flag.ts
+++ b/packages/core/src/flag/flag.ts
@@ -38,6 +38,7 @@ export const Flag = {
),
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
+ OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DB: process.env["OPENCODE_DB"],
diff --git a/packages/opencode/src/cli/cmd/tui/context/thinking.ts b/packages/opencode/src/cli/cmd/tui/context/thinking.ts
new file mode 100644
index 0000000000..c5cae734b9
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/thinking.ts
@@ -0,0 +1,67 @@
+import { createMemo, type Setter } from "solid-js"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { useKV } from "./kv"
+
+export type ThinkingMode = "show" | "minimal" | "hide"
+
+const MODES: readonly ThinkingMode[] = ["show", "minimal", "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
+}
+
+export function isThinkingMode(value: unknown): value is ThinkingMode {
+ return typeof value === "string" && (MODES as readonly string[]).includes(value)
+}
+
+// Cycle order matches the slash command: show → minimal → hide → show.
+export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
+ const idx = MODES.indexOf(current)
+ return MODES[(idx + 1) % MODES.length] ?? "show"
+}
+
+export function useThinkingMode() {
+ const kv = useKV()
+ // Capture pre-state before `kv.signal` seeds a default, so we can detect
+ // first-time users with a legacy `thinking_visibility` boolean and migrate.
+ // The KVProvider only renders children once kv.ready, so reads here are safe.
+ const hadStored = kv.get("thinking_mode") !== undefined
+ const legacy = kv.get("thinking_visibility")
+ const [stored, setStored] = kv.signal("thinking_mode", "minimal")
+
+ // The kv signal exposes its setter typed as `Setter` which carries Solid's
+ // overload set; passing an updater fn through a property access loses the
+ // bivariance trick the existing `setX((prev) => ...)` callsites rely on.
+ // Wrap it in a sane shape so consumers can just call `set(next)` or pass
+ // an updater.
+ const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
+ if (typeof next === "function") setStored(next as Setter)
+ else setStored(() => next)
+ }
+
+ // Preserve previous experience for users who had explicitly toggled the
+ // legacy `thinking_visibility` boolean. First-time users (no legacy key)
+ // get the new "minimal" default.
+ if (!hadStored) {
+ if (legacy === true) set("show")
+ else if (legacy === false) set("hide")
+ }
+
+ const mode = createMemo(() => {
+ if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal"
+ const value = stored()
+ return isThinkingMode(value) ? value : "minimal"
+ })
+
+ return {
+ mode,
+ set,
+ locked: () => Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING === true,
+ }
+}
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 bcf3032ea3..dda6309d93 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,6 +5,7 @@ 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 { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap"
@@ -317,7 +318,11 @@ function AssistantMessage(props: {
-
+ props.message.time.completed}
+ />
@@ -378,30 +383,64 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
)
}
-function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
+function AssistantReasoning(props: {
+ part: SessionMessageAssistantReasoning
+ subtleSyntax: SyntaxStyle
+ completedAt: () => number | undefined
+}) {
const { theme } = useTheme()
+ const thinking = useThinkingMode()
+ const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
+ const inMinimal = createMemo(() => thinking.mode() === "minimal")
+ // 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 toggle = () => {
+ if (!inMinimal()) return
+ setExpanded((prev) => !prev)
+ }
+
return (
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {title() ? "▶ Thought: " + title() : "▶ Thought"}
+
+
+
+
+
+ {title() ? "Thinking: " + title() : "Thinking"}
+
+
+
)
}
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 e1922bfed5..376fed0d7d 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -82,6 +82,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 { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
@@ -157,6 +158,7 @@ const context = createContext<{
width: number
sessionID: string
conceal: () => boolean
+ thinkingMode: () => ThinkingMode
showThinking: () => boolean
showTimestamps: () => boolean
showDetails: () => boolean
@@ -214,7 +216,9 @@ export function Session() {
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true)
- const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
+ const thinking = useThinkingMode()
+ const thinkingMode = thinking.mode
+ const showThinking = createMemo(() => thinkingMode() !== "hide")
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
@@ -683,7 +687,12 @@ export function Session() {
},
},
{
- title: showThinking() ? "Hide thinking" : "Show thinking",
+ title: (() => {
+ const next = nextThinkingMode(thinkingMode())
+ if (next === "minimal") return "Switch thinking to minimal"
+ if (next === "hide") return "Hide thinking"
+ return "Show thinking"
+ })(),
value: "session.toggle.thinking",
category: "Session",
slash: {
@@ -691,7 +700,17 @@ export function Session() {
aliases: ["toggle-thinking"],
},
run: () => {
- setShowThinking((prev) => !prev)
+ // Env override forces minimal for the process. Updating KV here would
+ // silently diverge from what's rendered; tell the user instead.
+ if (thinking.locked()) {
+ toast.show({
+ message: "Thinking mode is locked to minimal by OPENCODE_EXPERIMENTAL_MINIMAL_THINKING",
+ variant: "info",
+ })
+ dialog.clear()
+ return
+ }
+ thinking.set(nextThinkingMode(thinkingMode()))
dialog.clear()
},
},
@@ -1086,6 +1105,7 @@ export function Session() {
},
sessionID: route.sessionID,
conceal,
+ thinkingMode,
showThinking,
showTimestamps,
showDetails,
@@ -1492,32 +1512,77 @@ const PART_MAPPING = {
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, subtleSyntax } = useTheme()
const ctx = use()
+ // Collapsed by default in minimal mode: a single line throughout, so the
+ // layout never shifts. Click to open the full markdown block, click to close.
+ const [expanded, setExpanded] = createSignal(false)
+
const content = createMemo(() => {
- // Filter out redacted reasoning chunks from OpenRouter
- // OpenRouter sends encrypted reasoning data that appears as [REDACTED]
+ // OpenRouter encrypts some reasoning blocks; drop the placeholder.
return props.part.text.replace("[REDACTED]", "").trim()
})
+ // Reasoning is finalized when the server sets `time.end` (see processor.ts).
+ // Flips independently of the parent message completing.
+ const isDone = createMemo(() => props.part.time.end !== undefined)
+ const inMinimal = createMemo(() => ctx.thinkingMode() === "minimal")
+ const duration = createMemo(() => {
+ 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()))
+
+ const toggle = () => {
+ if (!inMinimal()) return
+ setExpanded((prev) => !prev)
+ }
+
return (
-
-
-
-
+
+
+
+ {/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
+
+
+
+
+
+ {/* Settled: ▶ at the start as the click-to-expand cue. */}
+
+
+ {"▶ " +
+ (title()
+ ? "Thought: " + title() + " · " + Locale.duration(duration())
+ : "Thought for " + Locale.duration(duration()))}
+
+
+
+
+ {/* Streaming: leading animated spinner, no disclosure arrow yet — it
+ snaps in once reasoning settles, signalling "done, click to expand". */}
+
+ {title() ? "Thinking: " + title() : "Thinking"}
+
+
+
)
}