Skip to content

fix(terminal): make viewport ownership transactional across output, replay, and reflow - #8674

Merged
Jinwoo-H merged 10 commits into
mainfrom
Jinwoo-H/native-xterm-scroll-ownership
Jul 16, 2026
Merged

Jinwoo-H merged 10 commits into
mainfrom
Jinwoo-H/native-xterm-scroll-ownership

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #8667 and completes the viewport/replay work that remained incomplete after #8625.

What this fixes

This PR fixes a family of terminal/TUI failures that shared one ownership race:

  • while an agent streamed output, a status or prompt region could appear frozen while newer text scrolled underneath and became hidden;
  • a terminal pinned slightly above the bottom could return at the top of scrollback after switching worktrees;
  • a stale pin could survive typing and prevent expected follow-output behavior;
  • hidden snapshot or reattach replay could leave xterm and the live PTY at different grids, causing stale wrapping, overlap, right gaps, or delayed recovery after focus/click;
  • fit/reflow work could run against source snapshot dimensions or against a replaced PTY generation.

The deterministic worktree-switch reproduction on main could settle at viewportY=0 with a much larger baseY, even though the user had pinned near the bottom.

Root cause

Three systems were making overlapping viewport decisions:

  1. xterm core already atomically owns follow-versus-pinned behavior for ordinary live output through BufferService.isUserScrolling;
  2. Orca independently captured and re-enforced intent around each renderer write batch;
  3. structural operations—snapshot replay, reattach, remount, visibility resume, split reparent, and reflow—changed buffers and dimensions asynchronously.

Snapshot bytes are parsed later by xterm. The old code could clear/rewrite the buffer, call safeFit, forward dimensions/SIGWINCH, and restore scroll intent while xterm still exposed a half-parsed buffer. A live write, fit, or visibility callback could then record that transient state as a durable pin or send the snapshot grid to the live PTY.

Ownership model

The resulting boundary is intentionally narrow:

  • xterm owns ordinary live-output scrolling. Orca no longer captures/enforces viewport intent around every output batch.
  • Orca owns structural transitions that xterm cannot understand. Replay, remount, visibility, split/reflow, and explicit layout changes are serialized and restore intent once from an authoritative state.
  • PTY stream identity stays in pty-connection. Every delayed resize/replay continuation revalidates the exact PTY and stream generation before touching transport state.

This is the mergeable nucleus of a future pane-scoped viewport controller without attempting a risky wholesale terminal rewrite in this PR.

Main changes

1. Native xterm ownership for live output

  • Removes per-write-batch scroll-intent capture/enforcement from pane-terminal-output-scheduler.ts.
  • Keeps renderer intent only for structural transitions.
  • Adds xterm-user-scrolling-contract.test.ts, pinned to the installed xterm version, covering:
    • follow-at-bottom during output;
    • user-pinned viewport hold;
    • content-stable movement during scrollback trim;
    • xterm's own user-scrolling flag management.

An xterm upgrade that changes this contract will now fail CI instead of silently changing terminal behavior.

2. Transactional structural replay

Hidden snapshot restore and reattach now run through a pane-scoped structural replay coordinator:

  1. capture the pre-transition structural intent;
  2. cancel stale deferred scroll restores;
  3. mark the buffer rebuild in flight;
  4. replay at the source snapshot grid without forwarding that temporary grid to the PTY;
  5. wait for FIFO-ordered xterm write completion so all replay bytes have parsed;
  6. end the rebuild and restore intent once by bottom offset;
  7. wait for a measurable destination fit;
  8. forward only the authoritative destination grid, then release deferred live bytes.

Later replay operations cannot overtake the post-replay fit. A temporarily unmeasurable pane retains a keyed, awaitable fit continuation; any later successful normal fit completes it. Replacement, PTY/stream generation changes, and pane/binding disposal explicitly cancel it.

This prevents both failure modes:

  • forwarding stale snapshot dimensions to local PTYs, SSH sessions, or ConPTY;
  • dropping the resize entirely and allowing live bytes to parse while xterm still uses the source grid.

3. Input-driven intent healing

  • Adds an onData-based intent resync so real typing heals a stale pin and resumes native follow behavior.
  • Defers the resync until queued writes settle, preventing typing from racing a pending output batch.
  • Skips mouse-report input so wheel/mouse tracking sequences sent to a TUI do not masquerade as keyboard intent.
  • Preserves the existing keyboard/wheel settle logic for sub-row wheel deltas and keys consumed by the application.

4. Replay-aware fit, reflow, and lifecycle handling

  • Centralizes safe fit and authoritative post-fit continuations.
  • Defers geometry mutation while a structural rebuild is in flight.
  • Preserves pinned content through reflow with markers and exact successor-generation checks.
  • Covers stable ResizeObserver fits, appearance/font changes, WebGL fallback, visibility resume, PTY-size reassertion, split/reparent, remount, and web-client resize forwarding.
  • Keeps deferred geometry work cancellable through its post-replay microtask.
  • Exception-isolates optional rebuild listeners so one observer cannot strand replay cleanup or viewport restoration.

