Skip to content

Buffer reflow produces duplicate/orphan lines when narrowing terminal #494

Description

@tobypang-agent

Summary

When a terminal is resized narrower such that existing content must soft-wrap, the buffer reflow logic in Buffer.swift can produce duplicate (orphan) lines that persist even when the terminal is resized back to its original width.

Steps to reproduce

  1. Create a SwiftTerm-based terminal (e.g., LocalProcessTerminalView) with scrollback enabled
  2. In the shell, have a line of text with no hard newline that fits within the current width (e.g., the zsh prompt % 12345 at 80 columns)
  3. Resize the terminal narrower so the line must soft-wrap (e.g., from 80 columns to 6 columns)
  4. Observe the display

Expected: The line wraps correctly into 2 lines:

% 1234
5

Actual: 3 lines appear, with the first line duplicated:

% 1234
% 1234
5
  1. Resize the terminal back to the original width (80 columns)
  2. The orphan line does not disappear — it persists as a permanent artifact

Diagnostic evidence

To isolate the issue to SwiftTerm's reflow (ruling out shell SIGWINCH redraw), I added return at the top of zsh's TRAPWINCH handler to suppress all shell-side redraw. The orphan still appears, confirming the bug originates in the reflow code itself, not in the shell's response to SIGWINCH.

Root cause analysis

I've identified two interacting bugs in Buffer.swift that together produce this behavior:

Bug 1: Cursor-line skip + post-reflow truncation = data loss

In reflowNarrower() (line ~916-922), lines containing the cursor are intentionally skipped:

// If these lines contain the cursor don't touch them, the program will handle fixing up
// wrapped lines with the cursor
let absoluteY = yBase + self.y
if absoluteY >= y && absoluteY < y + wrappedLines.count {
    continue
}

This skip is by design — the idea is to let the running program (shell) handle cursor-adjacent content. However, after reflow() returns, Buffer.resize() unconditionally truncates all buffer lines to newCols (line ~505-508):

if cols > newCols {
    for i in 0..<lines.maxLength {
        lines [i].resize (cols: newCols, fillData: CharData.Null)
    }
}

This truncation destroys the overflow content on the skipped cursor line. The characters beyond newCols are permanently lost from the buffer. The skipped line now contains truncated content (e.g., "% 1234" instead of "% 12345") with no corresponding wrapped continuation line and no isWrapped marker on any adjacent line.

When the shell subsequently redraws in response to SIGWINCH, it outputs the full prompt/input at the current cursor position. This creates new properly-wrapped lines below or at the cursor. But the truncated original line remains in the buffer as an orphan — it was never reflowed, has no isWrapped association, and is invisible to reflowWider() when the terminal is later widened.

Bug 2: self.y drift corrupts cursor-skip checks

In reflowNarrower(), lines are processed bottom-to-top. For each line that needs reflowing, the viewport adjustment code modifies self.y:

// line ~994-1015
var viewportAdjustments = linesToAdd - trimmedLines
while viewportAdjustments > 0 {
    viewportAdjustments -= 1
    if yBase == 0 {
        if self.y < newRows - 1 {
            self.y += 1       // <-- increments cursor row
            lines.pop ()
        } else {
            yBase += 1
            yDisp += 1
        }
    } else {
        // ...
    }
}

Since processing goes bottom-to-top, the first lines processed are below the cursor. Their insertions are below the cursor, so self.y should not change — the cursor's absolute position is unaffected by insertions below it. But the code increments self.y unconditionally.

This causes absoluteY (yBase + self.y) to drift upward with each processed line. By the time the loop reaches the actual cursor line, absoluteY no longer matches the cursor's true position. The cursor-skip check (absoluteY >= y && absoluteY < y + wrappedLines.count) may then:

  • Fail to skip the cursor line (because absoluteY has drifted past it), causing double-handling when the shell also redraws
  • Incorrectly skip a non-cursor line (because absoluteY now points to a different line), causing content loss on an unrelated line

How the bugs interact

  1. reflowNarrower processes bottom lines first, each incrementing self.y (Bug 2)
  2. By the time it reaches the cursor line, absoluteY has drifted — cursor-skip check produces wrong results
  3. If the cursor line IS skipped (correctly or not), the post-reflow truncation destroys its overflow content (Bug 1)
  4. The truncated line persists as an orphan with no isWrapped marker
  5. Shell SIGWINCH redraw creates new wrapped content at a potentially different position
  6. reflowWider cannot merge the orphan because it has no isWrapped association

Affected code

  • Sources/SwiftTerm/Buffer.swift:
    • reflowNarrower() — cursor skip logic (line ~916-922) and viewport adjustment (line ~994-1015)
    • resize() — post-reflow line truncation (line ~505-508)
    • reflow() — dispatcher (line ~1079-1092)

Suggested fix directions

Option A: Reflow the cursor line (recommended)

Remove the cursor-line skip in reflowNarrower. Instead, reflow the cursor line like any other line and adjust self.x/self.y to track the cursor's new position within the reflowed content. This is the approach used by some other terminal emulators and avoids the data loss entirely.

The cursor position after reflow can be calculated:

newY = y + (absoluteCursorOffset / newCols)
newX = absoluteCursorOffset % newCols

where absoluteCursorOffset is the cursor's character offset from the start of the wrapped line group.

Option B: Preserve skipped-line content

If the cursor-line skip is retained, the overflow content must be preserved before truncation. After reflow() returns but before lines[i].resize(cols: newCols), detect any lines that were skipped and manually create wrapped continuation lines for their overflow content, with isWrapped = true.

Fix for self.y drift (applies to both options)

The viewport adjustment in reflowNarrower should only increment self.y when the insertion is above the cursor (i.e., when y < absoluteY at the time of processing). For below-cursor insertions, only lines.pop() is needed (to maintain viewport size), without changing self.y.

Environment

  • SwiftTerm: latest from main branch (via SPM)
  • macOS 15.4 (Sequoia), Apple Silicon
  • Shell: zsh 5.9
  • App: SwiftUI app using LocalProcessTerminalView

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions