From 0cc02ac67d465d1d4d7fe070422bab17053cdaa3 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 1 Jul 2026 14:39:35 +0800 Subject: [PATCH] fix(tui): keep waiting spinner during encrypted reasoning streams (#1256) Empty (encrypted/redacted) thinking deltas no longer switch out of waiting mode, which previously stopped the moon spinner while no thinking component was ever created, leaving a blank spinner-less gap until the first real text/tool token. --- .../keep-waiting-spinner-empty-reasoning.md | 5 ++ .../tui/controllers/session-event-handler.ts | 8 +++ .../test/tui/kimi-tui-message-flow.test.ts | 53 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .changeset/keep-waiting-spinner-empty-reasoning.md diff --git a/.changeset/keep-waiting-spinner-empty-reasoning.md b/.changeset/keep-waiting-spinner-empty-reasoning.md new file mode 100644 index 000000000..e415b48b8 --- /dev/null +++ b/.changeset/keep-waiting-spinner-empty-reasoning.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep the waiting spinner visible while encrypted reasoning streams, fixing a blank spinner-less gap before the first response text appears. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 33cec0ff2..6abcba76a 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -438,6 +438,14 @@ export class SessionEventHandler { private handleThinkingDelta(event: ThinkingDeltaEvent): void { const { state, streamingUI } = this.host; + // Encrypted / redacted reasoning (e.g. Kimi over the Anthropic-compatible + // protocol) streams thinking deltas whose visible text is empty — only an + // opaque signature rides along. Such deltas carry nothing to render, so + // switching into the `thinking` pane mode here would stop the "waiting" + // moon spinner while no ThinkingComponent is ever created (it needs visible + // text), leaving a blank, spinner-less gap until the first real text/tool + // token arrives. Keep the moon up until actual thinking text shows up. + if (event.delta.length === 0 && !streamingUI.hasThinkingDraft()) return; streamingUI.appendThinkingDelta(event.delta); this.host.patchLivePane({ mode: 'idle' }); if (state.appState.streamingPhase !== 'thinking') { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 78b2c036d..06f3843a0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; import { AgentSwarmProgressComponent, agentSwarmGridHeightForTerminalRows, @@ -4528,6 +4529,58 @@ command = "vim" expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); }); + it('keeps the waiting moon spinner while reasoning streams only empty (encrypted) thinking deltas', async () => { + const { driver } = await makeDriver(); + + // Turn begins -> waiting mode shows the moon spinner. + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + } as Event, + vi.fn(), + ); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + + // Encrypted reasoning: thinking.delta events whose visible text is empty. + for (let i = 0; i < 3; i++) { + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: '', + } as Event, + vi.fn(), + ); + } + + // The moon must stay up: still waiting, no orphan thinking component, and + // the activity pane still renders a moon frame (no blank, spinner-less gap). + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); + const activity = stripSgr(renderActivity(driver)); + expect(MOON_SPINNER_FRAMES.some((frame) => activity.includes(frame))).toBe(true); + + // Real thinking text finally arrives -> transition into thinking mode. + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: 'actual reasoning', + } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + expect(driver.state.appState.streamingPhase).toBe('thinking'); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true); + }); + it('finalizes an orphaned thinking component on turn end', async () => { const { driver } = await makeDriver(); driver.state.appState.streamingPhase = 'thinking';