opentui(v5/item6): collapsible thinking traces

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: <title>` 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).
This commit is contained in:
alt-glitch 2026-06-09 03:37:42 +00:00
parent 7b14c51e7b
commit 5352aec064
4 changed files with 97 additions and 8 deletions

View file

@ -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' })

View file

@ -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}
/>
)
}

View file

@ -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} />}

View file

@ -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>
)
}