Skip to content

fix(terminal): clear the SGR pen on hidden-output restore and abandon - #14241

Open
brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/terminal-sgr-pen-reset-on-restore
Open

fix(terminal): clear the SGR pen on hidden-output restore and abandon#14241
brennanb2025 wants to merge 5 commits into
mainfrom
brennanb2025/terminal-sgr-pen-reset-on-restore

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

ELI5

When you switch away from a terminal, Orca stops sending it output to save work. If it stops mid-way through a piece of styled text — after "start bold" but before "stop bold" — the terminal is left holding the bold pen. When you come back, neither recovery path put the pen down, so everything painted afterwards came out bold. This puts the pen down.

What Changed

Added a RESET_AFTER_BYTE_GAP profile (cancel incomplete escape + close OSC 8 + SGR reset + all charset registers back to ASCII) and emitted it on the two paths that recover from dropped bytes:

  1. buildMainModelSnapshotReplayWrites — the two alt-screen branches already reset the pen; the normal-buffer branch did not, and the scrollback branch replayed scrollbackAnsi ahead of the \x1b[0m it did emit. Both now clear the pen before any replayed content.
  2. abandonHiddenOutputRestoreAndDrainPendingForeground — this path declares the dropped bytes unrecoverable (it writes a user-visible "recovery was unavailable" warning) and then drained the queued foreground chunks into xterm under that same unknown pen. Now clears the full byte-gap state before the warning, drained foreground data, and both exits, including pending-overflow; quiet and remote-rearm paths reset without showing a warning.

Every existing profile in terminal-mode-reset-profiles.ts clears DEC mode bits (cursor style, kitty keyboard, mouse reporting, bracketed paste). None touched SGR or charset — neither was ever in scope for a recovery path.

The complete profile is included because a gap strands more than the pen: a dropped partial escape, OSC 8 terminator, or charset designation can leave the parser, hyperlink, or character-set state latched. It restores every ISO 2022 register to ASCII and selects G0. It is deliberately not a soft reset (DECSTR) — xterm's DECSTR wipes kitty flags and stacks (see terminal-kitty-keyboard-mode-tracker applySoftReset), which would silence Option chords for a live agent that negotiates them only at startup. So: reset what a gap strands and no running TUI re-asserts on its own, and leave the rest to its next repaint.

Why

src/main/ipc/pty-hidden-delivery-gate.ts drops renderer-bound PTY bytes while a pane has no visible view. The renderer's xterm is a separate emulator from the daemon model, which is why a pane can render wrong while the daemon's serialized buffer reads clean — I verified that against a real occurrence (checkpoint.json: 6.1% bold, all runs terminated) and against the raw PTY log (246 bold runs, zero unterminated).

If the dropped span contains the sequence closing an attribute run, the renderer's pen stays latched. A recovery path that knows bytes were lost cannot assume the terminal's attribute state is still coherent.

Linked Issue

Related: STA-4042 / STA-4060 (bold and regular text becoming visually identical in terminal panes).

Deliberately not marked as fixing them. The layer is now confirmed against a live occurrence (below), but I have not proven that this specific abandon/replay path is the one users hit.

Visual Proof

N/A for a static screenshot — the defect is a transient render state. Behavioural before/after is the A/B below, measured from the renderer's buffer rather than pixels.

Reliability Contract

  • Invariant: terminal-output.scrollback-restore — after a renderer byte gap, restore, abandon, overflow, warning, retry, and remote re-arm paths establish a known parser/hyperlink/SGR/charset state before replayed or foreground text.
  • Oracle: deterministic ordering tests plus headless xterm cell-state assertions; rendered Electron QA verifies real cells after a natural hide → drop → reveal cycle.
  • Coverage: local/daemon natural restore rendered; remote re-arm and abandon variants covered deterministically. No mobile-facing surface or wire contract touched.
  • Performance budget: 25 recovery-only bytes per emission; no polling, timers, allocations, scans, or subprocesses added.
  • Residual gaps: rendered alt-screen, forced overflow/deadline abandon, and rendered remote re-arm were not exercised; their lower-layer branches are covered by the focused suite.

Testing

Live Electron A/B, isolated profile, 50 000-line scrollback, probing cell.isBold() on plain text written with no SGR codes at all after a hide → drop → reveal cycle:

variant before after
deep-natural (plain reveal, no forced hold) 8/8 cells bold 0/8
hold-overflow (overflow abandon after held snapshot) 8/8 cells bold 0/8
hold-deadline 0/8 0/8

Each fix closes a different arm: the replay fix closes deep-natural, the abandon fix closes hold-overflow. Neither alone closes both.

Automated: 594 tests pass across pty-connection.test.ts and terminal-snapshot-replay-paint.test.ts, including abandon/warning/remote-rearm ordering, replay-branch invariants, and headless xterm cell-state coverage. The new abandon test was verified non-vacuous — removing the write makes it fail. Several existing tests pinned the exact replay write arrays and were updated; the reattach/SSH replay assertions were deliberately left alone since that clear comes from a different code path.

pnpm typecheck, oxlint, oxfmt --check all clean.

Field capture, live occurrence. A pane was caught mid-symptom in a fresh session. Its raw PTY log — the daemon's record of the full byte stream — was clean: 9 SGR bold-on events, 31 resets, longest bold run 13 chars, zero unterminated, and none of the bolded spans corresponded to the affected text. The affected text was the user's own typed input line, showing bold on sub-word fragments (shoud+l, spac+ed, al+w+ays) — the signature of per-cell corruption applied as the TUI incrementally repaints. So the byte stream says that text is not bold and the screen rendered it bold: the divergence is renderer-side, on a real occurrence, which is the layer this PR addresses. It then healed on a hide/reveal — the restore path succeeding, which is the same path hardened here.

What I did not verify: that this is the cause of the field reports. The repro arms above are normal-buffer shells; the field reports are on alt-screen TUIs (Claude Code), whose branch already emitted a reset — for those, only the abandon path and the scrollback-ordering fix apply. I never observed abandonWarning=true on a natural (unforced) snapshot, so the field attribution remains unproven. Treat this as a real correctness fix on the recovery paths, not a confirmed closure of STA-4042.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Review

Ensure no issues in: Security, Cross-platform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance

  • Cross-platform / SSH / mobile: renderer-only terminal control bytes, identical across macOS, Linux, Windows, local, daemon, SSH, and remote-runtime transports. Remote hidden-output re-arm now shares the same reset invariant; SSH reattach remains on its existing replay path. No RPC, wire, persisted-state, or mobile-client contract changed.
  • Backwards compatibility: \x1b[0m prepended to writes that already cleared the screen in the same chunk. A live TUI re-arms its own attributes on its next write; a shell inherits a defined pen instead of an undefined one.
  • Performance: 25 bytes per recovery emission (50 bytes for the split normal+alternate replay), only when recovering a dropped-byte gap; no polling, timers, allocation, or scans added.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

The hidden-delivery gate drops renderer-bound PTY bytes while a pane has no
visible view. The renderer's xterm is a separate emulator from the daemon
model, so when the dropped span contains the sequence closing an attribute run
(e.g. the ESC[22m ending a bold run) the renderer's pen stays latched while the
daemon model stays correct. Neither recovery path cleared it:

- buildMainModelSnapshotReplayWrites reset the pen on the two alt-screen
  branches but not on the normal-buffer branch, and replayed scrollbackAnsi
  ahead of the reset it did emit, so replayed content inherited the stale pen.
- abandonHiddenOutputRestoreAndDrainPendingForeground declares the dropped
  bytes unrecoverable (it writes a user-visible warning) and then drained the
  queued foreground chunks straight into xterm under that same unknown pen.

Add RESET_GRAPHIC_RENDITION and emit it ahead of replayed content in every
branch, and on both abandon exits. The existing profiles all clear DEC mode
bits and none touched SGR.
@coderabbitai

coderabbitai Bot commented Aug 13, 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 Plus

Run ID: 6219ec75-988e-4bcc-991f-907084647ad7

📥 Commits

Reviewing files that changed from the base of the PR and between ed50c81 and 952234a.

📒 Files selected for processing (4)
  • src/renderer/src/components/terminal-pane/pty-connection.test.ts
  • src/renderer/src/components/terminal-pane/pty-connection.ts
  • src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts
  • src/shared/terminal-mode-reset-profiles.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/renderer/src/components/terminal-pane/pty-connection.test.ts

📝 Walkthrough

Walkthrough

The change adds RESET_GRAPHIC_RENDITION and the combined RESET_AFTER_BYTE_GAP sequence. The sequence restores SGR, hyperlink, incomplete-escape, and ASCII character-set state. Snapshot replay writes it before buffer reconstruction and alternate-screen transitions. PTY recovery writes it after dropped output and during hidden-output restoration. Tests verify reset ordering before warnings, replay operations, and subsequent terminal data.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the terminal recovery change and covers the hidden-output restore and abandon paths.
Description check ✅ Passed The description covers the required change, rationale, issue references, visual proof, testing, review considerations, and checklist.
✨ 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.

A gap can strand more than the pen: a dropped `ESC(B` leaves line-drawing
selected and ordinary text renders as box characters. Route both recovery
paths through one RESET_AFTER_BYTE_GAP profile covering SGR + charset.

Deliberately not a soft reset (DECSTR): xterm's DECSTR wipes kitty flags and
stacks (terminal-kitty-keyboard-mode-tracker applySoftReset), which would
silence Option chords for a live agent that negotiates them only at startup.
Reset what a gap strands and no running TUI re-asserts on its own; leave the
rest to its next repaint.
The restore-needed marker is the single point where "renderer-bound bytes
were dropped" is known. The handler already resets the transport's
cross-chunk parser state there for exactly this reason — a partial escape
spanning the gap would corrupt the next chunk. The emulator carries state
across chunks in the same way, so reset it in the same place.

That makes restore, abandon and overflow all start from a known pen by
construction, instead of each recovery path having to remember.

@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

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

⚠️ Outside diff range comments (1)
src/renderer/src/components/terminal-pane/pty-connection.ts (1)

7063-7078: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset before writing the recovery warning.

Line 7068 queues HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING before Line 7078 queues RESET_AFTER_BYTE_GAP. If dropped bytes left SGR or a line-drawing charset active, xterm can render the warning with that stale state. Write the reset before the warning.

Proposed fix
+      writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)
       if (!opts.quiet && !rearmedRemoteRestore) {
         clearHiddenOutputRestoreFloodRepaintTimer()
         writeRestoreUnavailableWarning()
       }
-      writePtyOutputToXterm(RESET_AFTER_BYTE_GAP, true)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 354dfada-ff11-4e5e-8d0b-16da04ea28a7

📥 Commits

Reviewing files that changed from the base of the PR and between 70a5d35 and ed50c81.

📒 Files selected for processing (5)
  • src/renderer/src/components/terminal-pane/pty-connection.test.ts
  • src/renderer/src/components/terminal-pane/pty-connection.ts
  • src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts
  • src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts
  • src/shared/terminal-mode-reset-profiles.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/renderer/src/components/terminal-pane/pty-connection.test.ts
  • src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts
  • src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Rendered Electron QA @ 952234a8ff

Validated on a live isolated Orca window at exact HEAD 952234a8ffe3520c9c9fc3d8a90e882b8ac80fd3. No product code was changed. Screenshots are uncropped full-app captures.

Instance. window.api.app.getIdentity() returned Orca: brennanb2025/terminal-sgr-pen-reset-on-restore, worktree arrowworm, this checkout. Isolated user-data profile, CDP 9337, renderer 5178.

Actions.

  1. Opened a throwaway folder workspace and a real terminal.
  2. On the visible pane, latched bold + hyperlink + line-drawing charset and printed LATCHED.
  3. Switched to Terminal 2 so Terminal 1 had no visible view (hiddenDeliveryGatedPtyCount=1, no delivery interest).
  4. Wrote reset + filler into the hidden PTY. Delivery debug: 8613 dropped chars / 12 chunks.
  5. Revealed Terminal 1 (natural restore from the main model).
  6. Wrote unmarked PLAINOK and qPLAINQ.

Visible proof.

Before hide: LATCHED is bold and the next prompt is line-drawing garbage.

01-latched-before-hide.png

Hidden: Terminal 2 is the live view while Terminal 1 is backgrounded.

02-hidden-on-terminal-2.png

After restore: dropped filler is back; PLAINOK / qPLAINQ are regular-weight ASCII; q is the letter q, not a box-drawing character.

03-after-restore-plainok.png

Historical LATCHED remains bold after restore (the live pen was reset; history was not restyled).

04-after-restore-latched-history.png

Backing cell state (live renderer buffer after reveal):

marker cells bold hyperlink glyphs
LATCHED (history) 7 7 7 LATCHED
PLAINOK (after restore) 7 0 0 PLAINOK
qPLAINQ (after restore) 7 0 0 qPLAINQ

Main model snapshot: source headless, seq 9858, contains both LATCHED and PLAINOK. Renderer console: 0 errors (3 unrelated warnings). App log: no restore-path errors.

Gaps. Overflow-abandon and deadline-abandon arms were not forced; this run used the natural hide → drop → reveal path. Alt-screen TUI restore and remote re-arm were not exercised. Hyperlink absence on PLAINOK is from cell attributes, not a hover tooltip.

Verdict: land for the natural hidden-output restore path. Unmarked text after reveal does not inherit the pre-drop bold, hyperlink, or line-drawing latch.

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