Platform and transport behavior

  • macOS/Linux local PTYs: forward the fitted grid and use guarded SIGWINCH only after replay settles.
  • Windows/ConPTY: uses the same authoritative grid ordering; no POSIX-only assumption is used to decide fit ownership.
  • SSH: delayed work validates the current PTY/stream before resize or signal; transient renderer-dimension failures are retained until fit or cancellation.
  • Remote runtime: receives the authoritative transport resize while skipping local explicit SIGWINCH behavior.
  • Mobile-owned PTYs: desktop fit/resize remains suppressed when a phone-grid override or driver lock owns the PTY.

Regression coverage

New and expanded tests cover:

  • native xterm pin/follow/trim semantics;
  • hidden snapshot and reattach replay ordering;
  • source-grid replay followed by destination-grid convergence;
  • unavailable-then-recovered destination fitting;
  • live-byte deferral until authoritative fit;
  • PTY/stream replacement and disposal cancellation;
  • stale generation and successor-remount protection;
  • pinned growth across remount/reflow;
  • throwing rebuild observers;
  • typing resync versus mouse-report input;
  • local, SSH/remote-runtime, and ConPTY-sensitive resize branches;
  • deterministic pinned streaming across a worktree switch.

Verification

Run after merging the latest origin/main into this branch:

  • pnpm run typecheck — passed;
  • terminal-pane + pane-manager unit matrix — 215 files passed, 2,672 tests passed, 1 skipped;
  • pnpm exec electron-vite build --mode e2e — passed;
  • deterministic Electron E2Es — 5/5 passed:
    • pinned pane returning during an active stream does not land at the top;
    • sub-row wheel-up does not create a phantom pin;
    • plain Home delivered to the app does not steal follow mode;
    • a real wheel pin stays fixed while output streams;
    • typing after a queued pinned write resumes follow-output;
  • touched-file lint and git diff --check — passed;
  • repeated adversarial review — no remaining correctness blocker.

The real fullscreen Codex/setup-split diagnostic was also exercised with WebGL enabled, a 3.5-second post-ready wait, human-paced typing, and no click before evidence. xterm, proposed fit, and applied PTY size all measured 65x63; native isUserScrolling was false; the remaining 11px pane remainder was below one cell and expected. Before/after-click screenshots were byte-identical, confirming focus was not masking a stale-grid artifact.

Review notes

This became larger than the original three-area patch because adversarial review found the same invariant was bypassed by appearance, visibility, WebGL, PTY-size reassertion, remount, and reattach paths. Those paths now use the same replay-aware fit/continuation boundary instead of accumulating independent fixes.

The final architecture deliberately avoids making Orca a second live-output scroll engine. xterm owns the hot path; Orca coordinates only structural state changes.

Made with Orca 🐋

Jinwoo-H and others added 3 commits July 14, 2026 00:45
…ing snapshot restores

Returning to a worktree whose agent kept streaming (Codex "Working…")
replays a main-buffer snapshot: the buffer is cleared and rewritten, but
xterm parses those bytes asynchronously. Scroll-intent capture/enforce that
raced the parse — live stream batches, safeFit, visibility resume, and the
restore's own synchronous enforcement — read the half-cleared buffer and
re-latched the intent as "pinned at line 0", parking the returned pane at
the very top of the scrollback (#8625 does not cover this).

- Bracket the snapshot replay with a per-terminal rebuild marker
  (terminal-scroll-intent-rebuild.ts); while it is in flight, intent
  capture returns null and enforcement no-ops, so no racer can latch
  transient coordinates.
- Re-apply the pre-replay intent once, in a FIFO-ordered empty-write
  completion after the replay bytes have parsed (bottom-offset restore),
  with a timeout fallback so a wedged pipeline cannot suppress intent
  forever.
- Defense-in-depth for unbracketed rebuilds: enforcement no longer
  re-latches a pinned intent from a scrollback shorter than the stored
  pin's, and capture only normalizes pinned-at-bottom to follow when the
  scrollback is at least as long as the pin's.
- Split the DOM wheel/pointer tracking into
  terminal-scroll-intent-dom-tracking.ts (max-lines).

Deterministic e2e repro: a synchronized-output streaming fixture, pinned
six rows above bottom, worktree switch away and back — settled at
viewportY=0 (baseY=284) before this fix, restores near the pin after.

Co-authored-by: Orca <help@stably.ai>
Delete the per-write-batch scroll-intent capture/enforce from the output
scheduler. xterm core already owns follow-vs-pinned for live output —
BufferService.isUserScrolling flips only on real viewport movement and is
consumed atomically inside the append (scroll() snaps ydisp to ybase when
following, holds it when pinned, and walks a pinned viewport down
content-stably when scrollback trims). The app-side per-batch enforcement
duplicated that decision non-atomically and was the race surface behind the
pinned-at-bottom freeze and the streaming top-jump.

App-side intent enforcement is now scoped to structural operations xterm
cannot know about: snapshot replay (rebuild bracket + post-parse
bottom-offset restore), remount/visibility resume, and fit reflow (marker
restore). Intent classification (wheel/keyboard settle, phantom-pin
normalization) is unchanged — stored intent feeds those structural restores.

A pinned-version contract test (@xterm/headless shares BufferService with
@xterm/xterm) locks the native semantics the write path now relies on, so
an emulator upgrade that changes them fails CI instead of silently breaking
follow behavior.

Verified: full pane-manager/terminal-pane suites (2508), the three
deterministic scroll e2e specs (follow-during-stream, static pinned
worktree switch, streaming pinned worktree switch), TUI wheel report/drain
and long-table scroll-restore e2e, scheduler throughput bench, typecheck.

Co-authored-by: Orca <help@stably.ai>
With per-batch enforcement gone, typing (which xterm scrolls to the bottom
via scrollOnUserInput) no longer had a write-path side effect that healed a
stored pin — the stale pin would be re-applied by the next workspace-switch
restore, yanking the user back to an old reading position. Subscribe to
core's onUserInput (real input only, never parser auto-replies) and resync
intent from the settled viewport; a pin whose viewport did not move (mouse
reports, app-consumed keys) is preserved.

Co-authored-by: Orca <help@stably.ai>
@coderabbitai

coderabbitai Bot commented Jul 14, 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: 1bbf8a3b-f5c1-4c53-9a12-02909263cae3

📥 Commits

Reviewing files that changed from the base of the PR and between 5a58baf and 4d6f17e.

📒 Files selected for processing (1)
  • src/main/ipc/pty.test.ts

📝 Walkthrough

Walkthrough

The changes separate DOM-based scroll-intent tracking from intent state management, add rebuild-in-flight coordination, and guard capture and enforcement during snapshot buffer reconstruction. Terminal output scheduling now relies on xterm’s native viewport behavior instead of enforcing intent per write. Snapshot restoration defers intent enforcement until replay parsing completes, with a timeout fallback. Unit and end-to-end tests cover xterm scrolling, rebuild races, and viewport restoration during worktree switching.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is thorough, but it does not match the required template and omits several mandatory sections. Reformat it to the template and add Summary, Screenshots, checklist-style Testing, AI Review Report, Security Audit, and Notes, including explicit cross-platform coverage.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main terminal viewport ownership change.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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: 1

🧹 Nitpick comments (1)
tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts (1)

22-49: 🩺 Stability & Availability | 🔵 Trivial

Fixture spinner runs ~40s of real background PTY activity beyond what the assertions need.

The 800-tick × 50ms loop keeps the fixture streaming well past the test's actual observation window (pin → switch away 3s → switch back → settle). Cleanup only removes the script file (Line 203); the spawned Node process itself isn't explicitly terminated, so it likely keeps running in the PTY until the app/session teardown reaps it. Worth confirming the harness reliably kills PTY child processes on worktree/app teardown between e2e specs, and consider trimming tick count/duration to reduce the window where a stray process could interfere with subsequent tests in CI.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c582e23a-51a6-4eed-a02e-232abd146ea3

📥 Commits

Reviewing files that changed from the base of the PR and between 0ab2e00 and 7333780.

📒 Files selected for processing (9)
  • src/renderer/src/components/terminal-pane/pty-connection.ts
  • src/renderer/src/lib/pane-manager/pane-lifecycle.ts
  • src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
  • src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts
  • src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts
  • src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts
  • src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts
  • src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts
  • tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts

Jinwoo-H and others added 6 commits July 14, 2026 01:48
Mouse-tracking apps stream mouse reports as user input at pointer
frequency, and xterm's scroll-on-user-input jumps a scrolled-up viewport to
the bottom on each one (pre-existing native behavior). Resyncing intent
from that jump would convert a reading pin into follow-output permanently —
a mouse-move over a mouse-tracking normal-buffer app would drop the
position a structural restore could otherwise bring back.

Subscribe through the public onData instead of the private core user-input
signal and skip mouse-report-shaped input (SGR and X10). Parser auto-replies
that also flow through onData never move the viewport, so their resync
settles as a no-op.

Co-authored-by: Orca <help@stably.ai>
…-scroll-ownership

# Conflicts:
#	src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
Co-authored-by: Orca <help@stably.ai>
@Jinwoo-H Jinwoo-H changed the title fix(terminal): streaming snapshot-restore top-jump + native xterm scroll ownership fix(terminal): make viewport ownership transactional across output, replay, and reflow Jul 15, 2026
…-scroll-ownership

# Conflicts:
#	src/renderer/src/components/terminal-pane/pty-connection.ts
@Jinwoo-H
Jinwoo-H merged commit 69c14fa into main Jul 16, 2026
4 checks passed
@Jinwoo-H
Jinwoo-H deleted the Jinwoo-H/native-xterm-scroll-ownership branch July 16, 2026 03:28
enrick-dev pushed a commit to enrick-dev/orca that referenced this pull request Oct 15, 2026
…eplay, and reflow (stablyai#8674)

Co-authored-by: Orca <help@stably.ai>
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.

1 participant