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

feat(app): show/hide reasoning summaries

Adam 2 месяцев назад
Родитель
Сommit
2a904ec56f

+ 12 - 0
packages/app/src/components/settings-general.tsx

@@ -250,6 +250,18 @@ export const SettingsGeneral: Component = () => {
             )}
           </Select>
         </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.reasoningSummaries.title")}
+          description={language.t("settings.general.row.reasoningSummaries.description")}
+        >
+          <div data-action="settings-reasoning-summaries">
+            <Switch
+              checked={settings.general.showReasoningSummaries()}
+              onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
+            />
+          </div>
+        </SettingsRow>
       </div>
     </div>
   )

+ 9 - 0
packages/app/src/context/settings.tsx

@@ -22,6 +22,7 @@ export interface Settings {
   general: {
     autoSave: boolean
     releaseNotes: boolean
+    showReasoningSummaries: boolean
   }
   updates: {
     startup: boolean
@@ -42,6 +43,7 @@ const defaultSettings: Settings = {
   general: {
     autoSave: true,
     releaseNotes: true,
+    showReasoningSummaries: false,
   },
   updates: {
     startup: true,
@@ -120,6 +122,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         setReleaseNotes(value: boolean) {
           setStore("general", "releaseNotes", value)
         },
+        showReasoningSummaries: withFallback(
+          () => store.general?.showReasoningSummaries,
+          defaultSettings.general.showReasoningSummaries,
+        ),
+        setShowReasoningSummaries(value: boolean) {
+          setStore("general", "showReasoningSummaries", value)
+        },
       },
       updates: {
         startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),

+ 2 - 0
packages/app/src/i18n/en.ts

@@ -610,6 +610,8 @@ export const dict = {
   "settings.general.row.theme.description": "Customise how OpenCode is themed.",
   "settings.general.row.font.title": "Font",
   "settings.general.row.font.description": "Customise the mono font used in code blocks",
+  "settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
+  "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
 
   "settings.general.row.wayland.title": "Use native Wayland",
   "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",

+ 3 - 0
packages/app/src/pages/session/message-timeline.tsx

@@ -14,6 +14,7 @@ import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useLanguage } from "@/context/language"
+import { useSettings } from "@/context/settings"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 
@@ -80,6 +81,7 @@ export function MessageTimeline(props: {
   const navigate = useNavigate()
   const sdk = useSDK()
   const sync = useSync()
+  const settings = useSettings()
   const dialog = useDialog()
   const language = useLanguage()
 
@@ -535,6 +537,7 @@ export function MessageTimeline(props: {
                     sessionID={sessionID() ?? ""}
                     messageID={message.id}
                     lastUserMessageID={props.lastUserMessageID}
+                    showReasoningSummaries={settings.general.showReasoningSummaries()}
                     classes={{
                       root: "min-w-0 w-full relative",
                       content: "flex flex-col justify-between !overflow-visible",

+ 8 - 4
packages/ui/src/components/message-part.tsx

@@ -96,6 +96,7 @@ export interface MessageProps {
   parts: PartType[]
   showAssistantCopyPartID?: string | null
   interrupted?: boolean
+  showReasoningSummaries?: boolean
 }
 
 export interface MessagePartProps {
@@ -264,14 +265,14 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
   return fallback
 }
 
-function renderable(part: PartType) {
+function renderable(part: PartType, showReasoningSummaries = true) {
   if (part.type === "tool") {
     if (HIDDEN_TOOLS.has(part.tool)) return false
     if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
     return true
   }
   if (part.type === "text") return !!part.text?.trim()
-  if (part.type === "reasoning") return !!part.text?.trim()
+  if (part.type === "reasoning") return showReasoningSummaries && !!part.text?.trim()
   return !!PART_MAPPING[part.type]
 }
 
@@ -280,6 +281,7 @@ export function AssistantParts(props: {
   showAssistantCopyPartID?: string | null
   turnDurationMs?: number
   working?: boolean
+  showReasoningSummaries?: boolean
 }) {
   const data = useData()
   const emptyParts: PartType[] = []
@@ -300,7 +302,7 @@ export function AssistantParts(props: {
 
     const parts = props.messages.flatMap((message) =>
       list(data.store.part?.[message.id], emptyParts)
-        .filter(renderable)
+        .filter((part) => renderable(part, props.showReasoningSummaries ?? true))
         .map((part) => ({ message, part })),
     )
 
@@ -480,6 +482,7 @@ export function Message(props: MessageProps) {
             message={assistantMessage() as AssistantMessage}
             parts={props.parts}
             showAssistantCopyPartID={props.showAssistantCopyPartID}
+            showReasoningSummaries={props.showReasoningSummaries}
           />
         )}
       </Match>
@@ -491,6 +494,7 @@ export function AssistantMessageDisplay(props: {
   message: AssistantMessage
   parts: PartType[]
   showAssistantCopyPartID?: string | null
+  showReasoningSummaries?: boolean
 }) {
   const grouped = createMemo(() => {
     const keys: string[] = []
@@ -519,7 +523,7 @@ export function AssistantMessageDisplay(props: {
     }
 
     parts.forEach((part, index) => {
-      if (!renderable(part)) return
+      if (!renderable(part, props.showReasoningSummaries ?? true)) return
 
       if (isContextGroupTool(part)) {
         if (start < 0) start = index

+ 12 - 0
packages/ui/src/components/session-turn.css

@@ -41,6 +41,8 @@
     display: flex;
     align-items: center;
     gap: 8px;
+    width: 100%;
+    min-width: 0;
     color: var(--text-weak);
     font-family: var(--font-family-sans);
     font-size: var(--font-size-base);
@@ -52,6 +54,16 @@
       width: 16px;
       height: 16px;
     }
+
+    [data-slot="session-turn-thinking-heading"] {
+      flex: 1 1 auto;
+      min-width: 0;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      color: var(--text-weaker);
+      font-weight: var(--font-weight-regular);
+    }
   }
 
   .error-card {

+ 84 - 14
packages/ui/src/components/session-turn.tsx

@@ -6,7 +6,7 @@ import { Binary } from "@opencode-ai/util/binary"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
 import { Dynamic } from "solid-js/web"
-import { AssistantParts, Message } from "./message-part"
+import { AssistantParts, Message, PART_MAPPING } from "./message-part"
 import { Card } from "./card"
 import { Accordion } from "./accordion"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -83,15 +83,55 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
 
 const hidden = new Set(["todowrite", "todoread"])
 
-function visible(part: PartType) {
+function partState(part: PartType, showReasoningSummaries: boolean) {
   if (part.type === "tool") {
-    if (hidden.has(part.tool)) return false
-    if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
-    return true
+    if (hidden.has(part.tool)) return
+    if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
+    return "visible" as const
+  }
+  if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
+  if (part.type === "reasoning") {
+    if (showReasoningSummaries) return "visible" as const
+    return
+  }
+  if (PART_MAPPING[part.type]) return "visible" as const
+  return
+}
+
+function clean(value: string) {
+  return value
+    .replace(/`([^`]+)`/g, "$1")
+    .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
+    .replace(/[*_~]+/g, "")
+    .trim()
+}
+
+function heading(text: string) {
+  const markdown = text.replace(/\r\n?/g, "\n")
+
+  const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
+  if (html?.[1]) {
+    const value = clean(html[1].replace(/<[^>]+>/g, " "))
+    if (value) return value
+  }
+
+  const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
+  if (atx?.[1]) {
+    const value = clean(atx[1])
+    if (value) return value
+  }
+
+  const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
+  if (setext?.[1]) {
+    const value = clean(setext[1])
+    if (value) return value
+  }
+
+  const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
+  if (strong?.[1]) {
+    const value = clean(strong[1])
+    if (value) return value
   }
-  if (part.type === "text") return !!part.text?.trim()
-  if (part.type === "reasoning") return !!part.text?.trim()
-  return false
 }
 
 export function SessionTurn(
@@ -99,6 +139,7 @@ export function SessionTurn(
     sessionID: string
     messageID: string
     lastUserMessageID?: string
+    showReasoningSummaries?: boolean
     onUserInteracted?: () => void
     classes?: {
       root?: string
@@ -242,6 +283,7 @@ export function SessionTurn(
 
   const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
   const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
+  const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
 
   const assistantCopyPartID = createMemo(() => {
     if (working()) return null
@@ -265,9 +307,33 @@ export function SessionTurn(
   const assistantVisible = createMemo(() =>
     assistantMessages().reduce((count, message) => {
       const parts = list(data.store.part?.[message.id], emptyParts)
-      return count + parts.filter(visible).length
+      return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length
     }, 0),
   )
+  const assistantTailVisible = createMemo(() =>
+    assistantMessages()
+      .flatMap((message) => list(data.store.part?.[message.id], emptyParts))
+      .flatMap((part) => {
+        if (partState(part, showReasoningSummaries()) !== "visible") return []
+        if (part.type === "text") return ["text" as const]
+        return ["other" as const]
+      })
+      .at(-1),
+  )
+  const reasoningHeading = createMemo(() =>
+    assistantMessages()
+      .flatMap((message) => list(data.store.part?.[message.id], emptyParts))
+      .filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning")
+      .map((part) => heading(part.text))
+      .filter((text): text is string => !!text)
+      .at(-1),
+  )
+  const showThinking = createMemo(() => {
+    if (!working() || !!error()) return false
+    if (showReasoningSummaries()) return assistantVisible() === 0
+    if (assistantTailVisible() === "text") return false
+    return true
+  })
 
   const autoScroll = createAutoScroll({
     working,
@@ -295,11 +361,6 @@ export function SessionTurn(
                 <div data-slot="session-turn-message-content" aria-live="off">
                   <Message message={msg()} parts={parts()} interrupted={interrupted()} />
                 </div>
-                <Show when={working() && assistantVisible() === 0 && !error()}>
-                  <div data-slot="session-turn-thinking">
-                    <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
-                  </div>
-                </Show>
                 <Show when={assistantMessages().length > 0}>
                   <div data-slot="session-turn-assistant-content" aria-hidden={working()}>
                     <AssistantParts
@@ -307,9 +368,18 @@ export function SessionTurn(
                       showAssistantCopyPartID={assistantCopyPartID()}
                       turnDurationMs={turnDurationMs()}
                       working={working()}
+                      showReasoningSummaries={showReasoningSummaries()}
                     />
                   </div>
                 </Show>
+                <Show when={showThinking()}>
+                  <div data-slot="session-turn-thinking">
+                    <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
+                    <Show when={!showReasoningSummaries() && reasoningHeading()}>
+                      {(text) => <span data-slot="session-turn-thinking-heading">{text()}</span>}
+                    </Show>
+                  </div>
+                </Show>
                 <Show when={edited() > 0 && !working()}>
                   <div data-slot="session-turn-diffs">
                     <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">