Skip to content

feat(llm,proxy): cursor restore lands on prompt-end col + DSR at key moments#102

Merged
fentas merged 2 commits into
masterfrom
feat/cursor-restore-with-col
May 18, 2026
Merged

feat(llm,proxy): cursor restore lands on prompt-end col + DSR at key moments#102
fentas merged 2 commits into
masterfrom
feat/cursor-restore-with-col

Conversation

@fentas
Copy link
Copy Markdown
Owner

@fentas fentas commented May 18, 2026

PR 3/3 of the cursor-position-tracking work. Consumes the foundations from #100 (col tracking + OSC 133 anchors) and #101 (DSR-6n parser) to fix the user-reported Ctrl+Up column bug and ground-truth the tracker at sensitive moments.

Ctrl+Up column fix

The snapshot captured on inline-panel open now includes `col` alongside `row`. `inlineRestoreRow` → `inlineRestorePos` returning both. Paint CUPs `\x1B[;H` instead of `\x1B[;1H`. Col snapshot of 0 falls back to col 1 (defensive — matches prior behaviour for non-TTY test fixtures).

Mechanics:

  • `module.zig` — `Context.cursor_col` companion to `cursor_row`.
  • `proxy.zig` — plumb `cursor_tracker.currentCol()` at the same three sites that plumb `currentRow()`.
  • `modules/llm.zig` — `chat_open_cursor_col: u16 = 0`.
  • `modules/llm/hooks.zig` — capture col on toggle.
  • `modules/llm/paint.zig` — restore helper returns `struct { row, col }`; both CUP call sites updated.

DSR-6n firings

`proxy.zig` now writes `\x1B[6n` at:

  • Inline-panel reservation transition (`applyReserveRows` block) — between ghost-clear and the reservation grow/shrink. The next stdin tick filters the reply via PR feat(cursor_dsr): DSR-6n reply interceptor + stdin filter #101's parser and updates `cursor_tracker`.
  • OSC 133 `;D` (command end) — long-running command output may have scrolled the cursor unpredictably; re-anchor before the next sensitive op.

Fire-and-forget — reply lands async; no sync wait, no latency.

Stacks on master directly

PRs #100 + #101 are merged, so this branches off the new master.

Test plan

  • `zig build test` 620 pass (2 new col-restore tests)
  • `zig build` clean
  • `zig fmt --check` clean
  • Manual: open chat panel; Ctrl+Up parks cursor AFTER PS1 (not col 1)
  • Manual: in a long-output session, open chat; cursor row is accurate

🤖 Generated with Claude Code

fentas and others added 2 commits May 18, 2026 22:20
…moments

PR 3 of 3 — consumes the col-tracking + DSR plumbing from PRs
#100 / #101 to fix the user-reported "Ctrl+Up lands at col 1 not
after PS1" bug AND ground-truth the tracker at sensitive points.

## Ctrl+Up column fix

Snapshot now captures (row, col) on inline-panel open via the new
`ctx.cursor_col` plumb. `inlineRestoreRow` renamed to
`inlineRestorePos` returning both — paint CUPs `\x1B[<r>;<c>H`
instead of `\x1B[<r>;1H`. Col-snapshot 0 falls back to col 1
defensively (matches prior behaviour for non-TTY tests).

Mechanics:
- `module.zig` — adds `Context.cursor_col` (companion to
  `cursor_row`).
- `proxy.zig` — initial assign + the two refresh sites mirror the
  row plumb: `cursor_tracker.currentCol()`.
- `modules/llm.zig` — adds `chat_open_cursor_col: u16 = 0` field.
- `modules/llm/hooks.zig` — captures col alongside row on toggle.
- `modules/llm/paint.zig` — restore helper returns
  `struct { row, col }`; both CUP call sites updated.

## DSR-6n at key moments

`proxy.zig` now fires `\x1B[6n` at:
- Inline-panel reservation grow/shrink edge (where the previous
  layout chaos lived) — `applyReserveRows` block writes DSR after
  the ghost-clear, before the reservation transition. The reply
  lands on stdin and updates `cursor_tracker` via the existing
  filter (PR #101).
- OSC 133 `;D` (`cmd_end`) — command finished, its output may
  have scrolled the cursor unpredictably. Re-anchor before the
  next sensitive op.

DSR-6n is a write-only, asynchronous fire-and-forget — the reply
arrives on the next stdin tick via the filter and updates the
tracker. No sync wait, no latency.

## Tests

620 pass total (2 new pin the col snapshot + restore: `(row, col)
snapshot` test asserts `\x1B[8;4H` appears, `\x1B[8;1H` doesn't;
the col-snapshot 0 fallback test confirms defensive col=1 path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subagent review caught that the reservation-transition DSR fires
AFTER the LLM module already captured the (row, col) snapshot at
toggle dispatch — so the reply doesn't refresh THIS open's
restore target. It's a downstream consumer resync (next paint,
next prompt anchor) and the comment now says so. Also moves the
DSR write to AFTER `applyReserveRows` (its actual purpose is to
re-anchor after the DECSTBM + per-row erase sequence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fentas
Copy link
Copy Markdown
Owner Author

fentas commented May 18, 2026

Subagent review complete — 2 rounds at the latest commit. Ready for human merge.

Rounds:

  • Round 1: fix-and-ship — caught that the DSR-at-reservation comment oversold the timing (the snapshot is captured at toggle dispatch one iter earlier, so the DSR reply doesn't refresh THIS open's restore target).
  • Round 2 (post-fix): ship. Comment rewritten to honestly describe the DSR as a tracker resync (not a snapshot source), DSR write moved to AFTER applyReserveRows to clarify its purpose (re-anchor after DECSC/DECRC + per-row CUPs). ;D DSR firing nests correctly within the OSC 133 edge loop; out_buf aliasing safe.

Test plan:

  • zig build test — 620 pass (2 new col-restore tests)
  • zig build clean
  • zig fmt --check clean

The user-facing fix (Ctrl+Up parks cursor AFTER PS1, not col 1) is verified by the new tests; DSR resyncs are sub-perceptual best-effort improvements that don't have unit tests (would need an integration test with a fake terminal that replies to DSR — reasonable to defer).

🤖 Generated with Claude Code

@fentas fentas merged commit 6757d5d into master May 18, 2026
3 checks passed
@fentas fentas deleted the feat/cursor-restore-with-col branch May 18, 2026 20:47
fentas added a commit that referenced this pull request May 18, 2026
…or (#103)

* fix(proxy): scroll shell content up when inline panel grows past cursor

Reported repro: prompt at the bottom row, Alt+C → panel opens but
the prompt stays where it was, getting over-painted by the panel
chrome. Ctrl+Up then parks the cursor invisibly inside the panel.

Different from the earlier (reverted) attempt — this one runs ONLY
when the cursor_tracker reports the cursor would land in the new
reservation zone (`cur_row > new_bottom`), and uses the now-
reliable column from the cursor-tracking series (#100/#101/#102)
to CUP the cursor to the prompt's actual position (post-PS1) after
the scroll.

Mechanics:
1. Compute `new_bottom = sb.rows - want_reserve` (last row of the
   shrunken shell area).
2. If `cur_row > new_bottom`, emit `\n` × (cur_row - new_bottom)
   at the cursor's current row. Each LF at the bottom of the
   active DECSTBM region scrolls content up by 1.
3. CUP to (new_bottom, cur_col) so subsequent shell output
   continues from the prompt's relocated row + the column the
   user's typing was about to land at.
4. `applyReserveRows` follows immediately, shrinking DECSTBM and
   blanking the new reservation rows.
5. The DSR-6n fired afterwards re-anchors `cursor_tracker` once
   the terminal reports the actual post-scroll position.

Bash never sees the `\n` bytes — they go to STDOUT, not
pty.master. The shell's internal cursor model is unchanged; only
the visible screen scrolls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(proxy): drop redundant SIGWINCH on reservation toggle — it undoes the scroll-up

Cursor stays at the old row after the panel-grow scroll-up because
the SIGWINCH sent right after triggers bash's readline redraw —
and readline doesn't know we moved the visual prompt. It re-emits
the prompt at bash's INTERNAL cursor row, which is unchanged.

The SIGWINCH was a no-op for actual size: we pass the full
terminal dimensions (DECSTBM clipping is atty-side), so the slave
TIOCGWINSZ delta is zero. Real resize events are handled by the
SIGWINCH-handler path that listens on the proxy's own signal pipe,
so dropping the spurious bounce here doesn't lose anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(llm,proxy): defer chat snapshot to first paint + propagate post-scroll cursor

The reservation-grow scroll-up moves the prompt UP but the LLM module's
inline-panel snapshot was captured at toggle-action time — BEFORE the
scroll. The panel's restore CUP then landed on the pre-scroll row,
which is now inside the new panel zone, and bash's next prompt redraw
chased the cursor up — the prompt visibly walked UP on every redraw.

Two coordinated changes:

1. Proxy: after emitting the scroll-up sequence, set
   `cursor_tracker.setPosition(new_bottom, cur_col)` and refresh
   `ctx.cursor_row/col` so the upcoming paint sees post-scroll state
   without waiting for the DSR-6n reply (which arrives next tick).

2. LLM module: toggle resets the snapshot to 0 (defer marker) instead
   of eagerly capturing; the open path of `paintInlineChat` captures
   from `ctx.cursor_row/col` on the first paint. By then the proxy
   has propagated post-scroll position, so the restore CUP lands on
   the panel's actual top edge.

Tests in `paint_tests.zig` updated to drive a paint between toggle
and snapshot assertions — the deferred-capture semantics are
explicit in the assertion sequence now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(llm): inline_chat_top_gap — blank spacer row between prompt and panel

The inline panel divider used to sit directly against the bottom
shell row. With cursor restore now landing correctly on the prompt,
the prompt visually butted right up against the chat chrome —
cramped, no breathing room.

New `Config.inline_chat_top_gap` (default 1) reserves N extra rows
on top of `inline_chat_rows` and the panel paint leaves them blank.
At default the layout becomes: prompt row, 1 blank gap row, divider,
scrollback, input row, statusbar. Set to 0 for the old flush layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(proxy): scroll-up LFs must be emitted at region bottom, not cur_row

LFs only trigger DECSTBM scroll-up when emitted AT the bottom of the
scroll region — at any other row they just advance the cursor. The
scroll-up code was CUPing to `cur_row` first, so when the cursor sat
one row above the region bottom (e.g. after 3 empty Enters from a
post-chat-close prompt) the first LF only moved the cursor down
instead of scrolling, and we lost one scroll. Result: the prompt
landed one row higher than intended — an extra blank between the
prompt and the panel's top spacer.

CUP to `sb.effectiveRows()` (current region bottom) before emitting
the LF run; every LF then scrolls and `scroll_n = cur_row -
new_bottom` lands the prompt exactly on `new_bottom`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(llm,proxy): guard tiny-panel underflow; document scroll-up→DECRC contract

Two findings from subagent review of PR #103:

1. Underflow guard. paintInlineChat computed `scrollback_rows =
   panel_rows - 2` later in the function; if a proxy clamp landed
   `panel_rows` on 1 (e.g. `inline_chat_rows=3`, top_gap=1, terminal
   small enough that `live_reserve` got clamped one row above
   `base + 3`), the u16 wrapped to ~65k and the per-row blank loop
   overflowed the small chat paint buffer — surfaced as "terminal
   too small" with the panel rolling back. The early-out at the top
   of the function now also bails when `live_reserve - base_reserve
   < top_gap + 3` so the subtraction below is provably safe.

2. Ordering contract comment. The scroll-up's final CUP at
   (new_bottom, cur_col) is what `applyReserveRows`'s DECSC will
   snapshot a few bytes later; its DECRC restores cursor there
   once the new reservation + erase have run. If `applyReserveRows`
   ever moves its DECSC earlier the panel paint's snapshot would
   drift. Documented inline so the dependency is visible at the
   call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot mentioned this pull request May 19, 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.

1 participant