Skip to content

fix: ghost cursor at (0,0) and ESC k title-set payload leak#16

Merged
diegosouzapw merged 1 commit into
mainfrom
fix/upstream-issues-batch-renderer
May 23, 2026
Merged

fix: ghost cursor at (0,0) and ESC k title-set payload leak#16
diegosouzapw merged 1 commit into
mainfrom
fix/upstream-issues-batch-renderer

Conversation

@diegosouzapw
Copy link
Copy Markdown
Owner

Summary

Bundles two renderer/parser fixes for unrelated upstream issues, ported as a single PR because the changes don't touch overlapping files.

coder#122 — Ghost cursor stuck at (0,0)

The renderer skipped redrawing the previous cursor row in two cases:

  • when lastCursorPosition.y === cursor.y (cursor moved on the same row)
  • when buffer.isRowDirty(lastCursorPosition.y) (relied on the regular dirty pass)

Both shortcuts left a stale cursor glyph at the initial (0,0) position whenever later content moved the cursor on the same row using positional sequences (no cell content changing). Always redrawing the previous cursor row on cursorMoved is a trivial extra-render cost and guarantees the ghost is erased.

coder#153ESC k <text> ESC \ leaks payload as visible text

Ghostty WASM (commit 5714ed07) does not consume the GNU screen / tmux title-setting sequence. The parser logs unimplemented ESC action: ESC k and then prints <text> onto the grid plus consumes the trailing ESC \.

Fix: pre-filter input in Terminal.write to strip ESC k … (string and Uint8Array paths) before reaching WASM. OSC 0/1/2 title-set (ESC ] …) is untouched.

Attribution

Test plan

Risk

Low. The coder#122 fix adds at most one extra row-redraw per cursor move (negligible). The coder#153 strip runs a regex on every write payload but only matches in the rare ESC k path; OSC and other sequences pass through untouched.

Related issues observed as also-resolved by prior PRs

Posted as a separate rollup at _tasks/upstream-issues/_rollup-2026-05-23.md (gitignored). Notably coder#126 (line-drawing char gap) is the same root cause as #146a (font metric device-pixel alignment), already shipped in PR #6 — no new code needed.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses two main issues: a 'ghost cursor' bug and the leaking of 'ESC k' title sequences onto the terminal grid. In lib/renderer.ts, the logic was updated to always redraw the old cursor row when the cursor moves, ensuring the previous glyph is erased. In lib/terminal.ts, a stripUnimplementedTitleSequences method was introduced to filter out title sequences before they reach the WASM parser. Feedback indicates that the filtering logic is stateless and susceptible to failure if sequences are split across data chunks. Additionally, the renderer change introduces redundant draws for horizontal cursor movements, and there is a logic inconsistency between the string and byte-level implementations of the sequence filter.

