diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 1ce8a7ae25d..ade5cd387f2 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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( diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 3249dd2fb6f..89f16375d5a 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -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)', () => { diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 43829510465..4dd4468b97d 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -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={ <> + + {status => ( + + + {status()} + + + )} + + ) +}