hermes-agent/ui-opentui/src/view/reasoningPart.tsx

89 lines
4.0 KiB
TypeScript

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