Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
36 changes: 36 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 @@ -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();

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 Move last-chance compaction after finalizing thinking

For a turn that ends while thinking is still active and no assistant text has started, _pendingThinkingCompact is still false at this point, so this call is a no-op; finalizeLiveTextBuffers() below then calls onThinkingEnd() and sets the pending flag after the last-chance compact has already passed. That leaves no-assistant thinking blocks in stable/live-shaped rendering after turn completion and leaks the pending compact into a later event.

Useful? React with 👍 / 👎.

const completedTurnKey =
this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`;
this.finalizeLiveTextBuffers('idle');
Expand Down Expand Up @@ -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;
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 +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();
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