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:
- Start a session with a visible status line (e.g. waiting/thinking state)
- Zoom in (Cmd+= on macOS) or resize the terminal window
- Observe: previous sticky zone content appears in scrollback history
- 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-325 — cursorLinesFromBottom 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-110 — clearStickyZone 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.
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:
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
.lengthinstead 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, andStatusLineBuilder.ts:1.
renderer.ts:30— ANSI codes counted in wrap calculationlines[i]contains the prompt prefix + editor content. If there are ANSI codes in the prefix,.lengthover-counts. The cursor row ends up too high.Fix: Strip ANSI before measuring, same pattern as
terminal.ts:273:2.
renderer.ts:28-31— Missing within-line wrap for cursor rowThe loop counts screen rows for lines above the cursor, but doesn't add wrapping for the cursor's own line:
If the cursor is on column 150 in an 80-col terminal, it's on row 1 within its line, but
cursorRowdoesn't reflect that.Fix: Add the cursor line's own wrapping:
3.
terminal.ts:312-316— Editor lines measured with raw.lengthSame as Issue 1 but in
buildSticky(). If editor lines contain ANSI (e.g. from prompt colouring), the screen line count is inflated. This directly affectsstickyLineCount(line 332), which is used byclearStickyZoneto know how much to erase.4.
terminal.ts:322-325—cursorLinesFromBottomalso uses raw.lengthSame pattern, same bug. The cursor-up movement after rendering is calculated wrong if lines contain ANSI codes.
5.
terminal.ts:99-110—clearStickyZoneuses stale metrics on resizestickyLineCountandcursorLinesFromBottomwere computed with the previous column width. On resize,process.stdout.columnsalready 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 thanstickyLineCountsays →clearDowndoesn't erase enough → old content leaks into scrollback.6.
ClaudeCli.ts:966-979— Resize race between pause and redrawDuring the 300ms debounce,
process.stdout.columnsis already the new value but the sticky zone hasn't been cleared with the old metrics yet.clearStickyZoneis called insideredraw()→refresh(), but by thenstickyLineCountwas 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:
Suggested approach
StatusLineBuilderalready tracks visible width correctly (separatetext(),emoji(),ansi()methods). The problem is thatrenderer.tsand parts ofterminal.tsbypass this and use raw.length. A sharedvisibleWidth(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.