From 833939e968aa98f3f97292003ca980b60c358d45 Mon Sep 17 00:00:00 2001 From: happy wang Date: Mon, 25 May 2026 19:02:27 +0800 Subject: [PATCH 1/3] fix: stop thinking spinner leaking past turn end on empty deltas When a provider emits an empty thinking delta (e.g. Anthropic signature_delta -> think: ""), a ThinkingComponent was created with a running spinner but thinkingDraft stayed empty. Subsequent calls to flushThinkingToTranscript guarded on thinkingDraft.length and returned early without calling onThinkingEnd(), leaking the spinner past the turn end. Two fixes: - onThinkingUpdate: skip component creation for empty text when no existing component needs updating (source prevention). - flushThinkingToTranscript: finalize any orphaned component even when thinkingDraft is empty (defensive cleanup). --- apps/kimi-code/src/tui/kimi-tui.ts | 9 +++ .../test/tui/kimi-tui-message-flow.test.ts | 55 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a185e38658..d9a2f8af29 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1907,6 +1907,12 @@ export class KimiTUI { private flushThinkingToTranscript(nextMode: LivePaneState['mode'] = 'idle'): void { this.flushStreamingUiUpdatesNow(); if (this.state.thinkingDraft.length === 0) { + // A live ThinkingComponent may still exist with a running spinner + // (e.g. created by an empty thinking delta). Finalize it here so + // the spinner does not leak past the thinking phase. + if (this.state.activeThinkingComponent !== undefined) { + this.onThinkingEnd(); + } this.patchLivePane({ mode: nextMode }); return; } @@ -3273,6 +3279,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 8ca3132be4..ef1c8fbada 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 @@ -1421,6 +1421,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; From 755ae9bf6f1e952fbc4539a31bd907f15117221c Mon Sep 17 00:00:00 2001 From: happy wang Date: Wed, 27 May 2026 11:55:26 +0800 Subject: [PATCH 2/3] chore: add changeset for thinking spinner fix --- .changeset/fix-thinking-spinner-leak.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-thinking-spinner-leak.md diff --git a/.changeset/fix-thinking-spinner-leak.md b/.changeset/fix-thinking-spinner-leak.md new file mode 100644 index 0000000000..98841a7eca --- /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. From bf70c392e8aa56112f0972b9d65cf072c777ae74 Mon Sep 17 00:00:00 2001 From: happy wang Date: Wed, 27 May 2026 12:03:21 +0800 Subject: [PATCH 3/3] refactor(tui): simplify flushThinkingToTranscript --- apps/kimi-code/src/tui/kimi-tui.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index d9a2f8af29..f66fd0f617 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1904,18 +1904,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) { - // A live ThinkingComponent may still exist with a running spinner - // (e.g. created by an empty thinking delta). Finalize it here so - // the spinner does not leak past the thinking phase. - if (this.state.activeThinkingComponent !== undefined) { - this.onThinkingEnd(); - } - this.patchLivePane({ mode: nextMode }); - return; - } this.state.thinkingDraft = ''; this.onThinkingEnd(); this.patchLivePane({ mode: nextMode });