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
@ -123,6 +123,9 @@ export interface StoreState {
|
|||||||
subagents: SubagentInfo[]
|
subagents: SubagentInfo[]
|
||||||
/** Whether the agents dashboard overlay is open (/agents). */
|
/** Whether the agents dashboard overlay is open (/agents). */
|
||||||
dashboard: boolean
|
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
|
const LRU_LIMIT = 1000
|
||||||
@ -159,7 +162,8 @@ export function createSessionStore() {
|
|||||||
picker: undefined,
|
picker: undefined,
|
||||||
completions: undefined,
|
completions: undefined,
|
||||||
subagents: [],
|
subagents: [],
|
||||||
dashboard: false
|
dashboard: false,
|
||||||
|
status: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
// 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)
|
setSkin(event.payload)
|
||||||
break
|
break
|
||||||
case 'message.start':
|
case 'message.start':
|
||||||
|
setState('status', undefined)
|
||||||
setState(
|
setState(
|
||||||
produce(draft => {
|
produce(draft => {
|
||||||
draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true })
|
draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true })
|
||||||
@ -349,9 +354,19 @@ export function createSessionStore() {
|
|||||||
live.streaming = false
|
live.streaming = false
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
setState('status', undefined)
|
||||||
break
|
break
|
||||||
case 'reasoning.delta':
|
// thinking.delta / status.update are the TRANSIENT busy indicator (kaomoji
|
||||||
case 'thinking.delta': {
|
// 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 ?? ''
|
const text = event.payload?.text ?? ''
|
||||||
if (!text) break
|
if (!text) break
|
||||||
setState(
|
setState(
|
||||||
|
|||||||
@ -118,6 +118,25 @@ describe('session store — ordered parts (Phase 2b)', () => {
|
|||||||
const parts = store.state.messages.at(-1)!.parts ?? []
|
const parts = store.state.messages.at(-1)!.parts ?? []
|
||||||
expect(parts[0]).toMatchObject({ type: 'reasoning', text: 'thinking hard' })
|
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)', () => {
|
describe('session store — blocking prompts (Phase 3)', () => {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { Pager } from './overlays/pager.tsx'
|
|||||||
import { Picker } from './overlays/picker.tsx'
|
import { Picker } from './overlays/picker.tsx'
|
||||||
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
||||||
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
||||||
|
import { StatusLine } from './statusLine.tsx'
|
||||||
import { Transcript } from './transcript.tsx'
|
import { Transcript } from './transcript.tsx'
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
@ -64,6 +65,7 @@ export function App(props: AppProps) {
|
|||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Transcript store={props.store} />
|
<Transcript store={props.store} />
|
||||||
|
<StatusLine store={props.store} />
|
||||||
<Switch
|
<Switch
|
||||||
fallback={
|
fallback={
|
||||||
<Composer
|
<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…
x
Reference in New Issue
Block a user