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." })
+ })
+})