Skip to content

fix(terminal): smoother host scroll + subtle TUI paint glide - #9339

Open
gatsby74 wants to merge 3 commits into
stablyai:mainfrom
gatsby74:gatsby74/smooth-scroll-overscan
Open

fix(terminal): smoother host scroll + subtle TUI paint glide#9339
gatsby74 wants to merge 3 commits into
stablyai:mainfrom
gatsby74:gatsby74/smooth-scroll-overscan

Conversation

@gatsby74

@gatsby74 gatsby74 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes everyday terminal scrolling feel smoother in two separate paths:

  1. Host-owned scrollback (shell history, logs, long command output) — sub-row pixel motion instead of whole-row snaps.
  2. Mouse-reporting TUIs (Claude Code fullscreen, Codex, OpenCode, vim-style apps) — a short cosmetic paint glide between discrete app updates.

Builds on Prince’s #7450 and addresses @Jinwoo-H’s review: scope is explicit, host path only for real pixel remainder, TUI path does not invent logical app scroll.


What users should notice

Context Behavior
Normal buffer / scrollback Wheel + animated scroll keep a fractional pixel offset; small output jumps can catch-up glide instead of stepping.
Fullscreen Claude / Codex / OpenCode (mouse reporting on) Same discrete wheel reports as today, plus a subtle visual lag on the painted screen (default).
Settings Terminal → Scroll Speed → TUI scroll glide: Off / Subtle (default) / Medium

prefers-reduced-motion: reduce snaps both glides off.


What we added (technical)

@xterm/xterm patch

  • Host: scrollTop remainder → translateY (_pixelOffset)
  • Host: small ydisp jumps → decaying _catchUp (only when hasScrollback)
  • TUI: public nudgeTuiGlide(deltaPx, maxCellFraction)_tuiGlide
  • Shared RAF decay for catch-up + TUI glide; settle on mousedown
  • Clip via overflow: hidden on scroll content / screen so offsets don’t paint outside the box
  • Default smoothScrollDuration: 120 in Orca terminal options (decay timebase + smooth scrollable animation)

Orca

  • terminalTuiScrollGlide: 'off' | 'subtle' | 'medium' (default 'subtle')
  • Wired through existing TUI wheel attach path (enable-mouse-events gate) — only when report multiplication already runs
  • Glide intensities: subtle ≈ 0.35 cell, medium ≈ 0.65 cell (clamped)

Tests

  • IME composition merge edge cases (CodeRabbit on the shared xterm patch)
  • TUI glide unit tests (intensity mapping + viewport forward)

Limitations (please read)

Limitation Detail
Not Warp-class TUI scroll TUIs still own the buffer. We only ease the paint between whole-line app updates. Content still advances by app/report lines.
Not 1px logical scroll in Claude No sub-row ydisp, no overscan fill of app cells, no custom compositor.
TUI glide needs mouse reporting If Claude is just shell transcript without mouse-reporting, you get host smooth scroll only, not TUI glide.
Effect can be subtle Fast app redraws clear the offset quickly. Use Medium or compare Off vs Subtle on the same fullscreen session.
Blank-edge polish incomplete Jinwoo’s fractional blank strip was targeted by overscan (rows+2 paint). That implementation blanked new terminals (double Dom shift + incomplete WebGL) and was removed. Remaining mitigation is clip only — a thin empty edge can still show during host sub-row motion.
WebGL overscan Not in this PR (reverted with Dom overscan).

Relation to #7450 / overscan

Idea Status in this PR
Host pixel remainder + catch-up Shipped
Clip fractional transforms Shipped
Dom/WebGL ±1 overscan paint Tried → removed (blank terminals)
Cosmetic TUI glide Shipped (default subtle)
True alt-screen pixel scroll Out of scope (needs app protocol or custom engine)

#7450 can be closed as superseded once this lands, or left as historical context.


Scope table

In scope Out of scope
Host viewport pixel remainder + catch-up True 1px logical scroll inside alt-screen apps
Cosmetic TUI paint glide + setting Custom terminal compositor / GPU engine rewrite
Clip + reduced-motion Complete overscan reimplementation

Testing

  • vitest: TUI glide, mouse-wheel, IME composition de-duplication
  • Manual: long scrollback — sub-row remainder feels continuous
  • Manual: Claude fullscreen — Subtle vs Off vs Medium
  • Manual: shell-only Claude transcript uses host path (no false TUI claim)
  • Manual: prefers-reduced-motion disables glides
  • CI: lint / typecheck / test / build

Host scrollback fill (for manual test):

