feat(tui): add minimal thinking mode with click-to-expand (#27623)

This commit is contained in:
Shoubhit Dash 2026-05-16 02:09:58 +05:30 committed by GitHub
commit f060874b29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 219 additions and 47 deletions

View file

@ -38,6 +38,7 @@ export const Flag = {
), ),
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("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_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
OPENCODE_DB: process.env["OPENCODE_DB"], OPENCODE_DB: process.env["OPENCODE_DB"],

View file

@ -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<body>". 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<ThinkingMode>("thinking_mode", "minimal")
// The kv signal exposes its setter typed as `Setter<T>` 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<ThinkingMode>)
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<ThinkingMode>(() => {
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,
}
}

View file

@ -5,6 +5,7 @@ import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap" import { useBindings } from "../../keymap"
@ -317,7 +318,11 @@ function AssistantMessage(props: {
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} /> <AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
</Match> </Match>
<Match when={part.type === "reasoning"}> <Match when={part.type === "reasoning"}>
<AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} /> <AssistantReasoning
part={part as SessionMessageAssistantReasoning}
subtleSyntax={props.subtleSyntax}
completedAt={() => props.message.time.completed}
/>
</Match> </Match>
<Match when={part.type === "tool"}> <Match when={part.type === "tool"}>
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} /> <AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
@ -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 { theme } = useTheme()
const thinking = useThinkingMode()
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) 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 ( return (
<Show when={content()}> <Show when={content() && thinking.mode() !== "hide"}>
<box <Switch>
paddingLeft={2} <Match when={!inMinimal() || expanded()}>
marginTop={1} <box
flexDirection="column" paddingLeft={2}
border={["left"]} marginTop={1}
customBorderChars={SplitBorder.customBorderChars} flexDirection="column"
borderColor={theme.backgroundElement} border={["left"]}
flexShrink={0} customBorderChars={SplitBorder.customBorderChars}
> borderColor={theme.backgroundElement}
<code flexShrink={0}
filetype="markdown" onMouseUp={toggle}
drawUnstyledText={false} >
streaming={true} <code
syntaxStyle={props.subtleSyntax} filetype="markdown"
content={"_Thinking:_ " + content()} drawUnstyledText={false}
conceal={true} streaming={true}
fg={theme.textMuted} syntaxStyle={props.subtleSyntax}
/> content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
</box> conceal={true}
fg={theme.textMuted}
/>
</box>
</Match>
<Match when={isDone()}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<text fg={theme.textMuted} wrapMode="none">
{title() ? "▶ Thought: " + title() : "▶ Thought"}
</text>
</box>
</Match>
<Match when={true}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show> </Show>
) )
} }

View file

@ -82,6 +82,7 @@ import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript" import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts" import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action" import { DialogRetryAction } from "../../component/dialog-retry-action"
@ -157,6 +158,7 @@ const context = createContext<{
width: number width: number
sessionID: string sessionID: string
conceal: () => boolean conceal: () => boolean
thinkingMode: () => ThinkingMode
showThinking: () => boolean showThinking: () => boolean
showTimestamps: () => boolean showTimestamps: () => boolean
showDetails: () => boolean showDetails: () => boolean
@ -214,7 +216,9 @@ export function Session() {
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto") const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false) const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true) 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 [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_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", value: "session.toggle.thinking",
category: "Session", category: "Session",
slash: { slash: {
@ -691,7 +700,17 @@ export function Session() {
aliases: ["toggle-thinking"], aliases: ["toggle-thinking"],
}, },
run: () => { 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() dialog.clear()
}, },
}, },
@ -1086,6 +1105,7 @@ export function Session() {
}, },
sessionID: route.sessionID, sessionID: route.sessionID,
conceal, conceal,
thinkingMode,
showThinking, showThinking,
showTimestamps, showTimestamps,
showDetails, showDetails,
@ -1492,32 +1512,77 @@ const PART_MAPPING = {
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, subtleSyntax } = useTheme() const { theme, subtleSyntax } = useTheme()
const ctx = use() 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(() => { const content = createMemo(() => {
// Filter out redacted reasoning chunks from OpenRouter // OpenRouter encrypts some reasoning blocks; drop the placeholder.
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
return props.part.text.replace("[REDACTED]", "").trim() 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<body>` 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 ( return (
<Show when={content() && ctx.showThinking()}> <Show when={content() && ctx.thinkingMode() !== "hide"}>
<box <Switch>
id={"text-" + props.part.id} <Match when={!inMinimal() || expanded()}>
paddingLeft={2} {/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
marginTop={1} <box
flexDirection="column" id={"text-" + props.part.id}
border={["left"]} paddingLeft={2}
customBorderChars={SplitBorder.customBorderChars} marginTop={1}
borderColor={theme.backgroundElement} flexDirection="column"
> border={["left"]}
<code customBorderChars={SplitBorder.customBorderChars}
filetype="markdown" borderColor={theme.backgroundElement}
drawUnstyledText={false} onMouseUp={toggle}
streaming={true} >
syntaxStyle={subtleSyntax()} <code
content={"_Thinking:_ " + content()} filetype="markdown"
conceal={ctx.conceal()} drawUnstyledText={false}
fg={theme.textMuted} streaming={true}
/> syntaxStyle={subtleSyntax()}
</box> content={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
</Match>
<Match when={isDone()}>
{/* Settled: ▶ at the start as the click-to-expand cue. */}
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<text fg={theme.textMuted} wrapMode="none">
{"▶ " +
(title()
? "Thought: " + title() + " · " + Locale.duration(duration())
: "Thought for " + Locale.duration(duration()))}
</text>
</box>
</Match>
<Match when={true}>
{/* Streaming: leading animated spinner, no disclosure arrow yet it
snaps in once reasoning settles, signalling "done, click to expand". */}
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show> </Show>
) )
} }