mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
fix(opentui-v2): route thinking-faces to a transient status line (not the transcript)
Live-usage issue 3/5: the kaomoji faces ("(¬_¬) processing…") lingered in the
transcript. Traced (instrumented capture): they arrive via `thinking.delta` —
Hermes's transient kaomoji busy *indicator* (_INDICATOR_DEFAULT=kaomoji), which I
was rendering as a persistent reasoning part.
- store: new transient `status` field. thinking.delta / status.update → `status`
(not a part); message.start + message.complete clear it. Only the real
`reasoning.delta` still becomes a (dim) transcript part.
- view/statusLine.tsx: a dim busy line above the composer shown while `status` is
set (Ink's FaceTicker analog), rendering nothing when idle; wired into App
between the transcript and the input zone.
Verified: bun run check green (55 tests / 7 files) — store tests assert
thinking.delta → status (no transcript part) + cleared on complete; status.update
→ status. Live tmux: a turn showed "٩(๑❛ᴗ❛๑)۶ cogitating…" on the transient status
line (cleared on completion) with NO face left in the transcript.
This commit is contained in:
parent
808ef152e5
commit
26f6929eb4
4 changed files with 65 additions and 3 deletions
|
|
@ -123,6 +123,9 @@ export interface StoreState {
|
|||
subagents: SubagentInfo[]
|
||||
/** Whether the agents dashboard overlay is open (/agents). */
|
||||
dashboard: boolean
|
||||
/** Transient busy indicator (the kaomoji face/verb from `thinking.delta`/`status.update`);
|
||||
* shown above the composer WHILE a turn runs, cleared on `message.complete`. NOT transcript. */
|
||||
status: string | undefined
|
||||
}
|
||||
|
||||
const LRU_LIMIT = 1000
|
||||
|
|
@ -159,7 +162,8 @@ export function createSessionStore() {
|
|||
picker: undefined,
|
||||
completions: undefined,
|
||||
subagents: [],
|
||||
dashboard: false
|
||||
dashboard: false,
|
||||
status: undefined
|
||||
})
|
||||
|
||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
||||
|
|
@ -319,6 +323,7 @@ export function createSessionStore() {
|
|||
setSkin(event.payload)
|
||||
break
|
||||
case 'message.start':
|
||||
setState('status', undefined)
|
||||
setState(
|
||||
produce(draft => {
|
||||
draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true })
|
||||
|
|
@ -349,9 +354,19 @@ export function createSessionStore() {
|
|||
live.streaming = false
|
||||
})
|
||||
)
|
||||
setState('status', undefined)
|
||||
break
|
||||
case 'reasoning.delta':
|
||||
case 'thinking.delta': {
|
||||
// thinking.delta / status.update are the TRANSIENT busy indicator (kaomoji
|
||||
// face/verb) — route them to the status line, NOT the transcript (gotcha: Ink
|
||||
// shows these as a FaceTicker, not message content).
|
||||
case 'thinking.delta':
|
||||
case 'status.update': {
|
||||
const text = event.payload?.text ?? ''
|
||||
if (text) setState('status', text)
|
||||
break
|
||||
}
|
||||
// reasoning.delta is the model's actual reasoning — a (dim) transcript part.
|
||||
case 'reasoning.delta': {
|
||||
const text = event.payload?.text ?? ''
|
||||
if (!text) break
|
||||
setState(
|
||||
|
|
|
|||
|
|
@ -118,6 +118,25 @@ describe('session store — ordered parts (Phase 2b)', () => {
|
|||
const parts = store.state.messages.at(-1)!.parts ?? []
|
||||
expect(parts[0]).toMatchObject({ type: 'reasoning', text: 'thinking hard' })
|
||||
})
|
||||
|
||||
test('thinking.delta (kaomoji face) → transient status, NOT a transcript part; complete clears it', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'thinking.delta', payload: { text: '(´・_・`) formulating...' } })
|
||||
expect(store.state.status).toBe('(´・_・`) formulating...')
|
||||
expect(store.state.messages.at(-1)!.parts ?? []).toHaveLength(0) // no reasoning row from the face
|
||||
store.apply({ type: 'message.delta', payload: { text: 'Hi!' } })
|
||||
store.apply({ type: 'message.complete' })
|
||||
expect(store.state.status).toBeUndefined() // cleared when the turn ends
|
||||
// only the real reply text part remains — the face never entered the transcript
|
||||
expect((store.state.messages.at(-1)!.parts ?? []).map(p => p.type)).toEqual(['text'])
|
||||
})
|
||||
|
||||
test('status.update also drives the transient status line', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'status.update', payload: { kind: 'tool', text: 'running terminal…' } })
|
||||
expect(store.state.status).toBe('running terminal…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — blocking prompts (Phase 3)', () => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { Pager } from './overlays/pager.tsx'
|
|||
import { Picker } from './overlays/picker.tsx'
|
||||
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
||||
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
||||
import { StatusLine } from './statusLine.tsx'
|
||||
import { Transcript } from './transcript.tsx'
|
||||
|
||||
export interface AppProps {
|
||||
|
|
@ -64,6 +65,7 @@ export function App(props: AppProps) {
|
|||
fallback={
|
||||
<>
|
||||
<Transcript store={props.store} />
|
||||
<StatusLine store={props.store} />
|
||||
<Switch
|
||||
fallback={
|
||||
<Composer
|
||||
|
|
|
|||
26
ui-tui-opentui-v2/src/view/statusLine.tsx
Normal file
26
ui-tui-opentui-v2/src/view/statusLine.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* StatusLine — the transient busy indicator (spec §3 chrome; Ink's FaceTicker).
|
||||
* Shows the kaomoji face/verb from `thinking.delta`/`status.update` WHILE a turn
|
||||
* runs, above the composer; cleared on `message.complete`. This keeps those
|
||||
* transient indicators OUT of the transcript (they used to render as reasoning
|
||||
* rows and linger). Themed, dim. Renders nothing when idle.
|
||||
*/
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
export function StatusLine(props: { store: SessionStore }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Show when={props.store.state.status}>
|
||||
{status => (
|
||||
<box style={{ flexShrink: 0 }}>
|
||||
<text>
|
||||
<span style={{ fg: theme().color.muted }}>{status()}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue