Просмотр исходного кода

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

Shoubhit Dash 3 месяцев назад
Родитель
Сommit
f060874b29

+ 1 - 0
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"],

+ 67 - 0
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<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,
+  }
+}

+ 61 - 22
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: {
               <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,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 (
-    <Show when={content()}>
-      <box
-        paddingLeft={2}
-        marginTop={1}
-        flexDirection="column"
-        border={["left"]}
-        customBorderChars={SplitBorder.customBorderChars}
-        borderColor={theme.backgroundElement}
-        flexShrink={0}
-      >
-        <code
-          filetype="markdown"
-          drawUnstyledText={false}
-          streaming={true}
-          syntaxStyle={props.subtleSyntax}
-          content={"_Thinking:_ " + content()}
-          conceal={true}
-          fg={theme.textMuted}
-        />
-      </box>
+    <Show when={content() && thinking.mode() !== "hide"}>
+      <Switch>
+        <Match when={!inMinimal() || expanded()}>
+          <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={(inMinimal() ? "▼ " : "") + "_Thinking:_ " + content()}
+              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>
   )
 }

+ 90 - 25
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<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>
   )
 }