diff --git a/.changeset/fix-thinking-spinner-leak.md b/.changeset/fix-thinking-spinner-leak.md new file mode 100644 index 000000000..98841a7ec --- /dev/null +++ b/.changeset/fix-thinking-spinner-leak.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix thinking spinner leaking past turn end when an empty thinking delta creates an orphaned thinking component. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index d1a3f5d3e..0e71ef4ee 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1925,12 +1925,11 @@ export class KimiTUI { } // Finalizes live thinking output and moves the live pane to the next mode. + // onThinkingEnd() is safe to call even when no component exists (it no-ops + // when activeThinkingComponent is undefined), and clearing an already-empty + // thinkingDraft is harmless, so we can unconditionally clean up. private flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { this.flushStreamingUiUpdatesNow(); - if (this.state.thinkingDraft.length === 0) { - this.patchLivePane({ mode: nextMode }); - return; - } this.state.thinkingDraft = ''; this.onThinkingEnd(); this.patchLivePane({ mode: nextMode }); @@ -3717,6 +3716,9 @@ export class KimiTUI { // Creates or updates the live thinking transcript component. private onThinkingUpdate(fullText: string): void { + // Avoid creating a component whose spinner will never stop when the text + // is empty and there is no existing component to update. + if (fullText.length === 0 && this.state.activeThinkingComponent === undefined) return; if (this.state.activeThinkingComponent === undefined) { this.state.pendingAgentGroup = null; this.state.pendingReadGroup = null; 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 59f6369f2..db34d3459 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 @@ -1503,6 +1503,61 @@ describe('KimiTUI message flow', () => { }); }); + it('does not create a thinking component for empty thinking deltas', async () => { + const { driver } = await makeDriver(); + driver.state.appState.isStreaming = true; + driver.state.appState.streamingStartTime = 1; + + // An empty thinking delta — as emitted by providers like Anthropic + // (signature_delta → think: '') — must not create a ThinkingComponent + // whose spinner would leak past turn end. + driver.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: '', + } as Event, + vi.fn(), + ); + + expect(driver.state.activeThinkingComponent).toBeUndefined(); + }); + + it('finalizes an orphaned thinking component on turn end', async () => { + const { driver } = await makeDriver(); + driver.state.appState.isStreaming = true; + driver.state.appState.streamingStartTime = 1; + const sendQueued = vi.fn(); + + // Simulate a ThinkingComponent that leaked into state without + // corresponding thinkingDraft content (the exact scenario that + // could happen without the empty-delta guard above). + const { ThinkingComponent } = await import('../../src/tui/components/messages/thinking'); + driver.state.activeThinkingComponent = new ThinkingComponent( + '', + driver.state.theme.colors, + true, + 'live', + driver.state.ui, + ); + + driver.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + sendQueued, + ); + + // flushThinkingToTranscript must finalize the component even when + // thinkingDraft is empty, so the spinner does not outlive the turn. + expect(driver.state.activeThinkingComponent).toBeUndefined(); + }); + it('renders newly streamed thinking expanded when ctrl+o toggle was already active', async () => { const { driver } = await makeDriver(); driver.state.toolOutputExpanded = true;