Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-thinking-spinner-leak.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading