From 3cfd3a6df6e71b6b29074a29aa856cacba70adde Mon Sep 17 00:00:00 2001 From: star Date: Fri, 26 Jun 2026 19:58:10 +0800 Subject: [PATCH 1/3] fix(tui): prevent viewport jump when thinking finalizes above viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the ThinkingComponent transitions from 'live' to 'finalized' mode while above the visible viewport, its rendered line count changes (4→3 lines). This triggers pi-tui's `firstChanged < prevViewportTop` branch, which falls back to a destructive fullRender (clear-screen), causing the terminal to jump to the top. Introduce a stable transition mechanism: - `finalize()` now enters 'stable mode' that keeps the same line count as live mode (spinner replaced by static bullet, same content region) - `compact()` defers the actual line-count reduction to a safe window - StreamingUIController calls compactThinkingIfPending() at the start of assistant text streaming, so both changes land in the same render cycle and the fullRender is masked by the new content below Fixes #981 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/tui/components/messages/thinking.ts | 61 ++++++++++++++++--- .../src/tui/controllers/streaming-ui.ts | 36 +++++++++++ .../tui/components/messages/thinking.test.ts | 30 +++++++-- 3 files changed, 116 insertions(+), 11 deletions(-) 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..177670362 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; @@ -551,6 +553,8 @@ export class StreamingUIController { const { state } = this.host; if (state.appState.streamingPhase === 'idle') return; this.host.deferUserMessages = false; + // Last-chance compact in case no assistant text triggered it + this.compactThinkingIfPending(); const completedTurnKey = this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; this.finalizeLiveTextBuffers('idle'); @@ -579,7 +583,35 @@ 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; + for (const child of this.host.state.transcriptContainer.children) { + 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 +668,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); From c610a8cac410361d1f972234ac5cc5989054cdf5 Mon Sep 17 00:00:00 2001 From: star Date: Fri, 26 Jun 2026 20:05:07 +0800 Subject: [PATCH 2/3] docs: add changeset for #981 viewport fix Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fix-981-viewport-jump.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/fix-981-viewport-jump.md 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 From 004c55e6f7bebae0366aa9259ffb5837c1cabe40 Mon Sep 17 00:00:00 2001 From: star Date: Fri, 26 Jun 2026 20:17:45 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(tui):=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20fix=20compact=20order=20and=20target=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caught by Codex automated review: 1. compactThinkingIfPending() found the FIRST ThinkingComponent in transcript, which could be an older block from a previous turn. Now walks in reverse to find the most recent stable-mode instance. 2. Last-chance compaction in finalizeTurn() was called BEFORE finalizeLiveTextBuffers(), so _pendingThinkingCompact was still false when thinking was still active. Moved after finalizeLiveTextBuffers. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/kimi-code/src/tui/controllers/streaming-ui.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 177670362..33c973a6a 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -553,11 +553,13 @@ export class StreamingUIController { const { state } = this.host; if (state.appState.streamingPhase === 'idle') return; this.host.deferUserMessages = false; - // Last-chance compact in case no assistant text triggered it - this.compactThinkingIfPending(); 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; @@ -597,7 +599,11 @@ export class StreamingUIController { private compactThinkingIfPending(): void { if (!this._pendingThinkingCompact) return; this._pendingThinkingCompact = false; - for (const child of this.host.state.transcriptContainer.children) { + // 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();