From 5352aec064c3d1274f9d046dee5e9b249f60bfcd Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 03:37:42 +0000 Subject: [PATCH] opentui(v5/item6): collapsible thinking traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning rendered as an always-expanded plain muted blob. Now it's a proper collapsible part (opencode ReasoningPart): auto-EXPANDED while the turn streams (watch it think), then collapses to a one-line `▶ Thought: ` when settled; click toggles. Title is the model's leading `**bold**` line (reasoningSummary). Body renders as DIM markdown in a left-`│`-border block (Markdown gained an optional `fg` so reasoning is muted vs the answer). +1 render test (80 pass). --- ui-tui-opentui-v2/src/test/render.test.tsx | 22 +++++++ ui-tui-opentui-v2/src/view/markdown.tsx | 7 +- ui-tui-opentui-v2/src/view/messageLine.tsx | 7 +- ui-tui-opentui-v2/src/view/reasoningPart.tsx | 69 ++++++++++++++++++++ 4 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 ui-tui-opentui-v2/src/view/reasoningPart.tsx diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index 5d7ad99a7e6..3560ecc0957 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -121,6 +121,28 @@ describe('App render (Phase 1, themed)', () => { expect(frame).toContain('(2 lines)') // output line count (collapsed) }) + test('a settled reasoning part collapses to a one-line "Thought: <title>" header (item 6)', async () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.apply({ type: 'message.start' }) + store.apply({ type: 'reasoning.delta', payload: { text: '**Weighing options**\n\nthe hidden body text here' } }) + store.apply({ type: 'message.delta', payload: { text: 'Answer.' } }) + store.apply({ type: 'message.complete' }) + + const frame = await captureFrame( + () => ( + <ThemeProvider theme={() => store.state.theme}> + <App store={store} /> + </ThemeProvider> + ), + { until: 'Thought', width: 72, height: 16 } + ) + + expect(frame).toContain('Thought') // settled → collapsed header label + expect(frame).toContain('Weighing options') // the **bold** title is surfaced + expect(frame).not.toContain('hidden body text') // collapsed → body not shown + }) + test('an approval prompt replaces the composer (blocked) and renders the options', async () => { const store = createSessionStore() store.apply({ type: 'gateway.ready' }) diff --git a/ui-tui-opentui-v2/src/view/markdown.tsx b/ui-tui-opentui-v2/src/view/markdown.tsx index f5829a7bb06..1e982ce3e1c 100644 --- a/ui-tui-opentui-v2/src/view/markdown.tsx +++ b/ui-tui-opentui-v2/src/view/markdown.tsx @@ -51,12 +51,13 @@ function syntaxStyleFor(theme: Theme): SyntaxStyle { return style } -export function Markdown(props: { text: string; streaming?: boolean }) { +export function Markdown(props: { text: string; streaming?: boolean; fg?: string }) { const theme = useTheme() // opencode's v2 text path (session-v2.tsx AssistantText): the markdown engine via // <code filetype="markdown" streaming>. `drawUnstyledText={false}` avoids the // raw→styled flash per delta (the streaming flicker); `streaming` re-tokenizes - // incrementally rather than reparsing the whole buffer each repaint. + // incrementally rather than reparsing the whole buffer each repaint. `fg` + // overrides the base text color (e.g. muted for reasoning bodies — item 6). return ( <code filetype="markdown" @@ -65,7 +66,7 @@ export function Markdown(props: { text: string; streaming?: boolean }) { streaming={props.streaming ?? false} drawUnstyledText={false} conceal - fg={theme().color.text} + fg={props.fg ?? theme().color.text} /> ) } diff --git a/ui-tui-opentui-v2/src/view/messageLine.tsx b/ui-tui-opentui-v2/src/view/messageLine.tsx index 620659dd651..8ceb46f355f 100644 --- a/ui-tui-opentui-v2/src/view/messageLine.tsx +++ b/ui-tui-opentui-v2/src/view/messageLine.tsx @@ -12,6 +12,7 @@ import { For, Match, Show, Switch } from 'solid-js' import type { Message } from '../logic/store.ts' import { Markdown } from './markdown.tsx' +import { ReasoningPart } from './reasoningPart.tsx' import { useTheme } from './theme.tsx' import { ToolPart } from './toolPart.tsx' @@ -59,11 +60,7 @@ export function MessageLine(props: { message: Message }) { <Switch> <Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match> <Match when={part.type === 'reasoning' && part}> - {r => ( - <text> - <span style={{ fg: theme().color.muted }}>{r().text}</span> - </text> - )} + {r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />} </Match> <Match when={part.type === 'text' && part}> {t => <Markdown text={t().text} streaming={m().streaming ?? false} />} diff --git a/ui-tui-opentui-v2/src/view/reasoningPart.tsx b/ui-tui-opentui-v2/src/view/reasoningPart.tsx new file mode 100644 index 00000000000..528b69521fb --- /dev/null +++ b/ui-tui-opentui-v2/src/view/reasoningPart.tsx @@ -0,0 +1,69 @@ +/** + * ReasoningPart — the model's thinking trace, collapsible (item 6; opencode's + * ReasoningPart/ReasoningHeader). Auto-EXPANDED while the turn streams (so you + * watch it think), then COLLAPSES to a one-line `▶ Thought: <title>` once the + * turn settles. Click the header to override either way. + * + * ▼ Thinking: <title> ← live (streaming), body shown + * ▶ Thought: <title> ← settled (collapsed), click to reopen + * │ <reasoning markdown> ← dim body in a left-bordered block + * + * Title is the model's leading `**bold**` line when present (opencode's + * reasoningSummary). Dim throughout — it's secondary to the answer. + */ +import { createMemo, createSignal, Show } from 'solid-js' + +import { Markdown } from './markdown.tsx' +import { useTheme } from './theme.tsx' + +const GUTTER = 2 + +/** Split a leading `**Title**\n\n body` into {title, body} (opencode reasoningSummary). */ +function reasoningSummary(text: string): { title?: string; body: string } { + const s = (text ?? '').replace('[REDACTED]', '').trim() + const m = s.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/) + const title = m?.[1]?.trim() + if (!title) return { body: s } + return { title, body: s.slice(m![0].length).trimStart() } +} + +export function ReasoningPart(props: { text: string; streaming?: boolean }) { + const theme = useTheme() + const [override, setOverride] = createSignal<boolean | undefined>(undefined) + // live → expanded so you see it think; settled → collapsed. Click overrides. + const expanded = () => override() ?? !!props.streaming + const summary = createMemo(() => reasoningSummary(props.text)) + const label = () => (props.streaming ? 'Thinking' : 'Thought') + + return ( + <Show when={summary().body || summary().title}> + <box style={{ flexDirection: 'column', flexShrink: 0 }}> + <box + style={{ flexDirection: 'row', flexShrink: 0 }} + onMouseDown={() => setOverride(e => !(e ?? !!props.streaming))} + > + <box style={{ flexShrink: 0, width: GUTTER }}> + <text selectable={false}> + <span style={{ fg: theme().color.muted }}>{expanded() ? '▼' : '▶'}</span> + </text> + </box> + <text> + <span style={{ fg: theme().color.warn }}>{label()}</span> + <Show when={summary().title}> + <span style={{ fg: theme().color.muted }}>{`: ${summary().title}`}</span> + </Show> + </text> + </box> + <Show when={expanded() && summary().body}> + <box + style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }} + border={['left']} + borderColor={theme().color.border} + > + <Markdown text={summary().body} streaming={props.streaming ?? false} fg={theme().color.muted} /> + </box> + </Show> + </box> + </Show> + ) +}