Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions .github/workflows/autotag.yml

This file was deleted.

44 changes: 0 additions & 44 deletions .github/workflows/ci.yml

This file was deleted.

31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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].
Expand Down
6 changes: 5 additions & 1 deletion lib/src/core/buffer/buffer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion lib/src/core/escape/emitter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions lib/src/core/escape/handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions lib/src/core/escape/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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/
Expand Down
11 changes: 11 additions & 0 deletions lib/src/core/tabs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
29 changes: 29 additions & 0 deletions lib/src/terminal.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 20 additions & 2 deletions lib/src/ui/render.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
10 changes: 10 additions & 0 deletions lib/src/utils/debugger.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
77 changes: 77 additions & 0 deletions test/src/regression/csi_1k_erase_test.dart
Original file line number Diff line number Diff line change
@@ -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');
}
});
});
}
Loading