feat(tui): add minimal thinking mode with click-to-expand (#27623)
This commit is contained in:
parent
f21c582db9
commit
f060874b29
4 changed files with 219 additions and 47 deletions
|
|
@ -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"],
|
||||||
|
|
|
||||||
67
packages/opencode/src/cli/cmd/tui/context/thinking.ts
Normal file
67
packages/opencode/src/cli/cmd/tui/context/thinking.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue