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
41 changes: 19 additions & 22 deletions packages/tui/src/routes/session/message-parts.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -52,46 +52,51 @@ 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<boolean>()

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
return end === undefined ? 0 : Math.max(0, end - start)
})
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 (
<Show when={content()}>
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
border={open() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.decrease(theme.background.base)}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
paddingLeft={open() ? 1 : 0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
open={open()}
done={isDone()}
title={inMinimal() && !expanded() ? summary().title : null}
title={open() ? null : summary().title}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
</box>
<Show when={!inMinimal() || expanded()}>
<Show when={open()}>
<box marginTop={1}>
<box
border={["left"]}
Expand Down Expand Up @@ -121,13 +126,7 @@ export function reasoningContent(part: SessionMessageAssistantReasoning) {
return part.text.replace("[REDACTED]", "").trim()
}

function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
function ReasoningHeader(props: { open: boolean; done: boolean; title: string | null; duration?: string }) {
const theme = useTheme()
const fg = () =>
props.open
Expand All @@ -148,9 +147,7 @@ function ReasoningHeader(props: {
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.open ? "- " : "+ "}</span>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
Expand Down
109 changes: 109 additions & 0 deletions packages/tui/test/cli/tui/reasoning-disclosure.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
() => (
<TestTuiContexts>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<context.Provider
value={{
width: 60,
terminal: { width: 60, height: 20 },
sessionID: "fixture",
anchors: createTimelineAnchors(),
groupExpanded: () => false,
setGroupExpanded: () => {},
thinkingMode: mode,
markdownMode: () => "rendered",
groupExploration: () => false,
legacyTurns: () => false,
diffWrapMode: () => "word",
models: () => [],
messageIndex: () => undefined,
config,
mutatePending: async () => true,
pendingDelivery: () => undefined,
}}
>
<box paddingTop={2}>
<ReasoningPart last part={part} message={message} />
</box>
</context.Provider>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ 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()
}
})
Loading