Skip to content

fix(terminal): converge revealed inline-TUI panes without a manual resize - #9148

Closed
Jinwoo-H wants to merge 3 commits into
mainfrom
Jinwoo-H/fix-terminal-rendering-regressions
Closed

Jinwoo-H wants to merge 3 commits into
mainfrom
Jinwoo-H/fix-terminal-rendering-regressions

Conversation

@Jinwoo-H

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

Copy link
Copy Markdown
Contributor

Field bug

On v1.4.144-rc.4, switching to a workspace whose Codex TUI kept streaming while hidden periodically shows:

  • a mostly-blank terminal — viewport stranded mid-buffer (scrollbar mid-track), input box missing, sparse scrollback; or
  • a bottom gap — Codex draws its input box rows above the true bottom (PTY rows ≠ pane rows).

Both persist until a manual resize (e.g. Cmd+L sidebar toggle) forces SIGWINCH and Codex repaints. Same family as #7951.

Since parking landed, a workspace hidden >30s beyond the hot-retain budget fully unmounts its terminal and rebuilds from a daemon snapshot on reveal — a path none of the prior fixes (#7614/#8625/#8674) protect. Inline-mode TUIs are uniquely exposed because they keep appending across the reveal, so the snapshot's bottom is never the live bottom (alt-screen fixtures in existing specs can't catch this).

Three independent losing legs, each fixed

  1. Stranded corrective fit (pane-fit.ts) — the reattach/hidden-snapshot replay parks its PTY-resize continuation behind "the next successful fit". When the pane is momentarily unmeasurable mid-reveal, no later fit is guaranteed (the container size may already be final, so no further ResizeObserver event ever fires) and xterm stays at snapshot dims with no forwarded resize/SIGWINCH → the bottom-gap state. safeFitAndThen now actively retries on settled frames (bounded at 40 attempts) and records a terminal_safe_fit_retry_exhausted breadcrumb if it ever gives up, so a residual field case names itself.

  2. Swallowed replay paint (pane-terminal-foreground-render-settle.ts) — the cold-park remount path has no reveal-repaint, so a replay parsed while xterm's RenderService is paused (its IntersectionObserver lags the reveal by a frame, worse under load) never painted: buffer perfect, canvas blank until drag-select/resize. The settle refresh now drives one synchronous full render through the pause latch (extends the fix(terminal): repaint revealed panes stuck behind xterm's paused-render gate #7614 release, which only covered hidden-but-mounted reveals, to every foreground/replay refresh).

  3. Viewport stranded at the snapshot bottom (pty-connection.ts) — the reveal's bottom anchor is a one-shot that runs before the live chunks deferred during reattach get parsed. The restored scroll intent is now re-enforced after the deferred drain parses: following panes re-anchor to the live bottom; a deliberate scroll-up pin is preserved (enforceTerminalCurrentScrollIntent semantics). Deferred data is capped at 512KB, bounding the extra parse cost.

Repro / regression harness

New tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts + tests/e2e/fixtures/codex-inline-live-block-fixture.cjs: a codex-shaped inline TUI (agent-classified tab via launch telemetry, scrollback inserts + bottom live-block redraws, synchronized output, mouse reporting, startup queries, heartbeat file) driven through six reveal shapes — hidden-mounted, worktree switch after a hidden-time window resize, parked reveal ×6 cycles under 6× CPU throttle with 8k-line scrollback, rapid tab flapping, rapid worktree flapping, and parked reveal after a parked-time resize. Each requires convergence with no resize: viewport at buffer bottom, a fresh frame + input-box row on screen, still following afterwards, xterm grid == fit proposal == PTY-applied size, and painted-pixel ink in the live-block band (WebGL forced) so paint-layer bugs can't hide behind a healthy buffer.

The exact field interleavings are sub-frame races that even the escalated harness could not force on demand, so the product changes are repairs of statically verified latches — legs 1 and 2 each carry a deterministic unit repro that is red without the fix (pane-fit.test.ts, pane-terminal-foreground-render-settle.test.ts) — and the e2e suite is the standing regression net for the symptom family.

Verification

  • New unit repros red→green; full pty-connection suite (446), scroll-intent, pane-tree-ops, fit-observer, write-callback-guard, render-pause-release, reveal-repaint suites green.
  • Output-scheduler unit + throughput bench green (the settle change sits on the per-batch write path).
  • E2E green against the rebuilt bundle: hidden-view-parking (incl. 25-cycle byte-for-byte reveal), the new 6-scenario convergence suite, paused-render repro, tab-switch SIGWINCH restore, hidden-TUI visual restore, tab-switch visual restore. (One pre-existing cross-file order flake in "restores skipped hidden Grok output on light tab resume" — passes solo and on full-file rerun.)
  • pnpm typecheck and oxlint clean.

Behavior notes / trade-offs

  • All three changes act only inside failure windows that previously ended in "broken until manual resize"; steady-state rendering/scroll/throughput paths are untouched.
  • In the previously-broken cases the heal arrives as one quick TUI redraw shortly after the switch (the SIGWINCH that should have fired at reveal).
  • Deliberately not a continuous enforcement watchdog — per-batch enforce loops are the pattern that caused earlier scroll-freeze regressions; these are one-shot recoveries tied to reveal events.

Known follow-ups (out of scope)

  • The flood-abandon path writes pending live chunks back un-seq-sliced (pty-connection.ts abandon), which can duplicate visible lines under sustained floods — needs its own fuzz-mirror repro.
  • One organic catch during harness development: an agent-marked visible pane froze ~1s post-spawn (possible spawn-time hidden-declare unhide race); never reproduced again — the new breadcrumb will surface it if it recurs in the field.

Made with Orca 🐋

…size

Field bug (v1.4.144-rc.4): switching to a workspace with a streaming
inline TUI (Codex) periodically shows a mostly-blank pane (viewport
stranded mid-buffer, input box missing) or a bottom gap (PTY rows !=
pane rows) until a manual resize forces SIGWINCH.

Three independent losing legs on the reveal path, each fixed:

1. Stranded corrective fit: the reattach/hidden-snapshot replay parks
   its PTY-resize continuation behind "the next successful fit"; when
   the pane is momentarily unmeasurable mid-reveal, no later fit is
   guaranteed (container size may already be final), leaving xterm at
   snapshot dims with no forwarded resize. safeFitAndThen now actively
   retries on settled frames (bounded) and breadcrumbs on exhaustion.

2. Swallowed replay paint: the cold-park remount path has no reveal
   repaint, so a replay parsed while xterm's RenderService is paused
   (IntersectionObserver lags the reveal) never painted. The settle
   refresh now drives one synchronous render through the pause latch
   (extends the #7614 release to the remount path).

3. Viewport stranded at the snapshot bottom: the reveal's bottom anchor
   is a one-shot that runs before deferred live chunks parse; an inline
   TUI keeps appending across the reveal. Re-enforce the restored scroll
   intent after the deferred drain parses (pins preserved).

New regression harness: tests/e2e/terminal-inline-tui-reveal-convergence
drives a codex-shaped inline fixture (agent-marked tab, scrollback
inserts + bottom live block, synchronized output, mouse reporting)
through hidden/parked/flapping/resize reveals under CPU throttle and
asserts convergence with no resize: viewport at bottom, fresh frame +
input row visible, still following, xterm == fit proposal == applied
PTY size, and painted-pixel ink in the live-block band (WebGL forced).

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

coderabbitai Bot commented Jul 17, 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: 25db2fd4-6456-4ad2-bbae-bf5abdfa426c

📥 Commits

Reviewing files that changed from the base of the PR and between 778fae2 and cd76d07.

📒 Files selected for processing (2)
  • src/renderer/src/components/terminal-pane/pty-connection.test.ts
  • src/renderer/src/components/terminal-pane/pty-connection.ts

📝 Walkthrough

Walkthrough

Updates terminal reattach handling to reapply scroll intent after deferred output parsing, preserve hidden-output replay continuity, and reassert PTY size. Adds bounded safe-fit retries with cancellation, stale-continuation handling, and exhaustion breadcrumbs, plus render-pause refresh gating and unit tests. Introduces an inline TUI fixture and end-to-end scenarios covering hidden-pane reveals, resizing, repeated flapping, CPU throttling, geometry alignment, live output, and painted pixels.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required template sections for Summary, Screenshots, Testing, AI Review Report, Security Audit, or Notes. Reformat the PR description to match the template and add the missing Screenshots, Testing checklist, AI Review Report, Security Audit, and Notes sections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: revealed inline-TUI panes now converge without a manual resize.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: dda4ae23-73ab-4004-8c42-fe85935a2514

📥 Commits

Reviewing files that changed from the base of the PR and between d0b090b and 18f99f9.

📒 Files selected for processing (7)
  • src/renderer/src/components/terminal-pane/pty-connection.ts
  • src/renderer/src/lib/pane-manager/pane-fit.test.ts
  • src/renderer/src/lib/pane-manager/pane-fit.ts
  • src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts
  • src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts
  • tests/e2e/fixtures/codex-inline-live-block-fixture.cjs
  • tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts

Comment on lines +112 to +116
// Swallow query replies / mouse reports / keys like a real TUI agent.
process.stdin.resume()
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make fixture shutdown explicit. Raw mode prevents \x03 from generating SIGINT, so the current cleanup leaves each infinite streaming fixture alive.

  • tests/e2e/fixtures/codex-inline-live-block-fixture.cjs#L112-L116: handle the shutdown byte explicitly or provide another deterministic termination mechanism.
  • tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts#L353-L356: invoke that termination mechanism and wait for shutdown before the next serial test begins.
📍 Affects 2 files
  • tests/e2e/fixtures/codex-inline-live-block-fixture.cjs#L112-L116 (this comment)
  • tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts#L353-L356

Comment on lines +443 to +448
if (probe!.appliedPtySize) {
expect(
probe!.appliedPtySize,
`${label}: PTY applied size diverges: ${describeProbe(probe)}`
).toEqual({ cols: probe!.cols, rows: probe!.rows })
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Require the PTY-applied size instead of silently skipping it.

For this local fixture, getSize should be available. Treating null as a pass can miss the stale-PTY-grid regression this suite explicitly targets.

Proposed fix
-  if (probe!.appliedPtySize) {
-    expect(
-      probe!.appliedPtySize,
-      `${label}: PTY applied size diverges: ${describeProbe(probe)}`
-    ).toEqual({ cols: probe!.cols, rows: probe!.rows })
-  }
+  expect(
+    probe!.appliedPtySize,
+    `${label}: PTY applied size unavailable: ${describeProbe(probe)}`
+  ).not.toBeNull()
+  expect(
+    probe!.appliedPtySize,
+    `${label}: PTY applied size diverges: ${describeProbe(probe)}`
+  ).toEqual({ cols: probe!.cols, rows: probe!.rows })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (probe!.appliedPtySize) {
expect(
probe!.appliedPtySize,
`${label}: PTY applied size diverges: ${describeProbe(probe)}`
).toEqual({ cols: probe!.cols, rows: probe!.rows })
}
expect(
probe!.appliedPtySize,
`${label}: PTY applied size unavailable: ${describeProbe(probe)}`
).not.toBeNull()
expect(
probe!.appliedPtySize,
`${label}: PTY applied size diverges: ${describeProbe(probe)}`
).toEqual({ cols: probe!.cols, rows: probe!.rows })

Comment on lines +534 to +540
const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
expect(tabBId).not.toBe(setup.tabId)
await orcaPage.waitForTimeout(Math.min(PARKING_DELAY_MS / 2, 2_000) + 1_000)

await activateTerminalTab(orcaPage, setup.tabId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await assertRevealConvergence(orcaPage, testInfo, setup, 'hidden-mounted-reveal')

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep this reveal inside the mounted-tab parking window.

With the default 500 ms delay, Line 536 waits 1,250 ms, so tab A can cold-park before activation. That makes this test duplicate the parked-remount path instead of covering hidden-but-mounted restoration.

Remove the delay, assert that window.__paneManagers still contains setup.tabId, and increase the configured parking delay if tab creation itself can exceed the window.

…rain flush

Review hardening on the reveal-convergence fixes:

1. transport.resize is fire-and-forget on daemon/SSH; a silently dropped
   resize leaves the PTY at stale rows while xterm is correct — the
   "input box floats above the pane bottom, walking down as output
   accumulates" field variant (#7951 class). The remount path has no
   visibility-resume reassert, so fitAfterReattachRestore now verifies
   the provider's APPLIED size after the reattach fit and re-forwards
   only on drift (one getSize IPC per parked reveal, zero steady-state).

2. The post-drain scroll-intent enforce used an unbounded synchronous
   flush; a flood pane's queue could exceed the deferral budget at
   reveal. Now bounded to MAX_DEFERRED_REATTACH_LIVE_CHARS with a FIFO
   parse probe — enforcing after a partial drain stays correct (follow
   anchors at the bottom and native follow tracks the remainder).

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

Copy link
Copy Markdown
Contributor Author

Hardening commit 778fae26b (from adversarial self-review + a field observation):

  1. Applied-size verification after reattach. Field observation: the bottom-gap variant gradually self-heals as the agent emits more output, walking content down to the true bottom. That is only possible when xterm is correctly sized and the PTY is the stale side — i.e. the reattach's fire-and-forget transport.resize was silently dropped daemon-side ([Bug] Terminal UI breakage on switching #7951 class). The remount path had no corrector (noteVisibilityResume's reassert only fires for hidden-mounted resumes). fitAfterReattachRestore now runs the applied-size reassert after the fit: one pty:getSize IPC per parked reveal, re-forwards only on drift/unavailable readback, zero steady-state cost.

  2. Bounded drain flush. The post-drain scroll-intent enforce used waitForTerminalOutputParsed, whose unbounded synchronous flush could parse a flood pane's whole queue at reveal. Now bounded to the 512KB deferral budget + FIFO parse probe; enforcing after a partial drain stays correct (follow anchors at bottom, native follow tracks the remainder).

Re-verified: full pty-connection suite (446, incl. an updated reattach test that now pins the guarded reassert), typecheck, oxlint, and the parking + convergence e2e suites (10/10) against a rebuilt bundle.

The 750ms foreground deadline can abandon a hidden-output restore while
a RESOLVED snapshot's replay is still parsing. Those replay writes are
already queued in xterm's FIFO and WILL paint everything at or before
the snapshot seq, but the abandon (a) rewrote its parked live chunks
raw — duplicating every line the replay covers (field: TUI lines
stacked twice on reveal) — and (b) skipped baseline adoption, so main's
ACK-backlog re-deliveries below the seq duplicated again.

Track the in-flight replay snapshot from the moment its writes are
queued; on abandon, seq-slice the pending write-through against it
(raw fallback only for unmappable offsets — availability still wins)
and adopt it as the live reconcile baseline with the drain's continuity
advance. Abandons with no started replay keep the existing raw path.

Deterministic repro: pty-connection.test.ts stages snapshot-resolve →
replay parse held → deadline abandon, asserting no rewrite of covered
chunks, dedup of post-abandon backlog, and write-through of new bytes.

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

Copy link
Copy Markdown
Contributor Author

Commit cd76d0797 — duplication fix (reporter confirmed seeing TUI lines duplicated with the line below):

Root cause: the 750ms foreground deadline can abandon a hidden-output restore while a resolved snapshot's replay is still parsing. The replay writes are already in xterm's FIFO and will paint everything ≤ snapshot seq, but the abandon (a) rewrote its parked live chunks raw — duplicating every replay-covered line — and (b) skipped setRestoredSnapshotBaseline, so main's ACK-backlog re-deliveries below the seq duplicated a second time.

Fix: track the in-flight replay snapshot from the moment its writes are queued; on abandon, seq-slice the pending write-through against it (raw fallback only for unmappable offsets) and adopt it as the live reconcile baseline with the drain's continuity advance. Abandons where no replay started keep the existing raw write-through (correct there — nothing painted).

Repro-first: new deterministic unit test stages snapshot-resolve → replay-parse-held → deadline-abandon; red before the fix at the exact duplication assertion, green after. Full battery re-verified: 447 pty-connection tests, seq-slicing fuzz suite, typecheck/lint, and 15 e2e (parking, convergence, visual-restore suites) against a rebuilt bundle.

Note: a visually identical symptom also arises when Codex's erase misses the old live-block rows under a PTY/xterm row mismatch — that trigger is closed by this PR's size-convergence fixes, so both duplication sources are addressed here.

@OrcaWin

OrcaWin commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Superseded by #9503, which carries the hardened version of this fix on current main and has now merged. Closing this older PR to keep the review path unambiguous.

@OrcaWin OrcaWin closed this Jul 20, 2026
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.

2 participants