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/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index e3f53683b..87c26c423 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -389,6 +389,8 @@ export class SessionReplayRenderer { streamingUI.onStreamingTextUpdate(text); streamingUI.onStreamingTextEnd(); streamingUI.clearAssistantDraft(); + } else if (thinking.length > 0) { + streamingUI.compactPendingThinking(); } } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index cb620801e..89a823ff3 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< @@ -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); } @@ -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; @@ -554,6 +557,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; @@ -579,7 +586,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. + */ + 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; + } + } + } + onStreamingTextStart(): void { + // Compact thinking before adding assistant content so both changes + // land in the same render cycle (fixes #981 viewport jump). + this.compactPendingThinking(); + const { state } = this.host; this._pendingAgentGroup = null; this._pendingReadGroup = null; @@ -636,7 +675,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(); @@ -762,6 +805,7 @@ export class StreamingUIController { if (this._thinkingDraft.length > 0 || this._streamingBlock !== null) { this.finalizeLiveTextBuffers('tool'); + this.compactPendingThinking(); } const existingComponent = this._pendingToolComponents.get(id); 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); 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 9c02d4d11..c99ecbd63 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 @@ -4517,6 +4517,149 @@ command = "vim" expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); }); + it('compacts a thinking-only turn after finalizing it on turn end', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'thinking'; + driver.state.appState.streamingStartTime = 1; + + streamThinking(driver, ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7']); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('line1'); + expect(transcript).toContain('line2'); + expect(transcript).not.toContain('line7'); + expect(transcript).toContain('... (5 more lines, ctrl+o to expand)'); + }); + + it('compacts the latest pending thinking block when an older thinking block exists', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'thinking'; + driver.state.appState.streamingStartTime = 1; + + streamThinking(driver, ['old1', 'old2', 'old3', 'old4', 'old5', 'old6', 'old7']); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: 'first answer', + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + vi.fn(), + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 2, + delta: ['new1', 'new2', 'new3', 'new4', 'new5', 'new6', 'new7'].join('\n'), + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 2, + delta: 'second answer', + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('old1'); + expect(transcript).toContain('old2'); + expect(transcript).toContain('new1'); + expect(transcript).toContain('new2'); + expect(transcript).not.toContain('new7'); + expect(countOccurrences(transcript, 'ctrl+o to expand')).toBe(2); + }); + + it('compacts pending thinking before rendering a tool call', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'thinking'; + driver.state.appState.streamingStartTime = 1; + + streamThinking(driver, ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7']); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_read', + name: 'Read', + args: { path: 'src/a.ts' }, + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('line1'); + expect(transcript).toContain('line2'); + expect(transcript).not.toContain('line7'); + expect(transcript).toContain('... (5 more lines, ctrl+o to expand)'); + expect(transcript).toContain('Using Read (src/a.ts)'); + }); + + it('compacts pending thinking before rendering a streaming tool-call preview', async () => { + vi.useFakeTimers(); + try { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'thinking'; + driver.state.appState.streamingStartTime = 1; + + streamThinking(driver, ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7']); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_bash', + name: 'Bash', + argumentsPart: '{"command":"echo hi"}', + } as Event, + vi.fn(), + ); + + await vi.runOnlyPendingTimersAsync(); + + const transcript = stripSgr(renderTranscript(driver)); + expect(driver.streamingUI.getToolComponent('call_bash')).toBeDefined(); + expect(transcript).toContain('line1'); + expect(transcript).toContain('line2'); + expect(transcript).not.toContain('line7'); + expect(transcript).toContain('... (5 more lines, ctrl+o to expand)'); + } finally { + vi.useRealTimers(); + } + }); + it('renders newly streamed thinking expanded when ctrl+o toggle was already active', async () => { const { driver } = await makeDriver(); driver.state.toolOutputExpanded = true; @@ -4588,3 +4731,15 @@ command = "vim" expect(transcript).not.toContain(' { expect(driver.streamingUI.getToolComponent('call_read_2')).toBeUndefined(); }); + it('compacts replayed think-only assistant messages', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'think only' }]), + message('assistant', [ + { type: 'think', think: ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7'].join('\n') }, + ]), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('line1'); + expect(transcript).toContain('line2'); + expect(transcript).not.toContain('line7'); + expect(transcript).toContain('... (5 more lines, ctrl+o to expand)'); + expect(transcript).not.toContain('thought'); + }); + + it('compacts replayed think-only assistant messages before rendering tool calls', async () => { + const replay: AgentReplayRecord[] = [ + message('user', [{ type: 'text', text: 'read after thinking' }]), + message( + 'assistant', + [ + { + type: 'think', + think: ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7'].join('\n'), + }, + ], + { + toolCalls: [ + toolCall('call_read', 'Read', { file_path: '/tmp/proj-a/src/a.ts' }), + ], + }, + ), + message('tool', [{ type: 'text', text: 'line a\nline b\n' }], { + toolCallId: 'call_read', + }), + ]; + + const driver = await replayIntoDriver(replay); + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('line1'); + expect(transcript).toContain('line2'); + expect(transcript).not.toContain('line7'); + expect(transcript).toContain('... (5 more lines, ctrl+o to expand)'); + expect(transcript).toContain('Used Read'); + expect(transcript).toContain('src'); + expect(transcript).not.toContain('thought'); + }); + it('renders replayed AgentSwarm calls as compact result summaries', async () => { const replay: AgentReplayRecord[] = [ message('user', [{ type: 'text', text: 'review files with a swarm' }]),