-
Notifications
You must be signed in to change notification settings - Fork 364
fix(tui): prevent viewport jump when thinking finalizes above viewport #1141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3cfd3a6
c610a8c
004c55e
cb13939
a92658e
56662c0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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< | ||
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a thinking block is finalized, this flag now defers the actual compacting, but several existing finalizers never drain it. For example, Useful? React with 👍 / 👎. |
||
| this._activeThinkingComponent = undefined; | ||
| this.host.state.ui.requestRender(); | ||
| this.host.mergeCurrentTurnSteps(); | ||
|
|
@@ -763,6 +806,7 @@ export class StreamingUIController { | |
| if (this._thinkingDraft.length > 0 || this._streamingBlock !== null) { | ||
| this.finalizeLiveTextBuffers('tool'); | ||
| } | ||
| this.compactPendingThinking(); | ||
|
|
||
| const existingComponent = this._pendingToolComponents.get(id); | ||
| if (existingComponent !== undefined) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
transcriptContaineralready contains an olderThinkingComponent(for example after a previous turn or replayed message), this loop stops on that older finalized block;compact()returnsfalse, but_pendingThinkingCompacthas 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-shapedthoughtview instead of the finalized preview.Useful? React with 👍 / 👎.