/** * 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: ` 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 type { ThemeColors } from '../logic/theme.ts' import { Markdown } from './markdown.tsx' import { useScrollAnchor } from './scrollAnchor.tsx' import { useTheme } from './theme.tsx' const GUTTER = 2 /** * Header label style — reasoning is the MOST secondary tier, so the word * Thinking/Thought + its title preview stay muted but render ITALIC: a * `▶ Thought` row reads as a different KIND of row than a tool at a glance * (tools carry a bold text-colored name; reasoning stays quiet, just * recognizably distinct). Exported so tests can pin the selection logic * (char frames carry no color/attribute info). */ export function reasoningLabelStyle(color: ThemeColors): { fg: string; italic: boolean } { return { fg: color.muted, italic: true } } /** 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 (!m || !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 anchor = useScrollAnchor() const [override, setOverride] = createSignal<boolean | undefined>(undefined) // live → expanded so you see it think; settled → collapsed. Click overrides. const expanded = () => override() ?? !!props.streaming const toggle = () => anchor(() => setOverride(e => !(e ?? !!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={toggle}> <box style={{ flexShrink: 0, width: GUTTER }}> <text selectable={false}> <span style={{ fg: theme().color.accent }}>{expanded() ? '▼' : '▶'}</span> </text> </box> {/* the header is a collapsible-section LABEL (Thinking/Thought + title) — chrome, not the reasoning body — so a free-form drag yields only the markdown body below, not the section label (item 4). */} <text selectable={false}> {/* accent chevron marks it; muted ITALIC label keeps reasoning the most secondary tier AND visibly a different kind of row than a tool (bold name) — see reasoningLabelStyle. */} <span style={reasoningLabelStyle(theme().color)}>{label()}</span> <Show when={summary().title}> <span style={reasoningLabelStyle(theme().color)}>{`: ${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> ) }