|
|
@@ -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<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 (
|
|
|
- <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}
|
|
|
- >
|
|
|
- <code
|
|
|
- filetype="markdown"
|
|
|
- drawUnstyledText={false}
|
|
|
- streaming={true}
|
|
|
- syntaxStyle={subtleSyntax()}
|
|
|
- content={"_Thinking:_ " + content()}
|
|
|
- conceal={ctx.conceal()}
|
|
|
- fg={theme.textMuted}
|
|
|
- />
|
|
|
- </box>
|
|
|
+ <Show when={content() && ctx.thinkingMode() !== "hide"}>
|
|
|
+ <Switch>
|
|
|
+ <Match when={!inMinimal() || expanded()}>
|
|
|
+ {/* Full markdown block: `show` mode, or `minimal` after the user opens it. */}
|
|
|
+ <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={(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>
|
|
|
)
|
|
|
}
|