Skip to content

Sticky zone corruption on terminal resize/zoom #133

@bananabot9000

Description

@bananabot9000

Symptoms

When zooming or resizing the terminal, the sticky zone (status line, editor, attachments) corrupts the scrollback history. Old sticky content "leaks" into the scrollback buffer and cannot be scrolled away. The severity scales with zoom level — higher zoom (fewer columns) causes more frequent and visible corruption.

Reproduction:

  1. Start a session with a visible status line (e.g. waiting/thinking state)
  2. Zoom in (Cmd+= on macOS) or resize the terminal window
  3. Observe: previous sticky zone content appears in scrollback history
  4. Continue interacting — corruption accumulates with each resize

Impact: This is a blocker for screensharing/workshop use cases where zoom is essential for audience visibility.

Root cause: Multiple places in the rendering code calculate line wrapping using raw string .length instead of visible character width. ANSI escape codes (colour, bold, inverse) and multi-cell characters (emoji) inflate the measured width, causing the cursor position and sticky zone math to diverge from what the terminal actually renders.


Guidance

Six specific issues found across renderer.ts, terminal.ts, and StatusLineBuilder.ts:

1. renderer.ts:30 — ANSI codes counted in wrap calculation

cursorRow += Math.max(1, Math.ceil(lines[i].length / columns));

lines[i] contains the prompt prefix + editor content. If there are ANSI codes in the prefix, .length over-counts. The cursor row ends up too high.

Fix: Strip ANSI before measuring, same pattern as terminal.ts:273:

const visible = lines[i].replace(/\x1b\[[0-9;]*m/g, '').length;
cursorRow += Math.max(1, Math.ceil(visible / columns));

2. renderer.ts:28-31 — Missing within-line wrap for cursor row

The loop counts screen rows for lines above the cursor, but doesn't add wrapping for the cursor's own line:

let cursorRow = 0;
for (let i = 0; i < editor.cursor.row; i++) {
  cursorRow += Math.max(1, Math.ceil(lines[i].length / columns));
}

If the cursor is on column 150 in an 80-col terminal, it's on row 1 within its line, but cursorRow doesn't reflect that.

Fix: Add the cursor line's own wrapping:

cursorRow += Math.floor(cursorCol / columns);

3. terminal.ts:312-316 — Editor lines measured with raw .length

editorScreenLines += Math.max(1, Math.ceil(this.editorContent.lines[i].length / columns));

Same as Issue 1 but in buildSticky(). If editor lines contain ANSI (e.g. from prompt colouring), the screen line count is inflated. This directly affects stickyLineCount (line 332), which is used by clearStickyZone to know how much to erase.

4. terminal.ts:322-325cursorLinesFromBottom also uses raw .length

this.cursorLinesFromBottom += Math.max(1, Math.ceil(this.editorContent.lines[i].length / columns));

Same pattern, same bug. The cursor-up movement after rendering is calculated wrong if lines contain ANSI codes.

5. terminal.ts:99-110clearStickyZone uses stale metrics on resize

private clearStickyZone(): string {
  output += cursorDown(this.cursorLinesFromBottom);
  output += cursorUp(this.stickyLineCount - 1);
  // ...
}

stickyLineCount and cursorLinesFromBottom were computed with the previous column width. On resize, process.stdout.columns already reflects the new width, but these cached values haven't been recalculated. If columns decreased (zoom in), lines wrap more → the actual sticky zone is taller than stickyLineCount says → clearDown doesn't erase enough → old content leaks into scrollback.

6. ClaudeCli.ts:966-979 — Resize race between pause and redraw

process.stdout.on('resize', () => {
  this.term.paused = true;
  clearTimeout(this.resizeTimer);
  this.resizeTimer = setTimeout(() => {
    this.term.paused = false;
    this.redraw();
  }, 300);
});

During the 300ms debounce, process.stdout.columns is already the new value but the sticky zone hasn't been cleared with the old metrics yet. clearStickyZone is called inside redraw()refresh(), but by then stickyLineCount was calculated with old columns, and new content uses new columns. The clear and draw are using different column widths.

Fix: Clear sticky zone immediately on resize (using old metrics), then pause, then debounce the redraw:

process.stdout.on('resize', () => {
  // Clear with old metrics BEFORE columns change takes effect
  this.term.clearAndPause();  // new method: clearStickyZone + pause
  clearTimeout(this.resizeTimer);
  this.resizeTimer = setTimeout(() => {
    this.term.paused = false;
    this.redraw();
  }, 300);
});

Suggested approach

StatusLineBuilder already tracks visible width correctly (separate text(), emoji(), ansi() methods). The problem is that renderer.ts and parts of terminal.ts bypass this and use raw .length. A shared visibleWidth(str) helper (strip ANSI, count emoji as 2) applied consistently would fix Issues 1, 3, and 4. Issues 2 and 5-6 are logic fixes.

Priority for workshop: Issues 1-4 (wrong math) > Issue 5-6 (resize race). The wrong math causes corruption even without resize — resize just makes it worse.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions