diff --git a/src/cursor_dsr.zig b/src/cursor_dsr.zig new file mode 100644 index 0000000..5ab245a --- /dev/null +++ b/src/cursor_dsr.zig @@ -0,0 +1,198 @@ +//! DSR-6n (Device Status Report — cursor position) reply interceptor. +//! +//! `\x1B[6n` is the standard ECMA-48 / VT100 query: the terminal +//! replies on stdin with `\x1B[;R`. atty uses this to +//! ground-truth the cursor at sensitive moments (inline panel open, +//! SIGWINCH, post-command `;D`) when the passive `cursor_tracker` +//! model might have drifted. Reply parsing has to happen BEFORE the +//! stdin bytes reach the keymap / dispatchInput pipeline — otherwise +//! the reply gets forwarded to bash as if the user typed `[24;1R`. +//! +//! ## State machine +//! +//! Same shape as `osc133.zig`'s parser: walk bytes, drop into a +//! `csi_reply` state on `\x1B[`, accumulate digits + `;`, terminate +//! on `R` (success) or anything outside the accepted alphabet (abort +//! and pass through). Bytes that PARTICIPATE in a successful reply +//! are CONSUMED — `consumed_indices` records them so the caller can +//! filter the input slice. +//! +//! ## Scope of "consumed" +//! +//! We only consume bytes when we matched the FULL `\x1B[;R` +//! shape. Partial-match abandonment (e.g. user types `\x1B[A` for +//! Up-arrow — same prefix but different final) leaves the bytes in +//! the original stream so legacy keymap matching still works. The +//! consequence: a DSR reply gets parsed only when it's complete in +//! one chunk OR carries over correctly across two chunks (the +//! `pending` state handles that). Mid-stream partial matches that +//! abandon don't get retroactively re-fed. +//! +//! ## Non-goals +//! +//! - Multi-reply pipelining. atty fires DSR at most once per "key +//! moment" and waits for the reply (or times out) before firing +//! another. The parser only handles one in-flight reply at a time. +//! - Other DSR variants (DSR-5 status, DSR-15 printer). Not used. + +const std = @import("std"); + +pub const Position = struct { row: u16, col: u16 }; + +pub const DsrParser = struct { + state: State = .ground, + digits_buf: [16]u8 = undefined, + digits_len: u8 = 0, + row: u16 = 0, + col: u16 = 0, + /// Bytes accumulated while we're parsing a possible reply. + /// They DON'T appear in `feed`'s output until either: + /// - the reply completes successfully → drop the whole buffer + /// - the sequence aborts → flush the buffer to output verbatim + /// Withholding here is required for cross-chunk splits: a reply + /// like `\x1B[12;` (chunk 1) + `45R` (chunk 2) would otherwise + /// leak the 5 chunk-1 bytes to bash before we know it's a reply. + /// 32 bytes covers the largest legitimate DSR reply (`\x1B[<5 + /// digits>;<5 digits>R` = 13) with comfort. + pending_buf: [32]u8 = undefined, + pending_len: u8 = 0, + + const State = enum { ground, esc, csi, row_done }; + + /// Build the output slice by filtering the input through the + /// parser. Bytes that BELONG to a successful DSR reply are + /// dropped; everything else is preserved. Returns the position + /// parsed (if any) and the filtered byte count. + /// + /// `out` must be at least `input.len` bytes. Caller passes a + /// scratch buffer they're already writing to. + /// + /// Bytes that are part of an in-flight (not yet + /// completed-or-aborted) sequence are NOT written to `out` — + /// they live in the parser's internal `pending_buf` and either + /// vanish (success) or get flushed (abort) on a later feed call. + pub fn feed(self: *DsrParser, input: []const u8, out: []u8) struct { filtered_len: usize, pos: ?Position } { + var w: usize = 0; + var result_pos: ?Position = null; + + for (input) |b| { + switch (self.state) { + .ground => { + if (b == 0x1B) { + self.state = .esc; + self.pushPending(b); + } else { + out[w] = b; + w += 1; + } + }, + .esc => { + if (b == '[') { + self.state = .csi; + self.digits_len = 0; + self.row = 0; + self.col = 0; + self.pushPending(b); + } else { + // Not the shape we want — flush pending + + // current byte verbatim so keymap matchers + // downstream see the original sequence. + w += self.flushPending(out[w..]); + out[w] = b; + w += 1; + self.state = .ground; + } + }, + .csi => { + if (b >= '0' and b <= '9' and self.digits_len < self.digits_buf.len) { + self.digits_buf[self.digits_len] = b; + self.digits_len += 1; + self.pushPending(b); + } else if (b == ';') { + self.row = parseClamped(self.digits_buf[0..self.digits_len]); + self.digits_len = 0; + self.state = .row_done; + self.pushPending(b); + } else { + // Anything else aborts (including a stray + // 'R' with no `;` — malformed reply). Flush + // pending + the abort byte verbatim. + w += self.flushPending(out[w..]); + out[w] = b; + w += 1; + self.state = .ground; + } + }, + .row_done => { + if (b >= '0' and b <= '9' and self.digits_len < self.digits_buf.len) { + self.digits_buf[self.digits_len] = b; + self.digits_len += 1; + self.pushPending(b); + } else if (b == 'R') { + // Full reply received — drop pending buffer + // (the DSR sequence is consumed). + self.col = parseClamped(self.digits_buf[0..self.digits_len]); + result_pos = .{ .row = self.row, .col = self.col }; + self.pending_len = 0; + self.state = .ground; + self.digits_len = 0; + } else { + // Malformed (no terminator) — abandon, flush + // pending + the abort byte. + w += self.flushPending(out[w..]); + out[w] = b; + w += 1; + self.state = .ground; + } + }, + } + } + + return .{ .filtered_len = w, .pos = result_pos }; + } + + fn pushPending(self: *DsrParser, b: u8) void { + if (self.pending_len < self.pending_buf.len) { + self.pending_buf[self.pending_len] = b; + self.pending_len += 1; + } + // Overflow: silently drop bytes past the buffer. A + // legitimate DSR is ≤ 13 bytes; if we overflow we're + // looking at a hostile / malformed sequence that wasn't + // going to complete cleanly anyway. + } + + fn flushPending(self: *DsrParser, out: []u8) usize { + const n: usize = self.pending_len; + if (n > 0) @memcpy(out[0..n], self.pending_buf[0..n]); + self.pending_len = 0; + return n; + } + + /// Emit the DSR-6n query sequence into `w`. Caller writes the + /// result to STDOUT; the terminal replies on stdin which `feed` + /// will parse. + pub fn writeQuery(w: *std.Io.Writer) !void { + try w.writeAll("\x1B[6n"); + } +}; + +fn parseClamped(digits: []const u8) u16 { + var acc: u32 = 0; + const cap: u32 = std.math.maxInt(u16); + for (digits) |b| { + if (b < '0' or b > '9') continue; + if (acc >= cap) { + acc = cap; + continue; + } + const d: u32 = b - '0'; + const next: u64 = @as(u64, acc) * 10 + d; + acc = if (next > cap) cap else @intCast(next); + } + return @intCast(acc); +} + +test { + _ = @import("cursor_dsr_tests.zig"); +} diff --git a/src/cursor_dsr_tests.zig b/src/cursor_dsr_tests.zig new file mode 100644 index 0000000..96c710a --- /dev/null +++ b/src/cursor_dsr_tests.zig @@ -0,0 +1,159 @@ +//! Tests for `cursor_dsr.zig` — the DSR-6n reply interceptor. + +const std = @import("std"); +const testing = std.testing; +const mod = @import("cursor_dsr.zig"); + +const DsrParser = mod.DsrParser; + +test "DsrParser: full reply in one chunk is consumed; position returned" { + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r = p.feed("\x1B[24;80R", &out); + try testing.expectEqual(@as(usize, 0), r.filtered_len); + try testing.expect(r.pos != null); + try testing.expectEqual(@as(u16, 24), r.pos.?.row); + try testing.expectEqual(@as(u16, 80), r.pos.?.col); +} + +test "DsrParser: reply embedded between printable bytes — only reply consumed" { + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r = p.feed("a\x1B[10;5Rb", &out); + try testing.expect(r.pos != null); + try testing.expectEqual(@as(u16, 10), r.pos.?.row); + try testing.expectEqual(@as(u16, 5), r.pos.?.col); + try testing.expectEqualStrings("ab", out[0..r.filtered_len]); +} + +test "DsrParser: unrelated CSI (`\\x1b[A` — Up arrow) passes through" { + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r = p.feed("\x1B[A", &out); + try testing.expect(r.pos == null); + try testing.expectEqualStrings("\x1B[A", out[0..r.filtered_len]); +} + +test "DsrParser: CSI with single param ending in `R` (no `;`) doesn't match" { + // A real DSR reply always has row + col separated by `;`. A + // sequence like `\x1B[24R` is malformed — pass through. + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r = p.feed("\x1B[24R", &out); + try testing.expect(r.pos == null); + try testing.expectEqualStrings("\x1B[24R", out[0..r.filtered_len]); +} + +test "DsrParser: reply split across two feeds — NEITHER chunk leaks bytes" { + // Critical invariant: pending bytes stay in the parser's + // internal buffer until completion (drop) or abort (flush). + // A naive implementation would write `\x1B[12;` into the first + // chunk's filtered output and forward to bash before learning + // it was a reply. + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r1 = p.feed("\x1B[12;", &out); + try testing.expect(r1.pos == null); + try testing.expectEqual(@as(usize, 0), r1.filtered_len); + + var out2: [64]u8 = undefined; + const r2 = p.feed("45R", &out2); + try testing.expect(r2.pos != null); + try testing.expectEqual(@as(u16, 12), r2.pos.?.row); + try testing.expectEqual(@as(u16, 45), r2.pos.?.col); + try testing.expectEqual(@as(usize, 0), r2.filtered_len); +} + +test "DsrParser: split reply with user bytes BEFORE the tail — user bytes preserved" { + // Pathological case: chunk 1 starts a reply, chunk 2 contains + // user keystrokes BEFORE the reply completes (the user typed + // while the terminal queued the DSR response). The user bytes + // would abort the reply parse and must reach bash; the + // pending-buffer flush emits the partial-reply bytes + // VERBATIM so keymap matchers still see them. + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r1 = p.feed("\x1B[12;", &out); + try testing.expectEqual(@as(usize, 0), r1.filtered_len); + + // Chunk 2 has user input that aborts the pending reply. + var out2: [64]u8 = undefined; + const r2 = p.feed("xls\r", &out2); + try testing.expect(r2.pos == null); + // Aborted: the pending `\x1B[12;` flushes verbatim followed by + // `xls\r`. + try testing.expectEqualStrings("\x1B[12;xls\r", out2[0..r2.filtered_len]); +} + +test "DsrParser: in-flight pending across an idle feed call — bytes still withheld" { + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r1 = p.feed("\x1B[", &out); + try testing.expectEqual(@as(usize, 0), r1.filtered_len); + + // Empty feed (nothing arrives this tick). + const r2 = p.feed("", &out); + try testing.expectEqual(@as(usize, 0), r2.filtered_len); + try testing.expect(r2.pos == null); + + // Reply finishes later. + const r3 = p.feed("1;2R", &out); + try testing.expect(r3.pos != null); + try testing.expectEqual(@as(u16, 1), r3.pos.?.row); + try testing.expectEqual(@as(u16, 2), r3.pos.?.col); + try testing.expectEqual(@as(usize, 0), r3.filtered_len); +} + +test "DsrParser: zero-param fields parse as 0 (caller's job to clamp)" { + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r = p.feed("\x1B[;R", &out); + try testing.expect(r.pos != null); + try testing.expectEqual(@as(u16, 0), r.pos.?.row); + try testing.expectEqual(@as(u16, 0), r.pos.?.col); +} + +test "DsrParser: massive digit values saturate at u16 max" { + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r = p.feed("\x1B[99999;88888R", &out); + try testing.expect(r.pos != null); + try testing.expectEqual(@as(u16, std.math.maxInt(u16)), r.pos.?.row); + try testing.expectEqual(@as(u16, std.math.maxInt(u16)), r.pos.?.col); +} + +test "DsrParser: writeQuery emits the standard sequence" { + var buf: [16]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try DsrParser.writeQuery(&w); + try testing.expectEqualStrings("\x1B[6n", buf[0..w.end]); +} + +test "DsrParser: two replies in a single chunk both parse" { + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r1 = p.feed("\x1B[1;2R", &out); + try testing.expect(r1.pos != null); + try testing.expectEqual(@as(u16, 1), r1.pos.?.row); + try testing.expectEqual(@as(u16, 2), r1.pos.?.col); + + var out2: [64]u8 = undefined; + const r2 = p.feed("\x1B[3;4R", &out2); + try testing.expect(r2.pos != null); + try testing.expectEqual(@as(u16, 3), r2.pos.?.row); + try testing.expectEqual(@as(u16, 4), r2.pos.?.col); +} + +test "DsrParser: abort mid-CSI (`\\x1b[12;abc`) restores byte stream verbatim" { + // If the user types something that LOOKS like the start of a + // DSR reply but isn't, the parser must release the bytes so + // keymap matching downstream still works. + var p = DsrParser{}; + var out: [64]u8 = undefined; + const r = p.feed("\x1B[12;a", &out); + try testing.expect(r.pos == null); + // The chunk contained `\x1B[12;` (5 bytes accumulated as + // pending) + `a` (abort). After abort the parser should have + // released all 6 bytes through the output buffer. + try testing.expectEqualStrings("\x1B[12;a", out[0..r.filtered_len]); +} diff --git a/src/proxy.zig b/src/proxy.zig index 578598d..066a32f 100644 --- a/src/proxy.zig +++ b/src/proxy.zig @@ -48,6 +48,7 @@ const keymap = @import("keymap.zig"); const Osc133 = @import("osc133.zig").Osc133; const AltScreen = @import("altscreen.zig").AltScreen; const CursorTracker = @import("cursor_tracker.zig").CursorTracker; +const DsrParser = @import("cursor_dsr.zig").DsrParser; const Osc7 = @import("osc7.zig").Osc7; const subprocess_mod = @import("subprocess.zig"); const overlay_ring = @import("overlay_ring.zig"); @@ -261,6 +262,17 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, args: Args) !ExitInfo { } break :blk CursorTracker.init(rows, cols); }; + // DSR-6n reply interceptor. atty issues `\x1B[6n` at key moments + // (inline panel open, SIGWINCH, post-command `;D`) to ground- + // truth the cursor position; the terminal replies on stdin with + // `\x1B[;R`. The parser strips that reply from the stream + // before keymap matching / dispatch / pty.master forward so the + // shell never sees it as user input. + var dsr_parser = DsrParser{}; + // Scratch buffer the parser writes filtered stdin bytes into. + // Sized to match `read_buf` since the filtered output is at most + // as long as the input. + var stdin_filtered_buf: [4096]u8 = undefined; // Alternate-screen-buffer tracker — full-screen TUIs (k9s, vim, // less, htop, helix, lazygit, …) swap to the alt buffer with @@ -584,7 +596,16 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, args: Args) !ExitInfo { if (pfds[0].revents & POLLIN != 0) { const read_n = posix.read(posix.STDIN_FILENO, &read_buf) catch 0; if (read_n > 0) { - var input: []const u8 = read_buf[0..read_n]; + // DSR-6n reply intercept — `\x1B[;R` is the + // terminal's response to our cursor-position query; + // strip it before bash sees it as keyboard input. + const dsr_result = dsr_parser.feed(read_buf[0..read_n], &stdin_filtered_buf); + if (dsr_result.pos) |pos| { + cursor_tracker.setPosition(pos.row, pos.col); + trace.log(.cursor, "DSR-6n reply: row={d} col={d}", .{ pos.row, pos.col }); + } + var input: []const u8 = stdin_filtered_buf[0..dsr_result.filtered_len]; + if (input.len == 0) continue; // entire chunk was DSR reply trace.logBytes(.input, "stdin_read", input); trace.log(.altscreen, "alt_screen.active={} module_overlay_active={}", .{ alt_screen.active, ctx.module_overlay_active }); diff --git a/src/unit_tests.zig b/src/unit_tests.zig index c99ea7b..0c97c72 100644 --- a/src/unit_tests.zig +++ b/src/unit_tests.zig @@ -16,6 +16,7 @@ test { _ = @import("osc133.zig"); _ = @import("altscreen.zig"); _ = @import("cursor_tracker.zig"); + _ = @import("cursor_dsr.zig"); _ = @import("subprocess.zig"); _ = @import("osc7.zig"); _ = @import("pty.zig");