Bladeren bron

fix(tui): separate thinking header from markdown body (#29028)

Aiden Cline 3 maanden geleden
bovenliggende
commit
03bb53c389

+ 8 - 7
packages/opencode/src/cli/cmd/tui/context/thinking.ts

@@ -6,13 +6,14 @@ export type ThinkingMode = "show" | "hide"
 const MODES: readonly ThinkingMode[] = ["show", "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
+// title block: "**Inspecting PR workflow**\n\n<body>". Treat that first block,
+// or a complete title still awaiting its body while streaming, as disclosure
+// metadata so the TUI can style its header independently from the markdown body.
+export function reasoningSummary(text: string) {
+  const content = text.trim()
+  const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
+  if (!match) return { title: null, body: content }
+  return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
 }
 
 export function isThinkingMode(value: unknown): value is ThinkingMode {

+ 31 - 22
packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

@@ -5,9 +5,9 @@ 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 { reasoningSummary, useThinkingMode } from "@tui/context/thinking"
 import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
-import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
+import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
 import { useBindings } from "../../keymap"
 import { Locale } from "@/util/locale"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
@@ -398,7 +398,7 @@ function AssistantReasoning(props: {
   // 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 summary = createMemo(() => reasoningSummary(content()))
 
   const toggle = () => {
     if (!inMinimal()) return
@@ -407,41 +407,50 @@ function AssistantReasoning(props: {
 
   return (
     <Show when={content()}>
-      <Switch>
-        <Match when={!inMinimal() || expanded()}>
-          <box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0} onMouseUp={toggle}>
+      <box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
+        <box onMouseUp={toggle}>
+          <ReasoningHeader
+            toggleable={inMinimal()}
+            open={!inMinimal() || expanded()}
+            done={isDone()}
+            title={summary().title}
+          />
+        </box>
+        <Show when={(!inMinimal() || expanded()) && summary().body}>
+          <box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
             <code
               filetype="markdown"
               drawUnstyledText={false}
               streaming={true}
               syntaxStyle={props.subtleSyntax}
-              content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
+              content={summary().body}
               conceal={true}
               fg={theme.textMuted}
             />
           </box>
-        </Match>
-        <Match when={isDone()}>
-          <box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
-            <CollapsedReasoningText title={title()} />
-          </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>
+      </box>
     </Show>
   )
 }
 
-function CollapsedReasoningText(props: { title: string | null }) {
+function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
   const { theme } = useTheme()
+  const fg = () =>
+    props.open
+      ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
+      : theme.warning
 
   return (
-    <text fg={theme.warning} wrapMode="none">
-      <span style={{ fg: theme.warning, italic: true }}>{props.title ? "+ Thought: " + props.title : "+ Thought"}</span>
+    <text fg={fg()} wrapMode="none">
+      <Show when={props.toggleable}>
+        <span>{props.open ? "- " : "+ "}</span>
+      </Show>
+      <span>{props.done ? "Thought" : "Thinking"}</span>
+      <Show when={props.title}>
+        <span>: </span>
+        <span>{props.title}</span>
+      </Show>
     </text>
   )
 }

+ 46 - 32
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -81,7 +81,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 { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
 import { getScrollAcceleration } from "../../util/scroll"
 import { collapseToolOutput } from "../../util/collapse-tool-output"
 import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
@@ -1515,12 +1515,8 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
     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()))
-  // Keep markdown emphasis for the existing thinking color/concealment, but render it without italics.
-  const syntax = createMemo(() => generateSubtleSyntax(theme, { "markup.italic": { italic: false } }))
+  const summary = createMemo(() => reasoningSummary(content()))
+  const syntax = createMemo(() => generateSubtleSyntax(theme))
 
   const toggle = () => {
     if (!inMinimal()) return
@@ -1529,47 +1525,65 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
 
   return (
     <Show when={content()}>
-      <Switch>
-        <Match when={!inMinimal() || expanded()}>
-          {/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
-          <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" onMouseUp={toggle}>
+      <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
+        <box onMouseUp={toggle}>
+          <ReasoningHeader
+            toggleable={inMinimal()}
+            open={!inMinimal() || expanded()}
+            done={isDone()}
+            title={summary().title}
+            duration={isDone() ? Locale.duration(duration()) : undefined}
+          />
+        </box>
+        <Show when={(!inMinimal() || expanded()) && summary().body}>
+          <box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
             <code
               filetype="markdown"
               drawUnstyledText={false}
               streaming={true}
               syntaxStyle={syntax()}
-              // `_Thinking:_`/`_Thought:_` still drives markdown emphasis color and conceals the underscores;
-              // the syntax override above removes only the italic attribute from that emphasis token.
-              content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
+              content={summary().body}
               conceal={ctx.conceal()}
               fg={theme.textMuted}
             />
           </box>
-        </Match>
-        <Match when={isDone()}>
-          <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
-            <CollapsedReasoningText title={title()} duration={duration()} />
-          </box>
-        </Match>
-        <Match when={true}>
-          <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>
+      </box>
     </Show>
   )
 }
 
-function CollapsedReasoningText(props: { title: string | null; duration: number }) {
+function ReasoningHeader(props: {
+  toggleable: boolean
+  open: boolean
+  done: boolean
+  title: string | null
+  duration?: string
+}) {
   const { theme } = useTheme()
-  const duration = () => Locale.duration(props.duration)
+  const fg = () =>
+    props.open
+      ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
+      : theme.warning
 
   return (
-    <text fg={theme.warning} wrapMode="none">
-      <span style={{ fg: theme.warning }}>
-        {props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
-      </span>
+    <text fg={fg()} wrapMode="none">
+      <Show when={props.toggleable}>
+        <span>{props.open ? "- " : "+ "}</span>
+      </Show>
+      <span>{props.done ? "Thought" : "Thinking"}</span>
+      <Show when={props.title || props.duration}>
+        <span>: </span>
+      </Show>
+      <Show when={props.title}>
+        <span>{props.title}</span>
+      </Show>
+      <Show when={props.duration}>
+        <span>
+          {props.title ? " · " : ""}
+          {props.duration}
+        </span>
+      </Show>
     </text>
   )
 }

+ 36 - 0
packages/opencode/test/cli/tui/thinking.test.ts

@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test"
+import { reasoningSummary } from "../../../src/cli/cmd/tui/context/thinking"
+
+describe("reasoningSummary", () => {
+  test("extracts a leading summary title and leaves markdown body", () => {
+    expect(reasoningSummary("**Continuing Quality Review**\n\nDetails.\n\n**Next section**\n\nMore.")).toEqual({
+      title: "Continuing Quality Review",
+      body: "Details.\n\n**Next section**\n\nMore.",
+    })
+  })
+
+  test("extracts a completed title before its streamed body arrives", () => {
+    expect(reasoningSummary("**Continuing Quality Review**")).toEqual({
+      title: "Continuing Quality Review",
+      body: "",
+    })
+  })
+
+  test("preserves markdown-significant indentation in the extracted body", () => {
+    expect(reasoningSummary("**Continuing Quality Review**\n\n    const value = true\n")).toEqual({
+      title: "Continuing Quality Review",
+      body: "    const value = true",
+    })
+  })
+
+  test("does not consume ordinary leading bold content", () => {
+    expect(reasoningSummary("**Important:** keep this in the body.")).toEqual({
+      title: null,
+      body: "**Important:** keep this in the body.",
+    })
+  })
+
+  test("leaves content without a leading title in its body", () => {
+    expect(reasoningSummary("Details only.")).toEqual({ title: null, body: "Details only." })
+  })
+})