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
- Create a SwiftTerm-based terminal (e.g.,
LocalProcessTerminalView) with scrollback enabled
- 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)
- Resize the terminal narrower so the line must soft-wrap (e.g., from 80 columns to 6 columns)
- Observe the display
Expected: The line wraps correctly into 2 lines:
Actual: 3 lines appear, with the first line duplicated:
- Resize the terminal back to the original width (80 columns)
- 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
reflowNarrower processes bottom lines first, each incrementing self.y (Bug 2)
- By the time it reaches the cursor line,
absoluteY has drifted — cursor-skip check produces wrong results
- If the cursor line IS skipped (correctly or not), the post-reflow truncation destroys its overflow content (Bug 1)
- The truncated line persists as an orphan with no
isWrapped marker
- Shell SIGWINCH redraw creates new wrapped content at a potentially different position
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
Summary
When a terminal is resized narrower such that existing content must soft-wrap, the buffer reflow logic in
Buffer.swiftcan produce duplicate (orphan) lines that persist even when the terminal is resized back to its original width.Steps to reproduce
LocalProcessTerminalView) with scrollback enabled% 12345at 80 columns)Expected: The line wraps correctly into 2 lines:
Actual: 3 lines appear, with the first line duplicated:
Diagnostic evidence
To isolate the issue to SwiftTerm's reflow (ruling out shell SIGWINCH redraw), I added
returnat the top of zsh'sTRAPWINCHhandler 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 toSIGWINCH.Root cause analysis
I've identified two interacting bugs in
Buffer.swiftthat 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: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 tonewCols(line ~505-508):This truncation destroys the overflow content on the skipped cursor line. The characters beyond
newColsare 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 noisWrappedmarker 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 noisWrappedassociation, and is invisible toreflowWider()when the terminal is later widened.Bug 2:
self.ydrift corrupts cursor-skip checksIn
reflowNarrower(), lines are processed bottom-to-top. For each line that needs reflowing, the viewport adjustment code modifiesself.y:Since processing goes bottom-to-top, the first lines processed are below the cursor. Their insertions are below the cursor, so
self.yshould not change — the cursor's absolute position is unaffected by insertions below it. But the code incrementsself.yunconditionally.This causes
absoluteY(yBase + self.y) to drift upward with each processed line. By the time the loop reaches the actual cursor line,absoluteYno longer matches the cursor's true position. The cursor-skip check (absoluteY >= y && absoluteY < y + wrappedLines.count) may then:absoluteYhas drifted past it), causing double-handling when the shell also redrawsabsoluteYnow points to a different line), causing content loss on an unrelated lineHow the bugs interact
reflowNarrowerprocesses bottom lines first, each incrementingself.y(Bug 2)absoluteYhas drifted — cursor-skip check produces wrong resultsisWrappedmarkerreflowWidercannot merge the orphan because it has noisWrappedassociationAffected 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 adjustself.x/self.yto 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:
where
absoluteCursorOffsetis 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 beforelines[i].resize(cols: newCols), detect any lines that were skipped and manually create wrapped continuation lines for their overflow content, withisWrapped = true.Fix for self.y drift (applies to both options)
The viewport adjustment in
reflowNarrowershould only incrementself.ywhen the insertion is above the cursor (i.e., wheny < absoluteYat the time of processing). For below-cursor insertions, onlylines.pop()is needed (to maintain viewport size), without changingself.y.Environment
mainbranch (via SPM)LocalProcessTerminalView