Compare commits

...

1 Commits

Author SHA1 Message Date
Shoubhit Dash bddc19e582 feat(tui): add minimal thinking mode with click-to-expand 2026-05-15 04:39:01 +05:30
4 changed files with 260 additions and 44 deletions
+1
View File
@@ -59,6 +59,7 @@ export const Flag = {
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"),
OPENCODE_EXPERIMENTAL_MINIMAL_THINKING: truthy("OPENCODE_EXPERIMENTAL_MINIMAL_THINKING"),
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
@@ -0,0 +1,59 @@
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"
// Grace period between reasoning finishing and the minimal-mode auto-collapse.
// Long enough that the fold doesn't feel snappy, short enough that you don't
// have to wait around. Bypassed if the user manually toggles in the meantime.
export const MINIMAL_AUTO_COLLAPSE_MS = 2000
const MODES: readonly ThinkingMode[] = ["show", "minimal", "hide"] as const
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", "show")
// 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)
}
// legacy=true → "show" (default already matches, no migration needed).
// legacy=false → "hide".
// legacy=undefined → first-time user, leave at default.
if (!hadStored && legacy === false) set("hide")
const mode = createMemo<ThinkingMode>(() => {
if (Flag.OPENCODE_EXPERIMENTAL_MINIMAL_THINKING) return "minimal"
const value = stored()
return isThinkingMode(value) ? value : "show"
})
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 { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { MINIMAL_AUTO_COLLAPSE_MS, 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"
@@ -28,7 +29,7 @@ import type {
ToolFileContent,
ToolTextContent,
} from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
const id = "internal:session-v2-debug"
const route = "session.v2.messages"
@@ -317,7 +318,11 @@ function AssistantMessage(props: {
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
</Match>
<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 when={part.type === "tool"}>
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
@@ -378,30 +383,89 @@ 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 [userExpanded, setUserExpanded] = createSignal<boolean | undefined>(undefined)
const [autoFolded, setAutoFolded] = createSignal(false)
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
// v2 reasoning parts have no `time.end` (see SessionMessageAssistantReasoning
// in the v2 SDK), so collapse on parent-message completion. Coarser than
// the main session view but the best signal we have here.
const collapsible = createMemo(() => thinking.mode() === "minimal" && props.completedAt() !== undefined)
createEffect(() => {
if (!collapsible()) {
setAutoFolded(false)
return
}
if (userExpanded() !== undefined) return
if (autoFolded()) return
const completed = props.completedAt()
if (completed === undefined) return
const remaining = MINIMAL_AUTO_COLLAPSE_MS - (Date.now() - completed)
if (remaining <= 0) {
setAutoFolded(true)
return
}
const timer = setTimeout(() => setAutoFolded(true), remaining)
onCleanup(() => clearTimeout(timer))
})
const expanded = createMemo(() => {
if (!collapsible()) return true
const choice = userExpanded()
if (choice !== undefined) return choice
return !autoFolded()
})
const collapsed = createMemo(() => collapsible() && !expanded())
const toggle = () => {
if (!collapsible()) return
setUserExpanded(!expanded())
}
return (
<Show when={content()}>
<box
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
flexShrink={0}
<Show when={content() && thinking.mode() !== "hide"}>
<Show
when={collapsed()}
fallback={
<box
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
flexShrink={0}
onMouseUp={toggle}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.subtleSyntax}
content={(collapsible() ? "▼ " : "") + "_Thinking:_ " + content()}
conceal={true}
fg={theme.textMuted}
/>
</box>
}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.subtleSyntax}
content={"_Thinking:_ " + content()}
conceal={true}
fg={theme.textMuted}
/>
</box>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<code
filetype="markdown"
drawUnstyledText={false}
syntaxStyle={props.subtleSyntax}
content={"_Thought_"}
conceal={true}
fg={theme.textMuted}
/>
</box>
</Show>
</Show>
)
}
@@ -7,6 +7,7 @@ import {
For,
Match,
on,
onCleanup,
onMount,
Show,
Switch,
@@ -82,6 +83,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 { MINIMAL_AUTO_COLLAPSE_MS, nextThinkingMode, 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 +159,7 @@ const context = createContext<{
width: number
sessionID: string
conceal: () => boolean
thinkingMode: () => ThinkingMode
showThinking: () => boolean
showTimestamps: () => boolean
showDetails: () => boolean
@@ -214,7 +217,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 +688,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 +701,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 +1106,7 @@ export function Session() {
},
sessionID: route.sessionID,
conceal,
thinkingMode,
showThinking,
showTimestamps,
showDetails,
@@ -1492,32 +1513,103 @@ const PART_MAPPING = {
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
const { theme, subtleSyntax } = useTheme()
const ctx = use()
// `userExpanded` is the user's explicit choice. `undefined` means "no choice
// yet, follow the auto behavior". Once they click, it pins to true/false.
const [userExpanded, setUserExpanded] = createSignal<boolean | undefined>(undefined)
// Flips to true after the grace period elapses post-finalization.
const [autoFolded, setAutoFolded] = createSignal(false)
const content = createMemo(() => {
// Filter out redacted reasoning chunks from OpenRouter
// OpenRouter sends encrypted reasoning data that appears as [REDACTED]
return props.part.text.replace("[REDACTED]", "").trim()
})
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
// This flips independently of the parent message completing, so the
// collapse happens as soon as thinking ends — even while text/tools stream.
const isDone = createMemo(() => props.part.time.end !== undefined)
const collapsible = createMemo(() => ctx.thinkingMode() === "minimal" && isDone())
const duration = createMemo(() => {
const end = props.part.time.end
if (end === undefined) return 0
return Math.max(0, end - props.part.time.start)
})
// Schedule auto-collapse a short delay after reasoning finalizes in minimal
// mode, so the fold doesn't snap the instant streaming stops. A manual
// toggle or leaving minimal mode cancels the pending timer. For reasoning
// that finished before this component mounted (e.g. loading a past session)
// we skip the grace and collapse immediately.
createEffect(() => {
if (!collapsible()) {
setAutoFolded(false)
return
}
if (userExpanded() !== undefined) return
if (autoFolded()) return
const end = props.part.time.end
if (end === undefined) return
const remaining = MINIMAL_AUTO_COLLAPSE_MS - (Date.now() - end)
if (remaining <= 0) {
setAutoFolded(true)
return
}
const timer = setTimeout(() => setAutoFolded(true), remaining)
onCleanup(() => clearTimeout(timer))
})
// Effective expansion: stay expanded while streaming and during grace; fold
// when auto-fold fires; user clicks override everything.
const expanded = createMemo(() => {
if (!collapsible()) return true
const choice = userExpanded()
if (choice !== undefined) return choice
return !autoFolded()
})
const collapsed = createMemo(() => collapsible() && !expanded())
const toggle = () => {
if (!collapsible()) return
setUserExpanded(!expanded())
}
return (
<Show when={content() && ctx.showThinking()}>
<box
id={"text-" + props.part.id}
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
<Show when={content() && ctx.thinkingMode() !== "hide"}>
<Show
when={collapsed()}
fallback={
<box
id={"text-" + props.part.id}
paddingLeft={2}
marginTop={1}
flexDirection="column"
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
onMouseUp={toggle}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
content={(collapsible() ? "▼ " : "") + "_Thinking:_ " + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={subtleSyntax()}
content={"_Thinking:_ " + content()}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<code
filetype="markdown"
drawUnstyledText={false}
syntaxStyle={subtleSyntax()}
content={"_Thought for " + Locale.duration(duration()) + "_"}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
</Show>
</Show>
)
}