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-whitespace-thinking-blank-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix whitespace-only thinking content rendering as a blank bullet line in the transcript, both while streaming and when replaying session history.
7 changes: 4 additions & 3 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,12 +447,13 @@ export class SessionEventHandler {
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"
// opaque signature rides along. Models also occasionally stream whitespace-
// only thinking (e.g. a single space). 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;
if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) return;
streamingUI.appendThinkingDelta(event.delta);
this.host.patchLivePane({ mode: 'idle' });
if (state.appState.streamingPhase !== 'thinking') {
Expand Down
6 changes: 5 additions & 1 deletion apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,11 @@ export class StreamingUIController {
}

onThinkingUpdate(fullText: string): void {
if (fullText.length === 0 && this._activeThinkingComponent === undefined) return;
// Skip thinking that carries nothing visible — empty (e.g. encrypted
// reasoning) or whitespace-only (a model occasionally streams a single
// space as thinking). Session replay funnels through here as well, so a
// stored whitespace-only think part never becomes a bare bullet line.
if (fullText.trim().length === 0 && this._activeThinkingComponent === undefined) return;
const { state } = this.host;
if (this._activeThinkingComponent === undefined) {
this._pendingAgentGroup = null;
Expand Down
60 changes: 60 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 @@ -20,6 +20,7 @@ import {
agentSwarmGridHeightForTerminalRows,
} from '#/tui/components/messages/agent-swarm-progress';
import { BtwPanelComponent } from '#/tui/components/panes/btw-panel';
import { ThinkingComponent } from '#/tui/components/messages/thinking';
import { WelcomeComponent } from '#/tui/components/chrome/welcome';
import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector';
import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector';
Expand Down Expand Up @@ -4957,6 +4958,65 @@ command = "vim"
expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false);
});

it('does not create a thinking component for whitespace-only thinking deltas', async () => {
const { driver } = await makeDriver();
driver.state.appState.streamingPhase = 'waiting';

driver.sessionEventHandler.handleEvent(
{
type: 'thinking.delta',
agentId: 'main',
sessionId: 'ses-1',
delta: ' ',
} as Event,
vi.fn(),
);
driver.streamingUI.flushNow();

// Nothing to render: no component, and the phase is not hijacked into thinking.
expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false);
expect(driver.state.appState.streamingPhase).toBe('waiting');

// Real thinking text after the whitespace still starts thinking normally.
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);
expect(stripSgr(renderTranscript(driver))).toContain('actual reasoning');
});

it('does not create a thinking component for whitespace-only thinking on session replay', async () => {
const { driver } = await makeDriver();

// Session replay flushes stored thinking verbatim through onThinkingUpdate
// (see SessionReplayRenderer.flushAssistant), so a persisted whitespace-only
// think part must not become a bare bullet line.
driver.streamingUI.onThinkingUpdate(' ');
driver.streamingUI.onThinkingEnd();

expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false);
expect(
driver.state.transcriptContainer.children.filter(
(child) => child instanceof ThinkingComponent,
),
).toHaveLength(0);

// Real stored thinking still replays normally.
driver.streamingUI.onThinkingUpdate('visible reasoning');
driver.streamingUI.onThinkingEnd();

expect(stripSgr(renderTranscript(driver))).toContain('visible reasoning');
});

it('keeps the waiting moon spinner while reasoning streams only empty (encrypted) thinking deltas', async () => {
const { driver } = await makeDriver();

Expand Down
Loading