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:
alt-glitch 2026-06-08 16:54:07 +00:00
parent 808ef152e5
commit 26f6929eb4
4 changed files with 65 additions and 3 deletions

View file

@ -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(

View file

@ -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)', () => {

View file

@ -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

View 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>
)
}