Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<body>". Treat that first block,
Expand All @@ -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<ThinkingMode> }>) {
return <Show when={isThinkingVisible(props.mode())}>{props.children}</Show>
}

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
Expand Down Expand Up @@ -56,8 +74,7 @@ export function useThinkingMode() {
if ((stored() as string) === "minimal") set("hide")

const mode = createMemo<ThinkingMode>(() => {
const value = stored()
return isThinkingMode(value) ? value : "hide"
return normalizeThinkingMode(stored())
})

return {
Expand Down
79 changes: 42 additions & 37 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -1613,39 +1616,41 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
}

return (
<Show when={content() || opaque()}>
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
paddingLeft={3}
marginTop={1}
flexDirection="column"
flexShrink={0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal() && !opaque()}
open={!inMinimal() || expanded()}
done={isDone()}
title={summary().title}
duration={isDone() ? Locale.duration(duration()) : undefined}
encrypted={opaque()}
/>
</box>
<Show when={!opaque() && (!inMinimal() || expanded()) && summary().body}>
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={summary().body}
conceal={ctx.conceal()}
fg={theme.textMuted}
<ThinkingVisibility mode={ctx.thinkingMode}>
<Show when={content() || opaque()}>
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
paddingLeft={3}
marginTop={1}
flexDirection="column"
flexShrink={0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal() && !opaque()}
open={!inMinimal() || expanded()}
done={isDone()}
title={summary().title}
duration={isDone() ? Locale.duration(duration()) : undefined}
encrypted={opaque()}
/>
</box>
</Show>
</box>
</Show>
<Show when={!opaque() && (!inMinimal() || expanded()) && summary().body}>
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={summary().body}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
</Show>
</box>
</Show>
</ThinkingVisibility>
)
}

Expand Down
36 changes: 0 additions & 36 deletions packages/tui/test/cli/tui/thinking.test.ts

This file was deleted.

129 changes: 129 additions & 0 deletions packages/tui/test/cli/tui/thinking.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ThinkingMode>("show")
const app = await testRender(
() => (
<box flexDirection="column">
<text>before</text>
<ThinkingVisibility mode={mode}>
<box marginTop={1} flexDirection="column">
<text>Thinking: live</text>
<text>Thought: complete</text>
<text>Thought: opaque</text>
</box>
</ThinkingVisibility>
<text>after</text>
</box>
),
{ 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." })
})
})
Loading