diff --git a/packages/opencode/src/cli/cmd/tui/context/thinking.ts b/packages/opencode/src/cli/cmd/tui/context/thinking.ts index 55e995df11..bb1c2a6929 100644 --- a/packages/opencode/src/cli/cmd/tui/context/thinking.ts +++ b/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". 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". 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 { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 465b490fc1..8b9b088805 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/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 ( - - - + + + + + + - - - - - - - - - {title() ? "Thinking: " + title() : "Thinking"} - - - + + ) } -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 ( - - {props.title ? "+ Thought: " + props.title : "+ Thought"} + + + {props.open ? "- " : "+ "} + + {props.done ? "Thought" : "Thinking"} + + : + {props.title} + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 162ad62a1e..c34ff71b73 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/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" @@ -1517,12 +1517,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` 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 @@ -1531,47 +1527,65 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass return ( - - - {/* Full markdown block: `show` mode, or `hide` after the user opens it. */} - + + + + + + - - - - - - - - - {title() ? "Thinking: " + title() : "Thinking"} - - - + + ) } -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 ( - - - {props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()} - + + + {props.open ? "- " : "+ "} + + {props.done ? "Thought" : "Thinking"} + + : + + + {props.title} + + + + {props.title ? " · " : ""} + {props.duration} + + ) } diff --git a/packages/opencode/test/cli/tui/thinking.test.ts b/packages/opencode/test/cli/tui/thinking.test.ts new file mode 100644 index 0000000000..7f8af1a5f3 --- /dev/null +++ b/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." }) + }) +})