fix(tui): separate thinking header from markdown body (#29028)
This commit is contained in:
@@ -6,13 +6,14 @@ export type ThinkingMode = "show" | "hide"
|
|||||||
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
|
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
|
||||||
|
|
||||||
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
|
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
|
||||||
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
|
// title block: "**Inspecting PR workflow**\n\n<body>". Treat that first block,
|
||||||
// through the same shape, and the opencode provider relays it too. Pull the
|
// or a complete title still awaiting its body while streaming, as disclosure
|
||||||
// title out for a nicer label; return null for providers that don't follow
|
// metadata so the TUI can style its header independently from the markdown body.
|
||||||
// this convention so the caller can fall back to a generic "Thinking" string.
|
export function reasoningSummary(text: string) {
|
||||||
export function reasoningTitle(text: string): string | null {
|
const content = text.trim()
|
||||||
const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/)
|
const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
|
||||||
return match ? match[1].trim() : null
|
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 {
|
export function isThinkingMode(value: unknown): value is ThinkingMode {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { SplitBorder } from "@tui/component/border"
|
|||||||
import { Spinner } from "@tui/component/spinner"
|
import { Spinner } from "@tui/component/spinner"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { useLocal } from "@tui/context/local"
|
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 { 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 { useBindings } from "../../keymap"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||||
@@ -398,7 +398,7 @@ function AssistantReasoning(props: {
|
|||||||
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
|
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
|
||||||
// in the v2 SDK); we settle on parent-message completion instead.
|
// in the v2 SDK); we settle on parent-message completion instead.
|
||||||
const isDone = createMemo(() => props.completedAt() !== undefined)
|
const isDone = createMemo(() => props.completedAt() !== undefined)
|
||||||
const title = createMemo(() => reasoningTitle(content()))
|
const summary = createMemo(() => reasoningSummary(content()))
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (!inMinimal()) return
|
if (!inMinimal()) return
|
||||||
@@ -407,41 +407,50 @@ function AssistantReasoning(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={content()}>
|
<Show when={content()}>
|
||||||
<Switch>
|
<box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
|
||||||
<Match when={!inMinimal() || expanded()}>
|
<box onMouseUp={toggle}>
|
||||||
<box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0} 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
|
<code
|
||||||
filetype="markdown"
|
filetype="markdown"
|
||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
streaming={true}
|
streaming={true}
|
||||||
syntaxStyle={props.subtleSyntax}
|
syntaxStyle={props.subtleSyntax}
|
||||||
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
|
content={summary().body}
|
||||||
conceal={true}
|
conceal={true}
|
||||||
fg={theme.textMuted}
|
fg={theme.textMuted}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Show>
|
||||||
<Match when={isDone()}>
|
</box>
|
||||||
<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>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsedReasoningText(props: { title: string | null }) {
|
function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const fg = () =>
|
||||||
|
props.open
|
||||||
|
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
|
||||||
|
: theme.warning
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<text fg={theme.warning} wrapMode="none">
|
<text fg={fg()} wrapMode="none">
|
||||||
<span style={{ fg: theme.warning, italic: true }}>{props.title ? "+ Thought: " + props.title : "+ Thought"}</span>
|
<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>
|
</text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import * as Model from "../../util/model"
|
|||||||
import { formatTranscript } from "../../util/transcript"
|
import { formatTranscript } from "../../util/transcript"
|
||||||
import { UI } from "@/cli/ui.ts"
|
import { UI } from "@/cli/ui.ts"
|
||||||
import { useTuiConfig } from "../../context/tui-config"
|
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 { getScrollAcceleration } from "../../util/scroll"
|
||||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
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
|
const end = props.part.time.end
|
||||||
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
|
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
|
||||||
})
|
})
|
||||||
// OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n<body>` summary
|
const summary = createMemo(() => reasoningSummary(content()))
|
||||||
// blocks. Surface the title both while streaming and after settling so the
|
const syntax = createMemo(() => generateSubtleSyntax(theme))
|
||||||
// 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 toggle = () => {
|
const toggle = () => {
|
||||||
if (!inMinimal()) return
|
if (!inMinimal()) return
|
||||||
@@ -1529,47 +1525,65 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={content()}>
|
<Show when={content()}>
|
||||||
<Switch>
|
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
|
||||||
<Match when={!inMinimal() || expanded()}>
|
<box onMouseUp={toggle}>
|
||||||
{/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
|
<ReasoningHeader
|
||||||
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" onMouseUp={toggle}>
|
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
|
<code
|
||||||
filetype="markdown"
|
filetype="markdown"
|
||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
streaming={true}
|
streaming={true}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
// `_Thinking:_`/`_Thought:_` still drives markdown emphasis color and conceals the underscores;
|
content={summary().body}
|
||||||
// the syntax override above removes only the italic attribute from that emphasis token.
|
|
||||||
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
|
|
||||||
conceal={ctx.conceal()}
|
conceal={ctx.conceal()}
|
||||||
fg={theme.textMuted}
|
fg={theme.textMuted}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Show>
|
||||||
<Match when={isDone()}>
|
</box>
|
||||||
<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>
|
</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 { 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 (
|
return (
|
||||||
<text fg={theme.warning} wrapMode="none">
|
<text fg={fg()} wrapMode="none">
|
||||||
<span style={{ fg: theme.warning }}>
|
<Show when={props.toggleable}>
|
||||||
{props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
|
<span>{props.open ? "- " : "+ "}</span>
|
||||||
</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>
|
</text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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." })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user