seq -f '%04g | LINE %04g | scroll-bench-marker-%04g' 1 400 1 400 1 400

AI agent review summary

Area Notes
Cross-platform Renderer + settings only; no shortcuts/path/PTY semantics. macOS/Linux/Windows share the same xterm patch.
SSH / remote Client-side paint only; remote PTY and report bytes unchanged.
Agent / TUI compat Glide is visual-only; does not alter SGR wheel reports or buffer. Off setting restores prior TUI feel.
Performance One extra RAF while offset decays; clamp ±fraction of one cell.
UI quality Setting next to existing TUI scroll speed; default Subtle.
Security No new deps; patch integrity via lockfile; no network.

Notes for maintainers

  • xterm upgrades must re-apply config/patches/@xterm__xterm@… (IME merge + viewport smooth/glide live in the same patch on this branch).
  • Optional follow-ups: complete overscan (single transform owner, full WebGL model remap) if blank-edge polish is still wanted after living with clip-only host scroll; stronger TUI presentation only if Subtle is too weak in real Claude use.

ELI5

Terminal scrolling felt jumpy: history scrolled in whole-row steps and fullscreen TUIs painted with hard jumps. Host scrollback gets smoother pixel motion, and TUI updates get a short soft paint glide.

…tablyai#7450)

Addresses Jinwoo's stablyai#7450 review: fractional translateY no longer exposes a
blank strip (paint ±1 overscan row on DOM + WebGL, clip overflow:hidden),
and scope is host-owned scroll only (scrollback / normal buffer) — not true
1px logical scroll inside alt-screen TUIs.

- Extend @xterm/xterm patch: pixel-offset + catch-up glide, hasScrollback
  gate, clip/bg, DomRenderer overscan
- Extend @xterm/addon-webgl patch: paint rows+2, logical layout height,
  canvas -cellHeight shift inside clipped screen
- Default smoothScrollDuration 120ms so sub-row remainders animate
@gatsby74
gatsby74 marked this pull request as draft July 18, 2026 13:52
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 82073a03-a185-4935-9243-d70a4ea57de3

📥 Commits

Reviewing files that changed from the base of the PR and between 5977850 and d609954.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (17)
  • config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch
  • src/main/codex-accounts/runtime-home-service.test.ts
  • src/main/codex-accounts/service.test.ts
  • src/renderer/src/components/settings/TerminalInteractionSection.tsx
  • src/renderer/src/components/settings/setting-labels.ts
  • src/renderer/src/components/settings/terminal-tui-scroll-glide-setting.tsx
  • src/renderer/src/components/terminal-pane/terminal-ime-xterm-composition-deduplication.test.ts
  • src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
  • src/renderer/src/lib/pane-manager/pane-dom-creation.ts
  • src/renderer/src/lib/pane-manager/pane-lifecycle.ts
  • src/renderer/src/lib/pane-manager/pane-manager-types.ts
  • src/renderer/src/lib/pane-manager/pane-terminal-mouse-wheel.ts
  • src/renderer/src/lib/pane-manager/pane-terminal-tui-scroll-glide.test.ts
  • src/renderer/src/lib/pane-manager/pane-terminal-tui-scroll-glide.ts
  • src/shared/constants.ts
  • src/shared/types.ts

📝 Walkthrough

Walkthrough

Terminal settings now support configurable TUI scroll glide with off, subtle, and medium values, including defaults, UI controls, pane wiring, viewport nudging, and helper tests. Default terminal options set smoothScrollDuration to 120. IME composition tests add coverage for merge heuristics across duplicate, overlap, concatenation, repeated-character, and emoji inputs.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main terminal scrolling and TUI glide changes.
Description check ✅ Passed The description covers the summary, testing, review notes, and follow-up notes, but it omits the required Screenshots and Security Audit sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
config/patches/@xterm__xterm@6.1.0-beta.287.patch (1)

355-381: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

_sendCompositionInput's overlap-merge heuristic has no accompanying test coverage and looks fragile for edge cases (empty strings, repeated substrings, multi-byte/surrogate-pair splits).

The longest-common-prefix/suffix merge between _pendingKeypressData and the composition candidate can pick the wrong branch when both strings share ambiguous overlaps (e.g. repeated characters), potentially sending duplicated or truncated text to the terminal on IME composition end. Given this changes what actually gets written to the PTY, please add unit tests for this merge logic (empty keypress, full duplicate, partial prefix/suffix overlap, no overlap) before merging.

🧹 Nitpick comments (1)
config/patches/@xterm__xterm@6.1.0-beta.287.patch (1)

444-462: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

