fix(tui): stop thinking spinner leaking past turn end on empty deltas (#97)

* 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).

* chore: add changeset for thinking spinner fix

* refactor(tui): simplify flushThinkingToTranscript

---------

Co-authored-by: liruifengv <liruifeng1024@gmail.com>
This commit is contained in:
happy wang 2026-05-27 14:48:18 +08:00 committed by GitHub
parent d1c381f38a
commit 2e8c417818
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 66 additions and 4 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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;