Comment thread lib/terminal.ts
Comment on lines +563 to +605
private stripUnimplementedTitleSequences(data: string | Uint8Array): string | Uint8Array {
if (typeof data === 'string') {
// ESC = \x1b, ST = \x1b\x5c (ESC followed by backslash), BEL = \x07
return data.replace(/\x1bk[^\x1b\x07]*(?:\x1b\\|\x07)/g, '');
}
// Byte-level scan for Uint8Array. We only allocate a copy when we
// actually find a sequence to strip.
let i = 0;
let writeIdx = -1;
let out: Uint8Array | null = null;
while (i < data.length) {
if (data[i] === 0x1b && i + 1 < data.length && data[i + 1] === 0x6b) {
// Found ESC k — scan forward to ESC \ or BEL
let j = i + 2;
while (j < data.length) {
if (data[j] === 0x07) {
j++;
break;
}
if (data[j] === 0x1b && j + 1 < data.length && data[j + 1] === 0x5c) {
j += 2;
break;
}
// No terminator yet — keep scanning (handles split writes if WASM
// ever assembles them; defensively bail if we hit another ESC k).
j++;
}
if (out === null) {
out = new Uint8Array(data.length);
out.set(data.subarray(0, i));
writeIdx = i;
}
i = j;
continue;
}
if (out !== null) {
out[writeIdx++] = data[i];
}
i++;
}
if (out === null) return data;
return out.subarray(0, writeIdx);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The stripUnimplementedTitleSequences method is stateless and does not handle escape sequences split across multiple write() calls. If a chunk ends mid-sequence, the remainder of the payload will be passed to the WASM terminal as plain text in the next call, leading to the "leak" this PR intends to fix. For a robust solution, this filtering should be integrated into a stateful parser or the WASM parser itself should be updated to handle these sequences.

Comment thread lib/terminal.ts
Comment on lines +582 to +585
if (data[j] === 0x1b && j + 1 < data.length && data[j + 1] === 0x5c) {
j += 2;
break;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This check for the ST terminator (ESC \) will fail if the sequence is split across chunks such that the ESC is at the end of the current Uint8Array. In this case, the ESC will be incorrectly treated as part of the payload and stripped, and the subsequent \ in the next chunk will be passed to the terminal as literal text, potentially corrupting the output stream.

Comment thread lib/renderer.ts
Comment on lines +316 to 331
if (cursorMoved && !forceAll) {
// Always redraw the OLD cursor row to erase the previous cursor
// glyph, whether or not the row is dirty and whether or not it
// differs from the new cursor row (issue #122: ghost cursor
// persisted at the initial (0,0) position because the prior
// logic skipped the redraw when the row was already dirty —
// assuming the regular dirty pass would handle it — but the
// regular dirty pass only runs when buffer cells changed, not
// when the cursor moved across unchanged cells. A double redraw
// when the row is both dirty AND cursor-moved is a trivial perf
// cost compared to the visual correctness gain.).
const line = buffer.getLine(this.lastCursorPosition.y);
if (line) {
this.renderLine(line, this.lastCursorPosition.y, dims.cols);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current logic results in redundant rendering when the cursor moves horizontally on the same row. In this case, cursor.y and this.lastCursorPosition.y are identical. The preceding block (lines 309-315) already ensures the row is rendered if it is not dirty. By removing the row inequality check, this row is now rendered twice in a single frame. Adding a check to ensure the rows are different would be more efficient.

      if (cursorMoved && !forceAll && this.lastCursorPosition.y !== cursor.y) {
        // Redraw the OLD cursor row to erase the previous cursor glyph
        // when the cursor has moved to a different row. If the cursor
        // moved horizontally on the same row, that row is already
        // handled by the redraw logic above or the regular dirty pass.
        const line = buffer.getLine(this.lastCursorPosition.y);
        if (line) {
          this.renderLine(line, this.lastCursorPosition.y, dims.cols);
        }
      }

Comment thread lib/terminal.ts
private stripUnimplementedTitleSequences(data: string | Uint8Array): string | Uint8Array {
if (typeof data === 'string') {
// ESC = \x1b, ST = \x1b\x5c (ESC followed by backslash), BEL = \x07
return data.replace(/\x1bk[^\x1b\x07]*(?:\x1b\\|\x07)/g, '');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The regex used for string input ([^\x1b\x07]*) will stop at any ESC character, even if it is not followed by a backslash (the ST terminator). In contrast, the Uint8Array implementation (lines 577-589) correctly continues scanning until it finds a valid ST (ESC \) or BEL. This inconsistency means that certain payloads containing internal escape sequences will be handled differently depending on the input type.

Suggested change
return data.replace(/\x1bk[^\x1b\x07]*(?:\x1b\\|\x07)/g, '');
return data.replace(/\x1bk.*?(?:\x1b\\|\x07)/gs, '');

Bundles two renderer/parser fixes that don't share scope:

**coder#122 — ghost cursor at (0,0) on init.** The renderer skipped redrawing
the previous cursor row when the new cursor stayed on the SAME row as
the previous frame. The cursor-line redraw at the top of the cursor-
moved block only fires for the NEW cursor row; the symmetric branch
for the OLD cursor row had a `lastCursorPosition.y !== cursor.y` guard
that skipped same-row moves and an `!isRowDirty` guard that skipped any
move where the regular dirty pass was already going to redraw the row.
The combination left a stale cursor glyph at the initial (0,0) position
whenever later content moved the cursor on the same row via positional
sequences (no cell content changing on row 0). Always redrawing the
previous cursor row on cursorMoved is a trivial extra-render cost and
guarantees the ghost is erased.

**coder#153 — ESC k title sequence leaks onto the grid.** Ghostty WASM
(commit 5714ed07) does not consume `ESC k <text> ESC \` — the GNU
screen / tmux title-setting extension. The parser logs `unimplemented
ESC action: ESC k` and then prints `<text>` onto the grid, also
consuming the trailing `ESC \`. Same for the BEL-terminated variant.
We pre-filter input in `Terminal.write` to strip ESC k sequences before
they reach WASM. Implemented for both `string` and `Uint8Array` (the
Uint8Array path does a single-pass byte scan and only allocates when a
sequence is actually found). OSC 0/1/2 title-setting (`ESC ] …`) is
untouched and continues to be consumed by the WASM parser as before.

Adds four regression tests for the title-set behaviour (string input
with ST, BEL terminator, OSC 0 untouched, Uint8Array equivalence).
The cursor-ghost fix is structural and cannot be asserted in a
headless render context; manual smoke test pending in bun run dev.

Reported-by: mats16 (coder#122)
Reported-by: Fisher-Wang (coder#153)
@diegosouzapw diegosouzapw force-pushed the fix/upstream-issues-batch-renderer branch from 7899e08 to 51a33ab Compare May 23, 2026 16:57
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
47.9% Duplication on New Code (required ≤ 3%)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@diegosouzapw diegosouzapw merged commit 31ed228 into main May 23, 2026
1 of 2 checks passed
@diegosouzapw diegosouzapw deleted the fix/upstream-issues-batch-renderer branch May 23, 2026 16:59
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