Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/fix-981-viewport-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix viewport jumps when thinking output finalizes above the visible transcript.
61 changes: 54 additions & 7 deletions apps/kimi-code/src/tui/components/messages/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
* Supports live in-place updates while thinking streams, then finalizes
* without replacing the component.
* Supports expand/collapse via Ctrl+O (shared with tool output).
*
* ## Stable Transition (fixes #981)
*
* During streaming, the thinking component typically sits above the visible
* viewport. When its rendered line count changes at that position, pi-tui's
* diff renderer hits the `firstChanged < prevViewportTop` branch and falls
* back to a destructive fullRender (clear-screen), which jumps the terminal
* scroll position to the top.
*
* To prevent this, `finalize()` enters a **stable mode** that keeps the
* rendered line count identical to 'live' mode (spinner replaced by a static
* bullet, same content region). The actual compact transition to the minimal
* finalized form is deferred to `compact()`, which should be called when a
* render cycle also changes content below the viewport (e.g., when the
* assistant message starts streaming).
*/

import { Text, truncateToWidth, type Component, type TUI } from '@moonshot-ai/pi-tui';
Expand All @@ -23,6 +38,7 @@ export class ThinkingComponent implements Component {
private text: string;
private showMarker: boolean;
private mode: ThinkingRenderMode;
private stableMode = false;
private expanded = false;
private readonly ui: TUI | undefined;
private spinnerFrame = 0;
Expand Down Expand Up @@ -71,10 +87,35 @@ export class ThinkingComponent implements Component {
return currentTheme.italicFg('textDim', text);
}

/**
* Transition from live to finalized while keeping rendered line count
* stable. Stops the spinner but continues to render in live-format shape
* (same number of output lines) to avoid triggering pi-tui's destructive
* fullRender path when this component is above the viewport.
*
* Call `compact()` later to switch to the minimal finalized form.
*/
finalize(): void {
this.stopSpinner();
this.stableMode = true;
this.markRenderDirty();
}

/**
* Compact to the minimal finalized form (fewer rendered lines).
*
* This should only be called when it is safe for pi-tui to potentially
* trigger a fullRender — typically during a render cycle that also
* modifies content below the viewport (e.g., assistant text start).
*
* @returns true if the component actually changed shape
*/
compact(): boolean {
if (!this.stableMode) return false;
this.stableMode = false;
this.mode = 'finalized';
this.markRenderDirty();
this.stopSpinner();
return true;
}

dispose(): void {
Expand All @@ -100,18 +141,24 @@ export class ThinkingComponent implements Component {
const contentLines = this.text.length > 0 ? this.textComponent.render(contentWidth) : [''];

let rendered: string[];
if (this.mode === 'live') {
if (this.mode === 'live' || this.stableMode) {
// Stable path: same line shape as live mode. The spinner is replaced
// by a static bullet to stop animation, but the number of output
// lines is identical — this keeps pi-tui on the safe differential
// rendering path when the component is above the viewport.
const visibleLines =
contentLines.length > THINKING_PREVIEW_LINES
? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES)
: contentLines;
const spinner = currentTheme.fg(
'textDim',
`${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `,
);
const indicator = this.stableMode
? currentTheme.fg('textDim', `${STATUS_BULLET} `)
: currentTheme.fg(
'textDim',
`${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `,
);
rendered = [
'',
spinner + currentTheme.fg('textDim', 'thinking...'),
indicator + currentTheme.fg('textDim', this.stableMode ? 'thought' : 'thinking...'),
...visibleLines.map((line) => MESSAGE_INDENT + line),
];
} else {
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export class SessionEventHandler {
this.host.streamingUI.flushNow();
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeLiveTextBuffers('idle');
this.host.streamingUI.compactPendingThinking();
const reason = event.reason;
if (reason === 'error') return;
if (reason === 'aborted' || reason === undefined || reason === '') {
Expand Down Expand Up @@ -483,6 +484,7 @@ export class SessionEventHandler {
this.host.streamingUI.flushThinkingToTranscript('idle');
}
this.host.streamingUI.finalizeAssistantStream();
this.host.streamingUI.compactPendingThinking();
if (event.content.trim().length > 0) {
this.currentTurnHasAssistantText = true;
this.pendingModelBlockedFallback = undefined;
Expand Down Expand Up @@ -846,6 +848,7 @@ export class SessionEventHandler {
this.host.streamingUI.flushNow();
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeLiveTextBuffers('idle');
this.host.streamingUI.compactPendingThinking();
if (event.code === OAUTH_LOGIN_REQUIRED_CODE) {
this.host.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE);
return;
Expand Down Expand Up @@ -972,6 +975,7 @@ export class SessionEventHandler {

private handleCompactionBegin(event: CompactionStartedEvent): void {
this.host.streamingUI.finalizeLiveTextBuffers('waiting');
this.host.streamingUI.compactPendingThinking();
this.host.setAppState({
isCompacting: true,
streamingPhase: 'waiting',
Expand Down
5 changes: 4 additions & 1 deletion apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,18 +387,21 @@ export class SessionReplayRenderer {
const { streamingUI } = this.host;
const thinking = context.assistant.thinking.join('');
const text = context.assistant.text.join('');
const hasVisibleText = text.trim().length > 0;
context.assistant = { thinking: [], text: [] };
this.applyStepContext(context);

if (thinking.length > 0) {
streamingUI.onThinkingUpdate(thinking);
streamingUI.onThinkingEnd();
}
if (text.length > 0) {
if (hasVisibleText) {
streamingUI.onStreamingTextStart();
streamingUI.onStreamingTextUpdate(text);
streamingUI.onStreamingTextEnd();
streamingUI.clearAssistantDraft();
} else if (thinking.length > 0) {
streamingUI.compactPendingThinking();
Comment on lines +403 to +404

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compact replayed thinking for whitespace-only text

When a persisted assistant record has thinking plus a whitespace-only text part, this else branch is skipped, and onStreamingTextUpdate() also refuses to compact because fullText.trim() is empty. If that replayed message is followed by tool calls, renderToolCalls() adds the tool card without compacting, so resumed transcripts keep the thinking block in stable thought mode with the live tail preview and no expand footer, unlike the live tool-boundary path that compacts before rendering tools.

Useful? React with 👍 / 👎.

}
}

Expand Down
44 changes: 44 additions & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export class StreamingUIController {
private _thinkingDraft = '';
private _streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null;
private _activeThinkingComponent: ThinkingComponent | undefined = undefined;
private _pendingThinkingCompact = false;
private _activeCompactionBlock: CompactionComponent | undefined = undefined;
private _activeToolCalls = new Map<string, ToolCallBlockData>();
private _streamingToolCallArguments = new Map<
Expand Down Expand Up @@ -315,6 +316,7 @@ export class StreamingUIController {
existingComponent.updateToolCall(toolCall);
} else if (existing === undefined) {
this.finalizeLiveTextBuffers('tool');
this.compactPendingThinking();
if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') {
this.onToolCallStart(toolCall);
}
Expand Down Expand Up @@ -522,6 +524,7 @@ export class StreamingUIController {
resetLiveText(): void {
this.pendingAssistantFlush = false;
this.pendingThinkingFlush = false;
this._pendingThinkingCompact = false;
this.clearFlushTimerIfIdle();
this._assistantDraft = '';
this._streamingBlock = null;
Expand Down Expand Up @@ -555,6 +558,10 @@ export class StreamingUIController {
const completedTurnKey =
this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`;
this.finalizeLiveTextBuffers('idle');
// After finalizeLiveTextBuffers, onThinkingEnd may have set
// _pendingThinkingCompact. Compact now so the thinking block
// reaches its final compact form before the turn ends.
this.compactPendingThinking();
this.resetToolCallState();
this._currentTurnId = undefined;

Expand All @@ -580,6 +587,34 @@ export class StreamingUIController {
// Live Render Hooks
// ---------------------------------------------------------------------------

/**
* Compact a stable-mode thinking component to its minimal finalized form.
*
* Called after the first visible assistant text update so that the thinking
* line-count reduction and the assistant content addition happen in the
* same pi-tui render cycle. The assistant content growing below offsets
* the destructive fullRender, making the transition invisible.
*
* Also called as a fallback in `finalizeTurn()` for the edge case where
* no assistant text follows the thinking block.
*/
compactPendingThinking(): void {
if (!this._pendingThinkingCompact) return;
this._pendingThinkingCompact = false;
// Walk in reverse to find the most recent stable-mode ThinkingComponent,
// not an older one from a previous turn.
const children = this.host.state.transcriptContainer.children;
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (child instanceof ThinkingComponent) {
if ((child as ThinkingComponent).compact()) {
this.host.state.ui.requestRender();
}
break;
Comment on lines +609 to +613

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compact the pending thinking block, not the first one

When transcriptContainer already contains an older ThinkingComponent (for example after a previous turn or replayed message), this loop stops on that older finalized block; compact() returns false, but _pendingThinkingCompact has already been cleared and the loop still breaks. The just-finalized stable-mode block later in the transcript is then never compacted, leaving it in the live-shaped thought view instead of the finalized preview.

Useful? React with 👍 / 👎.

}
}
}

onStreamingTextStart(): void {
const { state } = this.host;
this._pendingAgentGroup = null;
Expand All @@ -601,8 +636,12 @@ export class StreamingUIController {
onStreamingTextUpdate(fullText: string): void {
const block = this._streamingBlock;
if (block !== null) {
const hasVisibleAssistantText = fullText.trim().length > 0;
block.entry.content = fullText;
block.component.updateContent(fullText, { transient: true });
if (hasVisibleAssistantText) {
this.compactPendingThinking();
}
this.host.state.ui.requestRender();
}
}
Expand Down Expand Up @@ -637,7 +676,11 @@ export class StreamingUIController {

onThinkingEnd(): void {
if (this._activeThinkingComponent === undefined) return;
// Enter stable mode: spinner stops but rendered line count stays
// identical to live mode, preventing a destructive fullRender when
// this component is above the viewport (fixes #981).
this._activeThinkingComponent.finalize();
this._pendingThinkingCompact = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Finalize replayed thinking instead of leaving it stable

onThinkingEnd() is also used by SessionReplayRenderer.flushAssistant(). For replayed assistant messages that contain only think parts, or think plus tool calls with no text, replay never calls onStreamingTextStart() or finalizeTurn(), so this pending flag is never consumed and the transcript keeps rendering the block in stable live-format (thought header with only the tail lines) instead of the normal compact finalized preview. Resume/replay should either compact immediately in that path or expose a finalized mode that does not leave pending live state behind.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drain pending thinking outside assistant/tool paths

When a thinking block is finalized, this flag now defers the actual compacting, but several existing finalizers never drain it. For example, handleHookResult() calls flushThinkingToTranscript() and then appends the hook-result assistant entry without calling compactPendingThinking(), and interruption/error/compaction paths also call finalizeLiveTextBuffers() without a later assistant/tool/finalizeTurn drain. In those flows a long thinking block is left indefinitely in stable live-shape (thought, tail lines, no expand hint, and Ctrl+O has no effect), whereas before it finalized into the normal compact/expandable transcript entry. Please compact in those terminal/non-tool paths before adding the next transcript/status entry or before returning to idle.

Useful? React with 👍 / 👎.

this._activeThinkingComponent = undefined;
this.host.state.ui.requestRender();
this.host.mergeCurrentTurnSteps();
Expand Down Expand Up @@ -764,6 +807,7 @@ export class StreamingUIController {
if (this._thinkingDraft.length > 0 || this._streamingBlock !== null) {
this.finalizeLiveTextBuffers('tool');
}
this.compactPendingThinking();

const existingComponent = this._pendingToolComponents.get(id);
if (existingComponent !== undefined) {
Expand Down
30 changes: 26 additions & 4 deletions apps/kimi-code/test/tui/components/messages/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ThinkingComponent } from '#/tui/components/messages/thinking';
import { STATUS_BULLET } from '#/tui/constant/symbols';

function strip(text: string): string {
return text.replaceAll(/\u001B\[[0-9;]*m/g, '');
return text.replaceAll(/\[[0-9;]*m/g, '');
}

const longThinking = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7'].join('\n');
Expand Down Expand Up @@ -53,11 +53,26 @@ describe('ThinkingComponent', () => {
vi.useRealTimers();
});

it('finalizes in place into a collapsed preview', () => {
it('finalize() enters stable mode with live-format line count', () => {
const component = new ThinkingComponent(longThinking, true, 'live');
const liveOut = strip(component.render(80).join('\n'));

component.finalize();

const stableOut = strip(component.render(80).join('\n'));
// Same number of lines as live mode (no viewport jump)
expect(liveOut.split('\n').length).toBe(stableOut.split('\n').length);
// Spinner stopped, replaced by bullet
expect(stableOut).not.toContain('thinking...');
expect(stableOut).toContain(`${STATUS_BULLET}`);
expect(stableOut).toContain('thought');
});

it('compact() produces the collapsed preview after stable mode', () => {
const component = new ThinkingComponent(longThinking, true, 'live');
component.finalize();
component.compact();

const out = strip(component.render(80).join('\n'));
expect(out).toContain('line1');
expect(out).toContain('line2');
Expand All @@ -66,9 +81,15 @@ describe('ThinkingComponent', () => {
expect(out).toContain('... (5 more lines, ctrl+o to expand)');
});

it('expands and collapses after finalization', () => {
it('compact() returns false when not in stable mode', () => {
const component = new ThinkingComponent('hi', true, 'finalized');
expect(component.compact()).toBe(false);
});

it('expands and collapses after compact', () => {
const component = new ThinkingComponent(longThinking, true, 'live');
component.finalize();
component.compact();

component.setExpanded(true);
const expanded = strip(component.render(80).join('\n'));
Expand All @@ -81,9 +102,10 @@ describe('ThinkingComponent', () => {
expect(collapsed).toContain('ctrl+o to expand');
});

it('keeps the finalized truncation footer within the requested render width', () => {
it('keeps the truncated footer within the requested render width after compact', () => {
const component = new ThinkingComponent(longThinking, true, 'live');
component.finalize();
component.compact();

for (const line of component.render(37)) {
expect(visibleWidth(line)).toBeLessThanOrEqual(37);
Expand Down
Loading