Skip to content

fix: reset popup scroll state synchronously on new prompt (#83)#84

Closed
StanMarek wants to merge 2 commits intomasterfrom
fix/issue-83-popup-overlap-prompt-boundary
Closed

fix: reset popup scroll state synchronously on new prompt (#83)#84
StanMarek wants to merge 2 commits intomasterfrom
fix/issue-83-popup-overlap-prompt-boundary

Conversation

@StanMarek
Copy link
Copy Markdown
Owner

Summary

Fixes #83 — completion popup occasionally renders on top of the prompt after Ctrl+C on Terminal.app and Ghostty.

Root cause

InputHandler::scroll_deficit (crates/gc-pty/src/handler.rs:189) was only reset via the async CPR roundtrip drained inside trigger() (handler.rs:614). After Ctrl+C, the shell prints ^C\n + new prompt + OSC 7771;A + OSC 7770("") in one batched read. Task B writes CSI 6n and immediately calls trigger() with buffer_dirty=true — but the terminal's CPR response is still in flight on stdin. take_cpr_synced() returns false, scroll_deficit stays at its pre-Ctrl+C value, and dismiss() (handler.rs:1050) didn't reset it either.

If the user types within that window, final_cursor_row = real_row − scroll_deficit produces start_row = real_row — the popup anchor lands on the new prompt row. With scroll_deficit=1 you get exactly the symptom from the issue: (b<popup>Book-Air GitHub % su with the popup overlaid at column 2.

Both terminals are affected because the race lives in the proxy/handler — not in DECSET 2026 vs pre-render-buffer.

Fix

Add a synchronous prompt-boundary signal independent of CPR. Drain it at every render entry so a re-render that fires before the CPR roundtrip lands still sees a clean per-prompt state.

  • gc-parser/src/state.rs — new prompt_started: bool field on TerminalState. Public take_prompt_started() mirrors take_cpr_synced (atomic drain). pub(crate) mark_prompt_started() setter.
  • gc-parser/src/performer.rs — call mark_prompt_started() inside both OSC 133;A and OSC 7771;A handlers, alongside the existing request_cursor_sync / set_prompt_row calls.
  • gc-pty/src/handler.rs:
    • New drain_prompt_started_reset helper called at the start of render, try_merge_dynamic, navigation in process_key_visible, and accept_with_chaining. On true: scroll_deficit = 0 and last_layout = None.
    • trigger() drains both flags. prompt_started resets both fields; cpr_synced (fallback) only resets scroll_deficit because a CPR sync within the same prompt context shouldn't blow away a valid layout.
    • dismiss() now resets scroll_deficit = 0.
    • Doc-comment on scroll_deficit updated to reflect all reset paths.

CPR-sync reset retained as defense-in-depth.

Test plan

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --workspace942 passed, 0 failed
  • 8 new tests (4 parser, 4 handler) covering the regression:
    • prompt_started_flag_default_false
    • prompt_started_set_then_drained
    • test_osc133_prompt_a_sets_prompt_started
    • test_osc7771_prompt_a_sets_prompt_started
    • test_render_resets_scroll_deficit_on_prompt_started
    • test_dismiss_resets_scroll_deficit
    • test_try_merge_dynamic_resets_scroll_deficit_on_prompt_started
    • test_trigger_resets_last_layout_on_prompt_started_only
  • Manual repro: 24-row Terminal.app, run a command that surfaces history, hit Ctrl+C while popup is visible, type immediately. Confirm popup no longer overlays the new prompt.
  • Same on Ghostty.

The popup occasionally rendered on top of the prompt after Ctrl+C
because `scroll_deficit` and `last_layout` only reset via the async
CPR roundtrip — a window large enough for typing to race in and a
stale `scroll_deficit=N` to anchor the popup at `prompt_row`.

Add a synchronous `prompt_started` flag on `TerminalState`, set on
every OSC 133;A and OSC 7771;A. Drain it at every handler render
entry (render, render_at, try_merge_dynamic, navigation, accept
chaining, trigger) and reset both fields. Also reset `scroll_deficit`
in `dismiss()`. CPR-sync fallback in `trigger` retained.
- Replace test_render_resets_scroll_deficit_on_prompt_started and
  test_try_merge_dynamic_resets_scroll_deficit_on_prompt_started with
  direct unit tests on drain_prompt_started_reset that assert
  last_layout.is_none() and scroll_deficit == 0 after the drain.
- Add test_drain_prompt_started_reset_no_flag_preserves_state to pin
  the no-op-when-unset contract.
- Add test_handle_resize_does_not_consume_prompt_started.
- Cover both-flags-set case in test_trigger_resets_last_layout_on_prompt_started_only.
- Drop redundant drain_prompt_started_reset calls in process_key_visible
  navigate paths; render() drains internally.
- Remove "issue #83" cross-references from comments per CLAUDE.md.
- Tighten scroll_deficit field doc and drain_prompt_started_reset doc.
@StanMarek
Copy link
Copy Markdown
Owner Author

Old PR

@StanMarek StanMarek closed this May 5, 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.

ghost complete messes my terminal output

1 participant