From ab1357c87ab6e791dfa241ee559e9756f8a554d2 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 17 Jul 2026 16:52:03 +0800 Subject: [PATCH] fix(tui): hide whitespace-only thinking from the transcript Models occasionally stream whitespace-only thinking (e.g. a single space). It starts a thinking draft that renders as a bare bullet line, both while streaming and when replaying session history. Skip whitespace-only thinking deltas before they create a draft, and skip whitespace-only think text at the component funnel so stored whitespace think parts never render on replay. Stored thinking is still replayed verbatim to the model. --- .../fix-whitespace-thinking-blank-line.md | 5 ++ .../tui/controllers/session-event-handler.ts | 7 ++- .../src/tui/controllers/streaming-ui.ts | 6 +- .../test/tui/kimi-tui-message-flow.test.ts | 60 +++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-whitespace-thinking-blank-line.md diff --git a/.changeset/fix-whitespace-thinking-blank-line.md b/.changeset/fix-whitespace-thinking-blank-line.md new file mode 100644 index 0000000000..56c34a4d3e --- /dev/null +++ b/.changeset/fix-whitespace-thinking-blank-line.md @@ -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. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 82257c86cf..00d79c3d8c 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -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') { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index ae5eac7681..7ecb8b0e1f 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -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; 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 3bc1497fa0..567270558c 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 @@ -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'; @@ -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();