Skip to content

fix(tui): prevent viewport jump when thinking finalizes above viewport#1141

Open
starSumi wants to merge 6 commits into
MoonshotAI:mainfrom
starSumi:fix/thinking-viewport-scroll-jump
Open

fix(tui): prevent viewport jump when thinking finalizes above viewport#1141
starSumi wants to merge 6 commits into
MoonshotAI:mainfrom
starSumi:fix/thinking-viewport-scroll-jump

Conversation

@starSumi

Copy link
Copy Markdown

Summary

When ThinkingComponent transitions from live to finalized above the viewport, its line count changes (4→3), triggering pi-tui's destructive fullRender(true) which clears the screen and jumps to the top.

Fix: Stable transition mechanism — finalize() keeps live-format line count, compact() defers the reduction to a safe render cycle when assistant content also changes.

Root Cause

finalize() → render lines: 4→3
→ pi-tui: firstChanged < prevViewportTop → fullRender(true) → \x1b[2J → jump to top

Solution

thinking.ts — Stable mode

  • finalize() enters stable mode: stops spinner, keeps same line shape as live
  • compact() defers actual line-count reduction, returns boolean
  • render() uses mode === 'live' || stableMode for identical output

streaming-ui.ts — Deferred compaction

  • onThinkingEnd()finalize() (stable, not compact)
  • compactThinkingIfPending() called at onStreamingTextStart() — both changes in same render cycle
  • Fallback in finalizeTurn(), cleanup in resetLiveText()

thinking.test.ts — Updated + 3 new tests

  • Stable mode line count preservation, compact return value, expand/collapse after compact

Why it works

Phase Lines pi-tui path
Live 4 (stable) Differential ✓
Thinking ends 4 (stable) Differential ✓
Assistant starts 3 (compact) + new content fullRender masked by content below

Testing

  • TypeScript compiles clean
  • All 8 tests pass
  • Backward compatible (finalized-mode constructor unchanged)

Related

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 MoonshotAI#981

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 56662c0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3cfd3a6df6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +601 to +605
if (child instanceof ThinkingComponent) {
if ((child as ThinkingComponent).compact()) {
this.host.state.ui.requestRender();
}
break;

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 👍 / 👎.

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 👍 / 👎.

starSumi and others added 2 commits June 26, 2026 20:17
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) <noreply@anthropic.com>
Add controller-level regressions for thinking-only turn compaction and latest pending thinking block selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@starSumi

Copy link
Copy Markdown
Author

Follow-up pushed in cb139392 for the Codex P2 review:

  • Added controller-level regressions for both review points:
    • thinking-only turns compact after finalizeLiveTextBuffers() finalizes the active thinking block
    • when an older thinking block already exists, the latest pending thinking block is the one compacted
  • Kept the patch limited to test coverage because commit 004c55e6 already moved last-chance compaction after finalization and changed pending compaction to scan transcript children in reverse.

Validation:

  • pnpm exec vitest run apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts -t "thinking" --reporter=dot
  • pnpm exec vitest run apps/kimi-code/test/tui/components/messages/thinking.test.ts --reporter=dot
  • pnpm exec oxlint apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
  • pnpm --filter @moonshot-ai/kimi-code typecheck
  • git diff --check

Transport note: HTTPS and explicit SSH git push both failed from my local environment, so I used the GitHub Contents API to create the single-file test commit on the fork branch after verifying the remote head was still 004c55e6.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb139392d2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// After finalizeLiveTextBuffers, onThinkingEnd may have set
// _pendingThinkingCompact. Compact now so the thinking block
// reaches its final compact form before the turn ends.
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 Compact pending thinking before tool-call renders

When a turn goes from thinking.delta directly to tool.call.started or tool.call.delta, the tool-call paths first call finalizeLiveTextBuffers('tool'), which now sets _pendingThinkingCompact, but they never consume it before adding the tool UI. The first compaction then happens here in finalizeTurn() after the tool/result has already rendered, so the thinking block can shrink above the viewport with no newly-added content below and recreate the same scroll-jump this patch is trying to avoid. Please also compact the pending thinking block before rendering/flushing the tool component.

Useful? React with 👍 / 👎.

// identical to live mode, preventing a destructive fullRender when
// this component is above the viewport (fixes #981).
this._activeThinkingComponent.finalize();
this._pendingThinkingCompact = true;

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 Finalize replayed thinking instead of leaving it stable

onThinkingEnd() is also used by SessionReplayRenderer.flushAssistant(). For replayed assistant messages that contain only think parts, or think plus tool calls with no text, replay never calls onStreamingTextStart() or finalizeTurn(), so this pending flag is never consumed and the transcript keeps rendering the block in stable live-format (thought header with only the tail lines) instead of the normal compact finalized preview. Resume/replay should either compact immediately in that path or expose a finalized mode that does not leave pending live state behind.

Useful? React with 👍 / 👎.

@starSumi

Copy link
Copy Markdown
Author

Follow-up pushed in a92658e for the two new Codex P2 comments on cb139392:

  • Tool-call transition: after finalizeLiveTextBuffers('tool'), both registerToolCall() and streaming flushToolCallPreview() now consume the pending thinking compaction before rendering or updating the tool UI. This covers both tool.call.started and tool.call.delta.
  • Replay transition: SessionReplayRenderer.flushAssistant() now compacts replayed think-only assistant messages when there is no assistant text to trigger onStreamingTextStart(). This covers think-only replay and think + tool-call replay with no assistant text.

Regression coverage added:

  • thinking.delta -> tool.call.started
  • thinking.delta -> tool.call.delta
  • replayed think-only assistant message
  • replayed think + tool call with no assistant text

Validation:

  • pnpm exec vitest run apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts -t thinking --reporter=dot
  • pnpm exec vitest run apps/kimi-code/test/tui/message-replay.test.ts -t compacts --reporter=dot
  • pnpm exec vitest run apps/kimi-code/test/tui/components/messages/thinking.test.ts --reporter=dot
  • pnpm exec oxlint apps/kimi-code/src/tui/controllers/streaming-ui.ts apps/kimi-code/src/tui/controllers/session-replay.ts apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts apps/kimi-code/test/tui/message-replay.test.ts
  • pnpm --filter @moonshot-ai/kimi-code typecheck
  • git diff --check

Transport note: I first tried a normal push while my local branch still had the pre-API SHA for the earlier test-only follow-up. I fetched the fork head, rebased this commit onto the actual remote cb139392, and then pushed normally as a fast-forward to a92658e.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a92658eff5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +806 to +808
if (this._thinkingDraft.length > 0 || this._streamingBlock !== null) {
this.finalizeLiveTextBuffers('tool');
this.compactPendingThinking();

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 pending thinking even without live text

When a thinking block has already been finalized by another boundary (for example handleStepBegin() calls finalizeLiveTextBuffers('waiting')), _pendingThinkingCompact can be true while both _thinkingDraft and _streamingBlock are empty. If the next event is tool.call.delta, this condition is skipped, so the tool preview is added under the stable thought live-shape block and compaction is delayed until a later turn boundary, reintroducing the stale preview/viewport-jump risk for that sequence. Call compactPendingThinking() before rendering the preview regardless of whether there is current draft/assistant text.

Useful? React with 👍 / 👎.

@starSumi

Copy link
Copy Markdown
Author

Follow-up pushed in 56662c0 for Codex discussion r3496231775.

  • flushToolCallPreview() now drains pending thinking compaction before rendering or updating a streaming tool-call preview, even when the thinking block was already finalized by an earlier boundary and there is no live thinking draft or assistant block left.
  • The existing live-buffer guard is preserved: finalizeLiveTextBuffers('tool') still only runs when there is live text to finalize.
  • Added a controller regression for thinking.delta -> turn.step.started -> tool.call.delta, covering the handleStepBegin() path where thinking is finalized before the streaming tool-call preview arrives.

Validation:

  • pnpm exec vitest run apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts -t "finalized pending thinking|streaming tool-call preview" --reporter=dot
  • pnpm exec vitest run apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts -t thinking --reporter=dot
  • pnpm exec vitest run apps/kimi-code/test/tui/message-replay.test.ts -t compacts --reporter=dot
  • pnpm exec vitest run apps/kimi-code/test/tui/components/messages/thinking.test.ts --reporter=dot
  • pnpm exec oxlint apps/kimi-code/src/tui/controllers/streaming-ui.ts apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
  • pnpm --filter @moonshot-ai/kimi-code typecheck
  • git diff --check

Transport note: the first HTTPS push picked up an invalid stored credential. Retrying with a one-shot gh auth git-credential helper pushed the commit normally as a fast-forward.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 56662c0a53

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// identical to live mode, preventing a destructive fullRender when
// this component is above the viewport (fixes #981).
this._activeThinkingComponent.finalize();
this._pendingThinkingCompact = true;

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 Drain pending thinking outside assistant/tool paths

When a thinking block is finalized, this flag now defers the actual compacting, but several existing finalizers never drain it. For example, handleHookResult() calls flushThinkingToTranscript() and then appends the hook-result assistant entry without calling compactPendingThinking(), and interruption/error/compaction paths also call finalizeLiveTextBuffers() without a later assistant/tool/finalizeTurn drain. In those flows a long thinking block is left indefinitely in stable live-shape (thought, tail lines, no expand hint, and Ctrl+O has no effect), whereas before it finalized into the normal compact/expandable transcript entry. Please compact in those terminal/non-tool paths before adding the next transcript/status entry or before returning to idle.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

终端流式输出时偶尔自动滚动到顶部(pi-tui fullRender 触发清屏)

1 participant