renderRows overscan range is a superset, not a shift — wastes ~2 extra full-row DOM rebuilds on every partial refresh.

Real paint index = logical index + SCROLL_OVERSCAN_ROWS, so the tight bound should be [start + SCROLL_OVERSCAN_ROWS, end + SCROLL_OVERSCAN_ROWS]. Instead the code uses [start, end + 2*SCROLL_OVERSCAN_ROWS], which still contains the correct rows but always rebuilds ~2 extra full-width rows (e.g. every cursor-move/selection/blink refresh), not just at scroll boundaries.

♻️ Proposed tightening
-    const paintStart = Math.max(0, start);
-    const paintEnd = Math.min(this._rowElements.length - 1, end + 2 * SCROLL_OVERSCAN_ROWS);
-    // Also refresh overscan rows adjacent to the requested band.
-    const lo = Math.max(0, paintStart);
-    const hi = Math.min(this._rowElements.length - 1, paintEnd);
+    const lo = Math.max(0, start + SCROLL_OVERSCAN_ROWS);
+    const hi = Math.min(this._rowElements.length - 1, end + SCROLL_OVERSCAN_ROWS);

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d4b0693c-5113-4e24-8e72-7da8abc19a62

📥 Commits

Reviewing files that changed from the base of the PR and between d8629c4 and 5977850.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch
  • src/renderer/src/lib/pane-manager/pane-terminal-options.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
config/patches/@xterm__xterm@6.1.0-beta.287.patch (1)

355-381: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

_sendCompositionInput's overlap-merge heuristic has no accompanying test coverage and looks fragile for edge cases (empty strings, repeated substrings, multi-byte/surrogate-pair splits).

The longest-common-prefix/suffix merge between _pendingKeypressData and the composition candidate can pick the wrong branch when both strings share ambiguous overlaps (e.g. repeated characters), potentially sending duplicated or truncated text to the terminal on IME composition end. Given this changes what actually gets written to the PTY, please add unit tests for this merge logic (empty keypress, full duplicate, partial prefix/suffix overlap, no overlap) before merging.

🧹 Nitpick comments (1)
config/patches/@xterm__xterm@6.1.0-beta.287.patch (1)

444-462: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

renderRows overscan range is a superset, not a shift — wastes ~2 extra full-row DOM rebuilds on every partial refresh.

Real paint index = logical index + SCROLL_OVERSCAN_ROWS, so the tight bound should be [start + SCROLL_OVERSCAN_ROWS, end + SCROLL_OVERSCAN_ROWS]. Instead the code uses [start, end + 2*SCROLL_OVERSCAN_ROWS], which still contains the correct rows but always rebuilds ~2 extra full-width rows (e.g. every cursor-move/selection/blink refresh), not just at scroll boundaries.

♻️ Proposed tightening
-    const paintStart = Math.max(0, start);
-    const paintEnd = Math.min(this._rowElements.length - 1, end + 2 * SCROLL_OVERSCAN_ROWS);
-    // Also refresh overscan rows adjacent to the requested band.
-    const lo = Math.max(0, paintStart);
-    const hi = Math.min(this._rowElements.length - 1, paintEnd);
+    const lo = Math.max(0, start + SCROLL_OVERSCAN_ROWS);
+    const hi = Math.min(this._rowElements.length - 1, end + SCROLL_OVERSCAN_ROWS);

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d4b0693c-5113-4e24-8e72-7da8abc19a62

📥 Commits

Reviewing files that changed from the base of the PR and between d8629c4 and 5977850.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch
  • src/renderer/src/lib/pane-manager/pane-terminal-options.ts
🛑 Comments failed to post (2)
config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch (1)

121-141: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

+2 overscan row count duplicated as a magic number.

SCROLL_OVERSCAN_ROWS is defined only in WebglRenderer.ts; GlyphRenderer.clear() independently hardcodes terminal.rows + 2. If the overscan row count ever changes in one file without the other, _vertices.attributes will be sized for a different row count than WebglRenderer._updateModel/updateCell write into — since JS typed-array writes past length are silently dropped, this fails silently as corrupted/missing rendering rather than a crash.

♻️ Suggested fix

Export SCROLL_OVERSCAN_ROWS (or an equivalent shared constant) from a common module and import it in both GlyphRenderer.ts and WebglRenderer.ts instead of hardcoding 2/+2*SCROLL_OVERSCAN_ROWS independently.

config/patches/@xterm__xterm@6.1.0-beta.287.patch (1)

200-215: 🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

Overscan row-alignment shift is applied twice (parent screenElement + child rowContainer), permanently misaligning rendered rows by one extra row height.

