mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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
4 changed files with 97 additions and 8 deletions
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
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…
Add table
Add a link
Reference in a new issue