feat(cursor_tracker): track column + OSC 133 anchors#100
Merged
Conversation
PR 1 of 3 — passive column tracking. Always-on, no terminal round-trip. Foundation for subsequent fixes (DSR-6n active query + inline-panel layout corrections). Extends `CursorTracker` to maintain `col` alongside `row`: - printable bytes (0x20-0x7E + UTF-8 leads) advance one column - CR resets col to 1; BS decrements - HT jumps to the next 8-col tab stop - soft-wrap: printable past `max_cols` falls into the next row - CSI movement: CUF/CUB/CHA/CUP/HVP/HPA + CNL/CPL (which reset col) OSC parser added — previously OSC body bytes (e.g. title-set `\x1B]0;hello\x07`) leaked as printables and advanced col incorrectly. Now the state machine consumes OSC until `\x07` or `\x1B\\` and drops the body bytes. OSC 133 anchors: - `;A` (prompt start) → col reset to 1 (post-CR truth); records `prompt_row` for "is cursor still on the prompt row?" checks - `;B` (input region start) → snapshots `prompt_end_col`, which consumers (inline-chat Ctrl+Up restore, anything that needs "where does typing start?") can use directly without a DSR round-trip UTF-8 aware — only leading bytes (>= 0xC0) count as one column; continuation bytes (0x80..0xBF) don't advance. Wide-glyph width not modelled (every codepoint = 1 col); load-bearing accuracy comes from the OSC 133 ;B snapshot, not advance arithmetic. `init` signature broken: `(rows)` → `(rows, cols)`. One caller in `proxy.zig` updated to pass terminal cols (queried from `Pty.querySize`, fallback 80). 16 new tests in `cursor_tracker_tests.zig`: - printable advance + wrap - CR / BS / HT - CUF / CUB / CUP / CHA / HPA / CNL / CPL - UTF-8 codepoint counting - OSC 133 ;A reset + ;B snapshot - OSC body byte non-count - ST-terminated OSC (`\x1B\\`) - `setPosition` (for DSR-6n consumer in PR 2) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
Owner
Author
|
Subagent review complete — 2 independent rounds at the latest commit, both verdict ship. Ready for human merge. Rounds:
Tests: 616 pass (16 new col tests), 🤖 Generated with Claude Code |
fentas
added a commit
that referenced
this pull request
May 18, 2026
* feat(cursor_tracker): track column + OSC 133 anchors
PR 1 of 3 — passive column tracking. Always-on, no terminal
round-trip. Foundation for subsequent fixes (DSR-6n active query
+ inline-panel layout corrections).
Extends `CursorTracker` to maintain `col` alongside `row`:
- printable bytes (0x20-0x7E + UTF-8 leads) advance one column
- CR resets col to 1; BS decrements
- HT jumps to the next 8-col tab stop
- soft-wrap: printable past `max_cols` falls into the next row
- CSI movement: CUF/CUB/CHA/CUP/HVP/HPA + CNL/CPL (which reset col)
OSC parser added — previously OSC body bytes (e.g. title-set
`\x1B]0;hello\x07`) leaked as printables and advanced col
incorrectly. Now the state machine consumes OSC until `\x07` or
`\x1B\\` and drops the body bytes.
OSC 133 anchors:
- `;A` (prompt start) → col reset to 1 (post-CR truth); records
`prompt_row` for "is cursor still on the prompt row?" checks
- `;B` (input region start) → snapshots `prompt_end_col`, which
consumers (inline-chat Ctrl+Up restore, anything that needs
"where does typing start?") can use directly without a DSR
round-trip
UTF-8 aware — only leading bytes (>= 0xC0) count as one column;
continuation bytes (0x80..0xBF) don't advance. Wide-glyph width
not modelled (every codepoint = 1 col); load-bearing accuracy
comes from the OSC 133 ;B snapshot, not advance arithmetic.
`init` signature broken: `(rows)` → `(rows, cols)`. One caller
in `proxy.zig` updated to pass terminal cols (queried from
`Pty.querySize`, fallback 80).
16 new tests in `cursor_tracker_tests.zig`:
- printable advance + wrap
- CR / BS / HT
- CUF / CUB / CUP / CHA / HPA / CNL / CPL
- UTF-8 codepoint counting
- OSC 133 ;A reset + ;B snapshot
- OSC body byte non-count
- ST-terminated OSC (`\x1B\\`)
- `setPosition` (for DSR-6n consumer in PR 2)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cursor_dsr): DSR-6n reply interceptor + stdin filter
PR 2 of 3 — the active-query side of cursor-position tracking.
PR 1 added passive `cursor_tracker` (column + OSC 133 anchors);
this adds the round-trip mechanism for moments when the passive
model needs ground truth from the terminal.
## What
New `src/cursor_dsr.zig` — small state machine that parses
`\x1B[<row>;<col>R` (the DSR-6n cursor-position reply) out of an
input byte stream:
- `DsrParser.feed(input, out)` — walk bytes, drop reply bytes,
return filtered output + parsed `{row, col}` on match.
- `DsrParser.writeQuery(w)` — emit the `\x1B[6n` query.
11 unit tests pin: full reply in one chunk; embedded between
printables; split across feeds; unrelated CSI (Up arrow) passes
through; malformed (no `;`) doesn't match; double-reply in one
chunk; mid-stream abort restores byte stream verbatim;
saturating digit-clamp at u16 max; zero-param fields.
## Wired into proxy.zig
After every stdin `posix.read`, route the bytes through
`dsr_parser.feed` BEFORE keymap matching / dispatchInput /
pty.master forward. On a successful reply: feed the
`(row, col)` into `cursor_tracker.setPosition` so subsequent
paint queries see the truth.
When the entire chunk was a DSR reply (no user bytes), `continue`
the main loop so dispatch sees nothing rather than an empty slice.
## Why not just always query?
DSR-6n round-trip is ~10-30ms — fine for explicit user actions
(panel open) but unacceptable per-keystroke. PR 3 will add the
caller-side helpers that fire DSR at key moments only.
## Test plan
- [x] `zig build test` — 616 pass (11 new in cursor_dsr_tests)
- [x] `zig build -Dtarget=x86_64-linux-gnu` clean
- [x] `zig fmt --check src/` clean
Stacks on top of PR #100 (cursor_tracker col tracking).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cursor_dsr): withhold pending bytes in internal buffer until commit/abort
Subagent review round 1 caught two real bugs in the cross-chunk
path:
1. Split-chunk leak: chunk 1 of `\x1B[12;` was being written to
`out` (filtered_len=5) BEFORE the parser knew it was a reply.
Bash saw a phantom `\x1B[12;` keystroke. The retroactive
rewind only spanned the current `feed` call — chunk 1's bytes
had already escaped.
2. Cross-chunk false rewind: in a continuation chunk the
`pending_w_start` was always 0, so on completion the rewind
would drop any user bytes that appeared earlier in that
chunk.
Fix: buffer pending bytes in a parser-internal `pending_buf` (32 B
covers the largest legitimate DSR). They DON'T appear in `feed`'s
output until either:
- reply completes successfully → drop the whole buffer
- sequence aborts → flush the buffer verbatim (so keymap
matchers downstream still see the bytes)
Tightened the split-chunk test (asserts filtered_len == 0 in both
chunks now — previously only checked chunk 1's `pos == null`,
which masked the leak) + added two new tests:
- split reply with user bytes after the partial: aborts cleanly,
partial bytes flush verbatim
- idle empty feed between split chunks: pending state persists
Removed the dead `pending` bool + `pending_byte_count` field
(set/cleared but never read across the public API).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
fentas
added a commit
that referenced
this pull request
May 18, 2026
…moments (#102) * feat(llm,proxy): cursor restore lands on prompt-end col + DSR at key 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> * docs(proxy): clarify DSR-at-reservation is a resync, not snapshot source 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> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 1/3 of the cursor-position-tracking work. Always-on column tracking. No behavioural change yet — just the foundation. PR 2 will add DSR-6n active query; PR 3 will wire both into the inline-panel layout fixes.
What
`CursorTracker` now maintains `col` alongside `row`:
OSC parser added — previously OSC body bytes (`\x1B]0;title\x07` etc.) leaked as printables and advanced col incorrectly. State machine now consumes OSC bodies until `\x07` or `\x1B\`.
OSC 133 anchors:
`init` signature: `(rows)` → `(rows, cols)`. One caller updated.
Why
Foundation for fixing the layout bugs reported in #99 (inline panel painting over the prompt; Ctrl+Up lands at col 1 instead of after PS1). OSC 133 `;B` gives the exact prompt-end column synchronously — no DSR round-trip needed for the common case.
Test plan
🤖 Generated with Claude Code