screenElement is the same DOM node in both Viewport and DomRenderer (passed identically at construction). DomRenderer._updateDimensions() already shifts rowContainer (a child of screenElement) by translateY(-overscanPx) to align the overscan-painted row with the real top row — this alone is correct. Viewport._applyPixelOffset() then reads the same -overscanPx value back out of screenElement.dataset.xtermOverscanPx and applies it again to screenElement itself. Since CSS transforms on nested elements compose additively, the resulting baseline shift is -2×overscanPx instead of -1×overscanPx — even when pixelOffset and catchUp are both 0. This isn't limited to scroll animation; it's the resting/idle state too, so terminal content will always render shifted up by roughly one extra row height, cutting off/duplicating content at the viewport edges.

  • config/patches/@xterm__xterm@6.1.0-beta.287.patch#L200-L215: In _applyPixelOffset, drop the overscanPx lookup/addition entirely and set el.style.transform from offset (pixelOffset + catchUp) alone, letting DomRenderer/WebGL renderer own the static overscan baseline on their own paint containers.
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch#L419-L430: Keep the rowContainer static shift as the single source of truth for overscan alignment (or, alternatively, remove it here and have Viewport own the full -overscanPx + offset shift on screenElement — but not both).

Please pick one owner for this shift and verify visually (e.g. scroll to top/bottom, resize, focus) that rows no longer appear off-by-one after the fix.

📍 Affects 1 file
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch#L200-L215 (this comment)
  • config/patches/@xterm__xterm@6.1.0-beta.287.patch#L419-L430

Remove incomplete Dom/WebGL overscan that blanked new terminals
(double -cellHeight shift; WebGL paint height without model remapping).
Keep host-viewport pixel-smooth scroll + catch-up + clip.

Also expand CompositionHelper merge unit tests for empty/full/partial
overlap, repeated chars, and surrogate pairs (CodeRabbit stablyai#9339).
@gatsby74 gatsby74 changed the title fix(terminal): smooth host-viewport scroll with overscan (builds on #7450) fix(terminal): smooth host-viewport scroll (builds on #7450) Jul 18, 2026
@gatsby74

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit review on 5977850e1 in 6389c31fc:

Finding Resolution
Critical double overscan shift (screenElement + rowContainer) Removed incomplete Dom/WebGL overscan entirely — it blanked new terminals
Major WebGL rows + 2 magic number in GlyphRenderer Moot after overscan removal
Nit Dom renderRows overscan range superset Moot after overscan removal
Major _sendCompositionInput merge untested edge cases Expanded unit tests (empty, full duplicate, prefix/suffix overlap, no overlap, repeated chars, surrogate pairs) — 18 tests green

Host-viewport smooth scroll (_pixelOffset, catch-up, clip, smoothScrollDuration: 120) remains. PR title/body updated to drop the overscan claim.

@gatsby74 gatsby74 changed the title fix(terminal): smooth host-viewport scroll (builds on #7450) fix(terminal): smooth host scroll + subtle TUI glide (builds on #7450) Jul 18, 2026
Add visual-only sub-row paint lag for mouse-reporting TUIs (Claude, Codex,
OpenCode, etc.) on top of host-viewport smooth scroll. Does not change ydisp
or wheel-report distance — only eases the painted screen between discrete
app updates.

- xterm Viewport: nudgeTuiGlide + shared offset RAF with host catch-up
- Setting terminalTuiScrollGlide: off | subtle | medium (default subtle)
- Wire via existing TUI wheel multiplier attach path
@gatsby74

Copy link
Copy Markdown
Contributor Author

Follow-up on the smoothness ladder:

  1. Host smooth scroll — already on this PR
  2. TUI cosmetic glide — landed in d60995403, default Subtle, setting Off/Subtle/Medium under Terminal → Scroll Speed
  3. Live-test Claude fullscreen and tune clamp/settle if needed

Overscan / Jinwoo blank edge: overscan was the fill-glyphs fix for fractional host scroll; it blanked terminals and was removed. Clip remains; full overscan is optional later work, not required for this PR.

@gatsby74 gatsby74 changed the title fix(terminal): smooth host scroll + subtle TUI glide (builds on #7450) fix(terminal): smoother host scroll + subtle TUI paint glide Jul 18, 2026
@gatsby74
gatsby74 marked this pull request as ready for review July 18, 2026 15:25
@AmethystLiang AmethystLiang self-assigned this Jul 18, 2026
@AmethystLiang
AmethystLiang self-requested a review July 18, 2026 16:47
@nwparker nwparker added the bug Something isn't working label Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants