diff --git a/.github/workflows/autotag.yml b/.github/workflows/autotag.yml deleted file mode 100644 index c2ffe125..00000000 --- a/.github/workflows/autotag.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Create Tag - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: jacopocarlini/action-autotag@3.0.0 - env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - with: - tag_prefix: "v" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 69aaf775..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CI - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - test: - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [macos-latest] - # sdk: [stable, beta, dev, 2.10.3, 2.12.0-29.10.beta] - sdk: [stable, master] - - steps: - - uses: actions/checkout@v2 - - # - uses: actions/setup-java@v1 - # with: - # java-version: "12.x" - - - uses: subosito/flutter-action@v1 - with: - channel: ${{ matrix.channel }} - - - name: Install dependencies - run: flutter pub get - - - name: Verify formatting - run: dart format --set-exit-if-changed . - - # Consider passing '--fatal-infos' for slightly stricter analysis. - - name: Analyze project source - run: flutter analyze --fatal-infos - - - name: Run tests - run: flutter test --coverage - - - name: Upload coverage - uses: codecov/codecov-action@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcd6152..4fbd8fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## Unreleased (prismatic-cz fork, branch `fix/nano-cursor`) + +* **Fix: implement CBT (`CSI Pn Z`, Cursor Backward Tab) and CHT + (`CSI Pn I`, Cursor Horizontal Tab).** Both sequences were absent + from the CSI handler table; parser fell through to `unknownCSI` and + the cursor stayed in place. nano uses CBT extensively to redraw + syntax-highlighted portions of the current line efficiently — when + the emulator ignored it, nano's internal cursor model drifted from + the actual buffer position by the tab-stop skip size, and subsequent + writes corrupted cells to the right of the cursor. Visible symptom: + pressing arrow-right on long syntax-highlighted lines made the + content of the line morph into garbled text one character at a time. + Fixes much of the user-visible bug in [#58], [#94] in combination + with the CPR fix below. + + Adds `TabStops.findBackward(start)` helper. Adds + `EscapeHandler.cursorBackwardTab` and `.cursorForwardTab`. + Regression harness: `test/src/regression/replay_test.dart` replays + a captured nano session. + +* **Fix: off-by-one in CPR (Cursor Position Report) reply.** + `EscapeEmitter.cursorPosition()` was emitting raw 0-indexed buffer + coordinates, but the VT100 CPR reply format (`CSI row ; col R`) is + 1-indexed. Every DSR (`CSI 6n`) query therefore returned a position + off by one in both axes. ncurses applications like nano and vim that + sync their internal cursor model from CPR ended up editing at the + wrong visible position. Manifests only on raw SSH (tmux answers CPR + from its own state, masking the bug). Fixes [#58], [#94]. + + Regression test: `test/src/regression/nano_vim_cursor_test.dart`. + ## [4.0.0] - 2024-02-27 * Update for Flutter 3.19 [#190]. Thanks [@domesticmouse]. * Fix designate charset logic [#186]. Thanks [@djnalluri]. diff --git a/lib/src/core/buffer/buffer.dart b/lib/src/core/buffer/buffer.dart index 10789382..5606a5fb 100644 --- a/lib/src/core/buffer/buffer.dart +++ b/lib/src/core/buffer/buffer.dart @@ -190,7 +190,11 @@ class Buffer { /// cursor. void eraseLineToCursor() { currentLine.isWrapped = false; - currentLine.eraseRange(0, _cursorX, terminal.cursor); + // eraseRange end is exclusive, so include cursor col with +1. + // Bug fix: previously erased [0, _cursorX) leaving the cursor cell intact, + // which breaks Claude Code's "Do you want to…" menu rendering (~36 `CSI 1 K` + // calls per frame, each leaving one residual cell of garbage). + currentLine.eraseRange(0, _cursorX + 1, terminal.cursor); } /// Erases the line at the current cursor position. diff --git a/lib/src/core/escape/emitter.dart b/lib/src/core/escape/emitter.dart index faf5d401..0bf77651 100644 --- a/lib/src/core/escape/emitter.dart +++ b/lib/src/core/escape/emitter.dart @@ -19,8 +19,13 @@ class EscapeEmitter { return '\x1b[0n'; } + /// CPR (Cursor Position Report) reply: `CSI row ; col R`, 1-indexed + /// per VT100 spec. Callers pass 0-indexed [x] (column) and [y] (row) + /// from the buffer, so we add 1 here. Without the +1, ncurses apps + /// (nano, vim) that sync their cursor model via DSR get an off-by-one + /// position and mis-edit subsequently. (T67 — fixes #58, #94) String cursorPosition(int x, int y) { - return '\x1b[$y;${x}R'; + return '\x1b[${y + 1};${x + 1}R'; } String bracketedPaste(String text) { diff --git a/lib/src/core/escape/handler.dart b/lib/src/core/escape/handler.dart index 166fb485..c943099f 100644 --- a/lib/src/core/escape/handler.dart +++ b/lib/src/core/escape/handler.dart @@ -11,6 +11,12 @@ abstract class EscapeHandler { void tab(); + /// CHT — Cursor Horizontal forward Tab (`CSI Pn I`). + void cursorForwardTab(int amount); + + /// CBT — Cursor Backward Tab (`CSI Pn Z`). + void cursorBackwardTab(int amount); + void lineFeed(); void carriageReturn(); diff --git a/lib/src/core/escape/parser.dart b/lib/src/core/escape/parser.dart index 0c7e74a9..bf409727 100644 --- a/lib/src/core/escape/parser.dart +++ b/lib/src/core/escape/parser.dart @@ -296,7 +296,9 @@ class EscapeParser { 'S'.codeUnitAt(0): _csiHandleScrollUp, 'T'.codeUnitAt(0): _csiHandleScrollDown, 'X'.codeUnitAt(0): _csiHandleEraseCharacters, + 'Z'.codeUnitAt(0): _csiHandleCursorBackwardTab, '@'.codeUnitAt(0): _csiHandleInsertBlankCharacters, + 'I'.codeUnitAt(0): _csiHandleCursorForwardTab, }); /// `ESC [ Ps a` Cursor Horizontal Position Relative (HPR) @@ -911,6 +913,32 @@ class EscapeParser { handler.scrollDown(amount); } + /// `ESC [ Ps Z` Cursor Backward Tab (CBT) + /// + /// Move cursor backward [Ps] tab stops (default 1). + /// https://terminalguide.namepad.de/seq/csi_sz/ + void _csiHandleCursorBackwardTab() { + var amount = 1; + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + handler.cursorBackwardTab(amount); + } + + /// `ESC [ Ps I` Cursor Horizontal Tab (CHT) + /// + /// Move cursor forward [Ps] tab stops (default 1). + /// https://terminalguide.namepad.de/seq/csi_si/ + void _csiHandleCursorForwardTab() { + var amount = 1; + if (_csi.params.isNotEmpty) { + amount = _csi.params[0]; + if (amount == 0) amount = 1; + } + handler.cursorForwardTab(amount); + } + /// `ESC [ Ps X` Erase Character (ECH) /// /// https://terminalguide.namepad.de/seq/csi_cx/ diff --git a/lib/src/core/tabs.dart b/lib/src/core/tabs.dart index 385f9499..62c5d094 100644 --- a/lib/src/core/tabs.dart +++ b/lib/src/core/tabs.dart @@ -32,6 +32,17 @@ class TabStops { return null; } + /// Finds the tab stop at-or-before [start]. Returns null if no tab stop + /// is set anywhere in `[0, start]`. Used by CBT (Cursor Backward Tab). + int? findBackward(int start) { + if (start < 0) return null; + final upper = min(start, _stops.length - 1); + for (var i = upper; i >= 0; i--) { + if (_stops[i]) return i; + } + return null; + } + /// Sets the tab stop at [index]. If there is already a tab stop at [index], /// this method does nothing. /// diff --git a/lib/src/terminal.dart b/lib/src/terminal.dart index 461e2084..45fe6932 100644 --- a/lib/src/terminal.dart +++ b/lib/src/terminal.dart @@ -413,6 +413,35 @@ class Terminal with Observable implements TerminalState, EscapeHandler { } } + @override + void cursorForwardTab(int amount) { + for (var i = 0; i < amount; i++) { + tab(); + } + } + + @override + void cursorBackwardTab(int amount) { + // CBT: move cursor backward [amount] tab stops. Search backwards from + // cursorX-1 for each step. Clamp to column 0 if fewer stops remain. + // Without this, ncurses apps (nano) that emit CBT to redraw status lines + // leave the cursor at the original column; subsequent writes overwrite + // unrelated cells and the line corrupts (T67, fixes #58, #94). + var count = amount; + if (count <= 0) count = 1; + var x = _buffer.cursorX; + while (count > 0 && x > 0) { + final prev = _tabStops.findBackward(x - 1); + if (prev == null) { + x = 0; + break; + } + x = prev; + count--; + } + _buffer.setCursorX(x); + } + @override void lineFeed() { _buffer.lineFeed(); diff --git a/lib/src/ui/render.dart b/lib/src/ui/render.dart index de2a2f7f..bfbb6a43 100644 --- a/lib/src/ui/render.dart +++ b/lib/src/ui/render.dart @@ -164,8 +164,26 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } void _onTerminalChange() { - markNeedsLayout(); - _notifyEditableRect(); + // PERF: originally `markNeedsLayout()` — which runs a full Flutter layout + // pass (walks the RenderObject tree, re-measures every child). For pure + // content writes the viewport geometry is unchanged; only the buffer + // grew. Layout was the hot path in TUI-heavy scenarios (Claude Code + // spinners, counters, vim/nano cursor storms), producing 5-10x slower + // rendering than xterm.js on web. We now update scroll bounds directly + // and trigger only a repaint. Geometry changes (widget resize, font + // size change, viewport resize) still go through the normal + // `performLayout` path via `markNeedsLayout` calls elsewhere. + if (hasSize && _viewportSize != null) { + _offset.applyContentDimensions(0, _maxScrollExtent); + if (_stickToBottom) { + final delta = _maxScrollExtent - _scrollOffset; + if (delta.abs() > 0.001) { + _offset.correctBy(delta); + } + } + } + markNeedsPaint(); + if (hasSize) _notifyEditableRect(); } void _onControllerUpdate() { diff --git a/lib/src/utils/debugger.dart b/lib/src/utils/debugger.dart index 303bbbbe..7fc5518f 100644 --- a/lib/src/utils/debugger.dart +++ b/lib/src/utils/debugger.dart @@ -106,6 +106,16 @@ class _TerminalDebuggerHandler implements EscapeHandler { onCommand('tab'); } + @override + void cursorForwardTab(int amount) { + onCommand('cursorForwardTab($amount)'); + } + + @override + void cursorBackwardTab(int amount) { + onCommand('cursorBackwardTab($amount)'); + } + @override void lineFeed() { onCommand('lineFeed'); diff --git a/test/src/regression/csi_1k_erase_test.dart b/test/src/regression/csi_1k_erase_test.dart new file mode 100644 index 00000000..c878e4f9 --- /dev/null +++ b/test/src/regression/csi_1k_erase_test.dart @@ -0,0 +1,77 @@ +// Regression: `CSI 1 K` (Erase in Line from start to cursor) must include +// the cell UNDER the cursor, per VT100 / xterm docs. +// +// Symptom fixed: Claude Code's "Do you want to…" interactive menu drew +// garbage/leftover characters on mobile + desktop (xterm.dart) but worked +// correctly in xterm.js on web. Each menu frame uses ~36 CSI 1 K calls; +// each one previously left exactly one residual cell of stale text. +// +// Root cause: eraseLineToCursor called eraseRange(0, _cursorX) but eraseRange +// end is exclusive, so column == _cursorX was never cleared. + +import 'package:test/test.dart'; +import 'package:xterm/core.dart'; + +/// Read the codepoint at a specific column on row 0. +int cp(Terminal t, int col) => t.buffer.lines[0].getCodePoint(col); + +void main() { + group('CSI 1 K — erase line to cursor (inclusive)', () { + test('erases cells [0..cursor] inclusive (mid-line)', () { + final t = Terminal(maxLines: 100); + t.resize(20, 5); + t.write('ABCDEFGHIJ'); // cells 0..9 filled + t.write('\x1b[6G'); // CUP column 6 1-indexed → col 5 0-indexed (on 'F') + t.write('\x1b[1K'); // Erase from start to cursor (inclusive) + + // Cols 0..5 should all be erased (codepoint 0). Cols 6..9 untouched. + for (var c = 0; c <= 5; c++) { + expect(cp(t, c), 0, reason: 'col $c should be erased'); + } + expect(cp(t, 6), 'G'.codeUnitAt(0)); + expect(cp(t, 7), 'H'.codeUnitAt(0)); + expect(cp(t, 8), 'I'.codeUnitAt(0)); + expect(cp(t, 9), 'J'.codeUnitAt(0)); + }); + + test('CSI 1 K at column 0 erases exactly that cell', () { + final t = Terminal(maxLines: 100); + t.resize(10, 3); + t.write('ABCDE'); + t.write('\x1b[1G'); // cursor to col 0 + t.write('\x1b[1K'); + + expect(cp(t, 0), 0, reason: "'A' should be erased"); + expect(cp(t, 1), 'B'.codeUnitAt(0)); + expect(cp(t, 4), 'E'.codeUnitAt(0)); + }); + + test('CSI 1 K at last column erases the whole line', () { + final t = Terminal(maxLines: 100); + t.resize(10, 3); + t.write('ABCDEFGHIJ'); + t.write('\x1b[10G'); // col 10 1-indexed → col 9 0-indexed (on 'J') + t.write('\x1b[1K'); + + // All cells 0..9 should be erased. + for (var c = 0; c < 10; c++) { + expect(cp(t, c), 0, reason: 'col $c should be erased'); + } + }); + + test('CSI 0 K (right/default) is unaffected by the fix', () { + final t = Terminal(maxLines: 100); + t.resize(10, 3); + t.write('ABCDEFGHIJ'); + t.write('\x1b[6G'); // col 5 0-indexed (on 'F') + t.write('\x1b[0K'); + + // Cols 0..4 untouched, 5..9 erased + expect(cp(t, 0), 'A'.codeUnitAt(0)); + expect(cp(t, 4), 'E'.codeUnitAt(0)); + for (var c = 5; c < 10; c++) { + expect(cp(t, c), 0, reason: 'col $c should be erased'); + } + }); + }); +} diff --git a/test/src/regression/nano_vim_cursor_test.dart b/test/src/regression/nano_vim_cursor_test.dart new file mode 100644 index 00000000..b85ea79b --- /dev/null +++ b/test/src/regression/nano_vim_cursor_test.dart @@ -0,0 +1,291 @@ +// T67 regression tests — nano/vim cursor misalignment over raw SSH. +// +// Context: xterm (Dart port) 4.0.0 miscounts cursor position in ncurses +// applications like nano and vim when run without tmux. Diagnostics showed +// PTY size and TERM propagation are correct; web (xterm.js) renders the same +// sequences correctly. So the bug is in this parser/buffer pair. +// +// Each test targets a hypothesis from specs/t67-xterm-fork.md §3.1. +// A failing test localizes the bug; a passing test rules that hypothesis out. +// +// Run from repo root: dart test test/src/regression/nano_vim_cursor_test.dart + +import 'package:test/test.dart'; +import 'package:xterm/core.dart'; + +/// Helper: stringify a row of the main buffer, trimming trailing spaces. +String row(Terminal t, int y) => t.buffer.lines[y].toString().trimRight(); + +/// Helper: fill each visible row with a unique marker so we can see what moves. +void fillRows(Terminal t, {int rows = 24}) { + for (var i = 0; i < rows; i++) { + t.write('L$i'); + if (i < rows - 1) t.write('\r\n'); + } +} + +void main() { + group('T67 — scroll region & cursor regression (nano/vim bug)', () { + // ---- H1: DECSTBM + RI (Reverse Index) at top margin ---------- + // + // Issue #94 symptom: "when cursor hits top of screen, vim does not + // scroll up properly. Only first line content changed, other lines + // remain unchanged." + // + // Classic trigger: RI (ESC M) at top of scroll region should scroll + // the entire region DOWN by one line — top row becomes blank, all + // other rows shift down, bottom row drops off. + + test('RI at top of screen (no DECSTBM) scrolls full screen down', () { + final t = Terminal(maxLines: 100); + t.resize(80, 24); + fillRows(t, rows: 24); + // Cursor is now past L23 on row 23. Move home. + t.write('\x1b[H'); // CUP 1;1 → (0, 0) + expect(t.buffer.cursorX, 0); + expect(t.buffer.cursorY, 0); + + t.write('\x1bM'); // RI — should scroll region (full screen) down 1 + expect(row(t, 0), isEmpty, reason: 'top row must be blank after RI'); + expect(row(t, 1), 'L0', reason: 'L0 must shift from row 0 to row 1'); + expect(row(t, 23), 'L22', reason: 'L22 shifts from row 22 to row 23'); + // L23 falls off the bottom of the region (can reappear in scrollback + // on main screen — we don't assert on that here). + }); + + test('RI at top of DECSTBM region (rows 2..10) scrolls only the region', () { + final t = Terminal(maxLines: 100); + t.resize(80, 24); + fillRows(t, rows: 24); + + // Set scroll region to rows 2..10 (1-based in the wire protocol → CSI 2;10r). + t.write('\x1b[2;10r'); + // CUP to top of region (row 2, 1-based = (0, 1) zero-based). + t.write('\x1b[2;1H'); + expect(t.buffer.cursorY, 1); + + t.write('\x1bM'); // RI at top of region → scrollDown(1) + + // Row 0 is OUTSIDE the region — must not change. + expect(row(t, 0), 'L0', reason: 'row 0 is above region, untouched'); + // Row 1 (top of region) must now be blank. + expect(row(t, 1), isEmpty, reason: 'top-of-region blanked by RI scroll'); + // Rows 2..9 shift down: row 2 gets what row 1 had (L1), row 3 gets L2, + // etc., row 9 gets L8. + expect(row(t, 2), 'L1'); + expect(row(t, 3), 'L2'); + expect(row(t, 9), 'L8'); + // Row 10 is OUTSIDE the region (bottom was 10, 1-based) — untouched. + expect(row(t, 10), 'L10', reason: 'row 10 is below region, untouched'); + expect(row(t, 23), 'L23'); + }); + + // ---- H1b: IND (Index, ESC D / LF) at bottom margin ---------- + // + // Mirror image: IND at bottom of region should scroll region UP by 1. + // Buggy buffers often drop content outside region or fail to scroll. + + test('IND at bottom of DECSTBM region scrolls only the region up', () { + final t = Terminal(maxLines: 100); + t.resize(80, 24); + fillRows(t, rows: 24); + + t.write('\x1b[2;10r'); // region rows 2..10 (1-based) + t.write('\x1b[10;1H'); // CUP to bottom of region (0-based y=9) + expect(t.buffer.cursorY, 9); + + t.write('\x1bD'); // IND — scroll region up 1 + + expect(row(t, 0), 'L0', reason: 'above region untouched'); + expect(row(t, 1), 'L2', reason: 'L2 shifts from row 2 up to row 1'); + expect(row(t, 8), 'L9', reason: 'L9 shifts from row 9 up to row 8'); + expect(row(t, 9), isEmpty, reason: 'bottom of region blanked'); + expect(row(t, 10), 'L10', reason: 'below region untouched'); + }); + + // ---- H2: Save/Restore cursor (DECSC / DECRC) ---------- + // + // Nano saves the cursor before repainting the status bar, moves, + // then restores. If DECRC doesn't return to the exact saved spot, + // subsequent writes land on the wrong row/col. + + test('DECSC + writes + DECRC restores exact cursor position', () { + final t = Terminal(maxLines: 100); + t.resize(80, 24); + + // Move to (col=10, row=5) — 0-based: (9, 4). + t.write('\x1b[5;10H'); + expect(t.buffer.cursorX, 9); + expect(t.buffer.cursorY, 4); + + t.write('\x1b7'); // DECSC + + // Wander: jump far away and write. + t.write('\x1b[20;40H'); + t.write('X'); + + t.write('\x1b8'); // DECRC — back to (9, 4) + expect(t.buffer.cursorX, 9); + expect(t.buffer.cursorY, 4); + }); + + test('DECSC/DECRC survives alternate-screen toggle', () { + final t = Terminal(maxLines: 100); + t.resize(80, 24); + + t.write('\x1b[5;10H'); // (9, 4) + t.write('\x1b7'); // save + + t.write('\x1b[?1049h'); // enter alt screen (nano does this) + t.write('\x1b[20;40H'); + t.write('junk in alt screen'); + t.write('\x1b[?1049l'); // leave alt screen + + t.write('\x1b8'); // restore + expect(t.buffer.cursorX, 9); + expect(t.buffer.cursorY, 4); + }); + + // ---- H3: Alternate screen buffer (1049) ---------- + // + // nano enters alt screen on start, paints UI there, and leaves on exit. + // Main screen must be unchanged after leave. + + test('alt-screen 1049h/l preserves main buffer content', () { + final t = Terminal(maxLines: 100); + t.resize(80, 24); + fillRows(t, rows: 24); + + t.write('\x1b[?1049h'); // enter alt + t.write('\x1b[2J'); // clear + t.write('\x1b[H'); + t.write('inside alt screen'); + t.write('\x1b[?1049l'); // leave alt + + // Main buffer should still have L0..L23 intact. + expect(row(t, 0), 'L0'); + expect(row(t, 5), 'L5'); + expect(row(t, 23), 'L23'); + }); + + // ---- H4: Autowrap / pending-wrap state (DECAWM) ---------- + // + // When cursor is at column N (last col) after writing there, the cursor + // should "hover" — next printable char wraps, but control seqs like CR, + // BS, cursor movement do NOT trigger the deferred wrap. + + test('writing exactly viewWidth chars leaves cursor in pending-wrap', () { + final t = Terminal(maxLines: 100); + t.resize(10, 5); + t.write('\x1b[H'); + + t.write('0123456789'); // exactly 10 chars on a 10-wide terminal + // After this, cursor is conceptually "past" col 9 but wrap is pending. + // The 10th char must be on row 0, and the next write should land on row 1. + expect(row(t, 0), '0123456789'); + expect(t.buffer.cursorY, 0, + reason: 'cursor must still report row 0 (wrap pending, not applied)'); + + t.write('X'); // triggers wrap + expect(row(t, 1).startsWith('X'), isTrue); + expect(t.buffer.cursorY, 1); + }); + + test('CR after full-width write does NOT itself wrap', () { + final t = Terminal(maxLines: 100); + t.resize(10, 5); + t.write('\x1b[H'); + t.write('0123456789'); + t.write('\r'); // carriage return only + + // CR should move cursor to col 0 of the SAME row, not advance to row 1. + expect(t.buffer.cursorX, 0); + expect(t.buffer.cursorY, 0); + }); + + // ---- H6: DSR (Device Status Report) / CPR (Cursor Position Report) ---- + // + // THE BUG (discovered 2026-04-13): + // emitter.cursorPosition() sends buffer.cursorX/cursorY as-is, but + // those are 0-indexed. VT100 CPR response is 1-indexed (CSI row;col R). + // Result: every CPR reply is off-by-one in both axes. + // + // Nano queries CPR at startup and after certain operations, uses the + // reply to sync its internal cursor model. Off-by-one CPR → nano's + // model is permanently shifted → "cursor edits at different position". + // + // Tmux doesn't forward CPR to xterm.dart (it answers from its own state), + // which is why nano-in-tmux works. vt100 terminfo typically doesn't + // trigger CPR queries, which is why vt100 mode looks fine. + + test('CPR reports 1-indexed position at home (should be 1;1, not 0;0)', () { + final replies = []; + final t = Terminal(maxLines: 100, onOutput: replies.add); + t.resize(80, 24); + + t.write('\x1b[H'); // CUP home → (0,0) internal + replies.clear(); + t.write('\x1b[6n'); // DSR: request cursor position + + expect(replies, hasLength(1)); + expect(replies.first, '\x1b[1;1R', + reason: 'CPR at home must be CSI 1;1 R (1-indexed per VT100)'); + }); + + test('CPR reports 1-indexed after CUP 5;10', () { + final replies = []; + final t = Terminal(maxLines: 100, onOutput: replies.add); + t.resize(80, 24); + + t.write('\x1b[5;10H'); // CUP row=5, col=10 (1-indexed on wire) + replies.clear(); + t.write('\x1b[6n'); + + expect(replies.first, '\x1b[5;10R', + reason: 'CPR must mirror the 1-indexed coordinates CUP used'); + }); + + test('CPR round-trip: CUP → CPR → CUP returns to same visible row', () { + final replies = []; + final t = Terminal(maxLines: 100, onOutput: replies.add); + t.resize(80, 24); + + t.write('\x1b[12;40H'); // go to middle of screen + final startY = t.buffer.cursorY; + final startX = t.buffer.cursorX; + replies.clear(); + + t.write('\x1b[6n'); // query + // Parse reply: CSI row;col R (1-indexed) + final reply = replies.first; + final match = RegExp(r'\x1b\[(\d+);(\d+)R').firstMatch(reply); + expect(match, isNotNull); + final reportedRow = int.parse(match!.group(1)!); + final reportedCol = int.parse(match.group(2)!); + + // Now feed the reply back as a CUP (as if an app did CPR-then-restore). + t.write('\x1b[$reportedRow;${reportedCol}H'); + + expect(t.buffer.cursorY, startY, + reason: 'CPR round-trip must land on the same row'); + expect(t.buffer.cursorX, startX, + reason: 'CPR round-trip must land on the same column'); + }); + + // ---- H5: CUP (Cursor Position) with DECSTBM ---------- + // + // CUP is defined to use absolute screen coordinates unless DECOM (origin + // mode) is enabled. Nano does NOT enable DECOM but does set DECSTBM. + // So CUP 1;1 must go to (0, 0) regardless of scroll region. + + test('CUP without DECOM is absolute even under DECSTBM', () { + final t = Terminal(maxLines: 100); + t.resize(80, 24); + + t.write('\x1b[5;15r'); // scroll region 5..15 (1-based) + t.write('\x1b[1;1H'); // CUP home + expect(t.buffer.cursorX, 0); + expect(t.buffer.cursorY, 0, reason: 'CUP home is absolute (0,0)'); + }); + }); +}