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:
parent
ee211d087b
commit
6a24249e7c
@ -121,6 +121,28 @@ describe('App render (Phase 1, themed)', () => {
|
|||||||
expect(frame).toContain('(2 lines)') // output line count (collapsed)
|
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 () => {
|
test('an approval prompt replaces the composer (blocked) and renders the options', async () => {
|
||||||
const store = createSessionStore()
|
const store = createSessionStore()
|
||||||
store.apply({ type: 'gateway.ready' })
|
store.apply({ type: 'gateway.ready' })
|
||||||
|
|||||||
@ -51,12 +51,13 @@ function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
|||||||
return style
|
return style
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown(props: { text: string; streaming?: boolean }) {
|
export function Markdown(props: { text: string; streaming?: boolean; fg?: string }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
// opencode's v2 text path (session-v2.tsx AssistantText): the markdown engine via
|
// opencode's v2 text path (session-v2.tsx AssistantText): the markdown engine via
|
||||||
// <code filetype="markdown" streaming>. `drawUnstyledText={false}` avoids the
|
// <code filetype="markdown" streaming>. `drawUnstyledText={false}` avoids the
|
||||||
// raw→styled flash per delta (the streaming flicker); `streaming` re-tokenizes
|
// 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 (
|
return (
|
||||||
<code
|
<code
|
||||||
filetype="markdown"
|
filetype="markdown"
|
||||||
@ -65,7 +66,7 @@ export function Markdown(props: { text: string; streaming?: boolean }) {
|
|||||||
streaming={props.streaming ?? false}
|
streaming={props.streaming ?? false}
|
||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
conceal
|
conceal
|
||||||
fg={theme().color.text}
|
fg={props.fg ?? theme().color.text}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { For, Match, Show, Switch } from 'solid-js'
|
|||||||
|
|
||||||
import type { Message } from '../logic/store.ts'
|
import type { Message } from '../logic/store.ts'
|
||||||
import { Markdown } from './markdown.tsx'
|
import { Markdown } from './markdown.tsx'
|
||||||
|
import { ReasoningPart } from './reasoningPart.tsx'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
import { ToolPart } from './toolPart.tsx'
|
import { ToolPart } from './toolPart.tsx'
|
||||||
|
|
||||||
@ -59,11 +60,7 @@ export function MessageLine(props: { message: Message }) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
|
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
|
||||||
<Match when={part.type === 'reasoning' && part}>
|
<Match when={part.type === 'reasoning' && part}>
|
||||||
{r => (
|
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
|
||||||
<text>
|
|
||||||
<span style={{ fg: theme().color.muted }}>{r().text}</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={part.type === 'text' && part}>
|
<Match when={part.type === 'text' && part}>
|
||||||
{t => <Markdown text={t().text} streaming={m().streaming ?? false} />}
|
{t => <Markdown text={t().text} streaming={m().streaming ?? false} />}
|
||||||
|
|||||||
69
ui-tui-opentui-v2/src/view/reasoningPart.tsx
Normal file
69
ui-tui-opentui-v2/src/view/reasoningPart.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user