diff --git a/packages/tui/src/routes/session/message-parts.tsx b/packages/tui/src/routes/session/message-parts.tsx index 5a7af359311f..c61c2058b450 100644 --- a/packages/tui/src/routes/session/message-parts.tsx +++ b/packages/tui/src/routes/session/message-parts.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, Match, Show, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, Match, on, Show, Switch } from "solid-js" import { RGBA, TextAttributes, type MouseEvent } from "@opentui/core" import { useRenderer, type JSX } from "@opentui/solid" import type { @@ -52,15 +52,20 @@ export function ReasoningPart(props: { generateThinkingSyntax(syntax(), theme.text.muted, renderer.nativeScene), ) const ctx = use() - // Collapsed by default in hide 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) + // Open by default when thinking is shown, collapsed to one line when hidden. + // Clicking the header overrides that default either way, so a block can be + // folded without hiding every block in the session. + const [override, setOverride] = createSignal() const content = createMemo(() => reasoningContent(props.part)) const isDone = createMemo( () => props.part.time?.completed !== undefined || props.message.time.completed !== undefined, ) const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") + // A new session default applies to every block; drop per-block overrides so + // switching between show and hide cannot leave a stale folded state behind. + createEffect(on(inMinimal, () => setOverride(undefined), { defer: true })) + const open = () => override() ?? !inMinimal() const duration = createMemo(() => { const end = props.part.time?.completed ?? props.message.time.completed const start = props.part.time?.created ?? props.message.time.created @@ -68,30 +73,30 @@ export function ReasoningPart(props: { }) const summary = createMemo(() => reasoningSummary(content())) const toggle = () => { - if (!inMinimal()) return - setExpanded((prev) => !prev) + // Selecting the header text for a copy must not fold the block. + if (renderer.getSelection()?.getSelectedText()) return + setOverride(!open()) } return ( - + props.open @@ -148,9 +147,7 @@ function ReasoningHeader(props: { - - {props.open ? "- " : "+ "} - + {props.open ? "- " : "+ "} Thought : diff --git a/packages/tui/test/cli/tui/reasoning-disclosure.test.tsx b/packages/tui/test/cli/tui/reasoning-disclosure.test.tsx new file mode 100644 index 000000000000..19264463cbd7 --- /dev/null +++ b/packages/tui/test/cli/tui/reasoning-disclosure.test.tsx @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { addDefaultParsers } from "@opentui/core" +import { createSignal } from "solid-js" +import type { SessionMessageAssistant, SessionMessageAssistantReasoning } from "@opencode/client" +import parsers from "../../../src/parsers-config" +import { ConfigProvider } from "../../../src/config" +import { ThemeProvider } from "../../../src/context/theme" +import { createTimelineAnchors } from "../../../src/routes/session/anchors" +import { ReasoningPart } from "../../../src/routes/session/message-parts" +import { context } from "../../../src/routes/session/render-context" +import { emptyThemeSource } from "../../fixture/fixture" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +test("toggles a reasoning block in show and hide modes", async () => { + addDefaultParsers(parsers.parsers) + const [mode, setMode] = createSignal<"show" | "hide">("show") + const part: SessionMessageAssistantReasoning = { + type: "reasoning", + text: "**Inspecting stability**\n\nThe body of the thought.", + time: { created: 0, completed: 2000 }, + } + const message: SessionMessageAssistant = { + id: "msg_reasoning", + type: "assistant", + agent: "build", + model: { providerID: "fixture", id: "fixture" }, + time: { created: 0, completed: 2000 }, + content: [part], + } + const config = createTuiResolvedConfig({ animations: false }) + const app = await testRender( + () => ( + + + + false, + setGroupExpanded: () => {}, + thinkingMode: mode, + markdownMode: () => "rendered", + groupExploration: () => false, + legacyTurns: () => false, + diffWrapMode: () => "word", + models: () => [], + messageIndex: () => undefined, + config, + mutatePending: async () => true, + pendingDelivery: () => undefined, + }} + > + + + + + + + + ), + { width: 60, height: 20 }, + ) + const frame = () => app.captureCharFrame() + const headerRow = () => + frame() + .split("\n") + .findIndex((line) => line.includes("Thought")) + const click = async () => { + // A double click turns into a word selection, and a selected header is + // intentionally not a disclosure toggle. + app.renderer.clearSelection() + const row = headerRow() + expect(row).toBeGreaterThan(0) + await app.mockMouse.click(6, row) + await app.renderOnce() + } + try { + app.renderer.start() + await app.waitForFrame((value) => value.includes("The body of the thought.")) + expect(frame()).toContain("- Thought") + expect(frame()).toContain("Inspecting stability") + + await click() + await app.waitForVisualIdle({ quietFrames: 3 }) + expect(frame()).not.toContain("The body of the thought.") + expect(frame()).toContain("+ Thought: Inspecting stability") + + await click() + await app.waitForFrame((value) => value.includes("The body of the thought.")) + + setMode("hide") + await app.waitForVisualIdle({ quietFrames: 3 }) + expect(frame()).not.toContain("The body of the thought.") + expect(frame()).toContain("+ Thought: Inspecting stability") + + await click() + await app.waitForFrame((value) => value.includes("The body of the thought.")) + + setMode("show") + await app.waitForFrame((value) => value.includes("The body of the thought.")) + } finally { + app.renderer.destroy() + } +})