Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/fix-981-viewport-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@moonshot-ai/kimi-code": patch
---

fix(tui): prevent viewport jump when thinking finalizes above viewport

When ThinkingComponent transitions from live to finalized above the viewport, its line count change triggers pi-tui's destructive fullRender path, clearing the screen. Introduces stable transition mode that keeps line count constant across the live→finalized boundary, deferring compaction to a safe render cycle.

Fixes #981
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 '@earendil-works/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
42 changes: 42 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 @@ -522,6 +523,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 @@ -554,6 +556,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.compactThinkingIfPending();
this.resetToolCallState();
this._currentTurnId = undefined;

Expand All @@ -579,7 +585,39 @@ export class StreamingUIController {
// Live Render Hooks
// ---------------------------------------------------------------------------

/**
* Compact a stable-mode thinking component to its minimal finalized form.
*
* Called at the start of assistant text streaming 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.
*/
private compactThinkingIfPending(): 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 +607 to +611

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 {
// Compact thinking before adding assistant content so both changes
// land in the same render cycle (fixes #981 viewport jump).
this.compactThinkingIfPending();

const { state } = this.host;
this._pendingAgentGroup = null;
this._pendingReadGroup = null;
Expand Down Expand Up @@ -636,7 +674,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;
this._activeThinkingComponent = undefined;
this.host.state.ui.requestRender();
this.host.mergeCurrentTurnSteps();
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