diff --git a/packages/tui/src/context/thinking.ts b/packages/tui/src/context/thinking.tsx similarity index 73% rename from packages/tui/src/context/thinking.ts rename to packages/tui/src/context/thinking.tsx index bb1c2a6929f9..59a85a1cc660 100644 --- a/packages/tui/src/context/thinking.ts +++ b/packages/tui/src/context/thinking.tsx @@ -1,9 +1,9 @@ -import { createMemo, type Setter } from "solid-js" +import { createMemo, Show, type Accessor, type ParentProps, type Setter } from "solid-js" import { useKV } from "./kv" -export type ThinkingMode = "show" | "hide" +export type ThinkingMode = "show" | "hide" | "off" -const MODES: readonly ThinkingMode[] = ["show", "hide"] as const +const MODES: readonly ThinkingMode[] = ["show", "hide", "off"] as const // OpenAI's Responses API surfaces reasoning summaries that start with a bolded // title block: "**Inspecting PR workflow**\n\n". Treat that first block, @@ -20,12 +20,30 @@ export function isThinkingMode(value: unknown): value is ThinkingMode { return typeof value === "string" && (MODES as readonly string[]).includes(value) } -// Cycle order matches the slash command: show → hide → show. +export function isThinkingVisible(mode: ThinkingMode) { + return mode !== "off" +} + +export function ThinkingVisibility(props: ParentProps<{ mode: Accessor }>) { + return {props.children} +} + +export function thinkingModeActionTitle(mode: ThinkingMode) { + if (mode === "show") return "Collapse thinking" + if (mode === "hide") return "Hide thinking" + return "Show thinking" +} + +// Cycle order matches the slash command: show → hide → off → show. export function nextThinkingMode(current: ThinkingMode): ThinkingMode { const idx = MODES.indexOf(current) return MODES[(idx + 1) % MODES.length] ?? "show" } +export function normalizeThinkingMode(value: unknown): ThinkingMode { + return isThinkingMode(value) ? value : "hide" +} + export function useThinkingMode() { const kv = useKV() // Capture pre-state before `kv.signal` seeds a default, so we can detect @@ -56,8 +74,7 @@ export function useThinkingMode() { if ((stored() as string) === "minimal") set("hide") const mode = createMemo(() => { - const value = stored() - return isThinkingMode(value) ? value : "hide" + return normalizeThinkingMode(stored()) }) return { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index cbdaf0cfa0c7..7eeffcd0e8cf 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -73,7 +73,14 @@ import { sessionEpilogue } from "../../util/presentation" import { setPreLayoutSiblingMargin } from "../../util/layout" import { useTuiConfig } from "../../config" import { useClipboard } from "../../context/clipboard" -import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking" +import { + nextThinkingMode, + reasoningSummary, + thinkingModeActionTitle, + ThinkingVisibility, + useThinkingMode, + type ThinkingMode, +} from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" import { collapseToolOutput } from "../../util/collapse-tool-output" import { usePluginRuntime } from "../../plugin/runtime" @@ -706,11 +713,7 @@ export function Session() { }, }, { - title: (() => { - const next = nextThinkingMode(thinkingMode()) - if (next === "hide") return "Collapse thinking" - return "Expand thinking" - })(), + title: thinkingModeActionTitle(thinkingMode()), value: "session.toggle.thinking", category: "Session", slash: { @@ -1613,39 +1616,41 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass } return ( - - alwaysSeparate.add(el)} - paddingLeft={3} - marginTop={1} - flexDirection="column" - flexShrink={0} - > - - - - - - + + alwaysSeparate.add(el)} + paddingLeft={3} + marginTop={1} + flexDirection="column" + flexShrink={0} + > + + - - - + + + + + + + + ) } diff --git a/packages/tui/test/cli/tui/thinking.test.ts b/packages/tui/test/cli/tui/thinking.test.ts deleted file mode 100644 index ab1fe5e1724d..000000000000 --- a/packages/tui/test/cli/tui/thinking.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { reasoningSummary } from "../../../src/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." }) - }) -}) diff --git a/packages/tui/test/cli/tui/thinking.test.tsx b/packages/tui/test/cli/tui/thinking.test.tsx new file mode 100644 index 000000000000..848312f30d03 --- /dev/null +++ b/packages/tui/test/cli/tui/thinking.test.tsx @@ -0,0 +1,129 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" +import { + isThinkingMode, + isThinkingVisible, + nextThinkingMode, + normalizeThinkingMode, + reasoningSummary, + thinkingModeActionTitle, + ThinkingVisibility, + type ThinkingMode, +} from "../../../src/context/thinking" + +describe("ThinkingMode", () => { + test("validates persisted thinking modes", () => { + expect(isThinkingMode("show")).toBe(true) + expect(isThinkingMode("hide")).toBe(true) + expect(isThinkingMode("off")).toBe(true) + expect(isThinkingMode("minimal")).toBe(false) + expect(isThinkingMode(undefined)).toBe(false) + }) + + test("cycles through show, hide, and off", () => { + const modes: ThinkingMode[] = ["show", "hide", "off"] + expect(modes.map(nextThinkingMode)).toEqual(["hide", "off", "show"]) + }) + + test("only shows reasoning outside off mode", () => { + expect(isThinkingVisible("show")).toBe(true) + expect(isThinkingVisible("hide")).toBe(true) + expect(isThinkingVisible("off")).toBe(false) + }) + + test("normalizes persisted modes without changing existing values", () => { + expect(normalizeThinkingMode("show")).toBe("show") + expect(normalizeThinkingMode("hide")).toBe("hide") + expect(normalizeThinkingMode("off")).toBe("off") + expect(normalizeThinkingMode("minimal")).toBe("hide") + expect(normalizeThinkingMode("invalid")).toBe("hide") + }) + + test("describes the next toggle action", () => { + expect(thinkingModeActionTitle("show")).toBe("Collapse thinking") + expect(thinkingModeActionTitle("hide")).toBe("Hide thinking") + expect(thinkingModeActionTitle("off")).toBe("Show thinking") + }) + + test("removes all reasoning renderables and spacing in off mode", async () => { + const [mode, setMode] = createSignal("show") + const app = await testRender( + () => ( + + before + + + Thinking: live + Thought: complete + Thought: opaque + + + after + + ), + { width: 30, height: 6 }, + ) + + const lines = () => + app + .captureCharFrame() + .trimEnd() + .split("\n") + .map((line) => line.trimEnd()) + + try { + await app.renderOnce() + expect(lines()).toContain("Thought: complete") + + setMode("hide") + await app.renderOnce() + expect(lines()).toContain("Thought: complete") + + setMode("off") + await app.renderOnce() + expect(lines()).toEqual(["before", "after"]) + + setMode("show") + await app.renderOnce() + expect(lines()).toContain("Thought: complete") + } finally { + app.renderer.destroy() + } + }) +}) + +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." }) + }) +})