diff --git a/.changeset/fix-981-viewport-jump.md b/.changeset/fix-981-viewport-jump.md new file mode 100644 index 000000000..6aeb3b1c3 --- /dev/null +++ b/.changeset/fix-981-viewport-jump.md @@ -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 diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index ca0847581..65b1e8b2d 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -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'; @@ -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; @@ -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 { @@ -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 { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index cb620801e..33c973a6a 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -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(); private _streamingToolCallArguments = new Map< @@ -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; @@ -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; @@ -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; + } + } + } + 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; @@ -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(); diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index 40f609be1..41c48c605 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -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'); @@ -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'); @@ -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')); @@ -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);