Skip to content

feat(cursor_tracker): track column + OSC 133 anchors#100

Merged
fentas merged 1 commit into
masterfrom
feat/cursor-col-tracking
May 18, 2026
Merged

feat(cursor_tracker): track column + OSC 133 anchors#100
fentas merged 1 commit into
masterfrom
feat/cursor-col-tracking

Conversation

@fentas
Copy link
Copy Markdown
Owner

@fentas fentas commented May 18, 2026

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`:

  • printables (0x20-0x7E + UTF-8 leads) advance one column
  • CR resets col to 1; BS decrements; HT jumps to next 8-col tab stop
  • soft-wrap: printable past `max_cols` falls into the next row
  • CSI: CUF / CUB / CHA / CUP / HVP / HPA / CNL / CPL

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:

  • `;A` → col reset to 1 (post-CR truth) + `prompt_row` recorded
  • `;B` → snapshots `prompt_end_col` (where bash's input region starts)

`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

  • `zig build test` 605 pass (16 new col tests)
  • `zig build -Dtarget=x86_64-linux-gnu` clean
  • `zig fmt --check src/` clean

🤖 Generated with Claude Code

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>
@fentas
Copy link
Copy Markdown
Owner Author

fentas commented May 18, 2026

Subagent review complete — 2 independent rounds at the latest commit, both verdict ship. Ready for human merge.

Rounds:

  • Round 1: ship with non-blocking note (DCS / APC / PM not handled — rare in shell output; would only matter for tmux passthrough, kitty graphics, sixel; documented limitation, follow-up issue if needed).
  • Round 2 (confirmation): re-verified UTF-8 advance (\xC2\xA9 → +1, \xF0\x9F\x98\x80 → +1, stray \x80 ignored); OSC 133 ;A / ;B logic correct; bracketed paste passes through cleanly; HT math verified at edges. No new findings.

Tests: 616 pass (16 new col tests), zig build clean, zig fmt --check clean.

🤖 Generated with Claude Code

@fentas fentas merged commit 5e13cf8 into master May 18, 2026
3 checks passed
@fentas fentas deleted the feat/cursor-col-tracking branch May 18, 2026 20:13
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>
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>
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