Summary
In the JLine TUI layer, TerminalUI.display() calls display.clear() + display.reset() on every redraw in the fullScreen path, immediately before display.update(newLines, …). org.jline.utils.Display.reset() discards the cached previous frame, so update() has no diff base and rewrites the entire screen on every frame instead of only the changed cells. JLine's cell-level diffing is effectively disabled for every redraw.
This is invisible for static frames (the redundant bytes render identical pixels), but very visible during a fast window resize, where each redraw re-lays out content: every frame becomes a full, non-atomic screen rewrite, producing tearing/flicker on terminals without synchronized output (e.g. macOS Terminal.app).
Environment
spring-shell-jline 4.0.3-SNAPSHOT
- JLine 3.30.x (FFM PTY provider, Java 25)
- macOS Terminal.app (no DECSET 2026 synchronized output)
Evidence
org/springframework/shell/jline/tui/component/view/TerminalUI.java, display() (the body invoked for every redraw()):
private synchronized void display() {
...
if (fullScreen) {
display.clear();
display.reset(); // wipes JLine's previous-frame cache (the diff base)
display.resize(size.getRows(), size.getColumns());
...
render(rect);
}
...
display.update(newLines, targetCursorPos); // would cell-diff, but reset() just nulled its base
}
Because reset() runs before update() on every frame, update() always performs a full redraw.
Impact
- Full-screen rewrite per frame regardless of how little changed.
- Fast resize: tearing / blank-growth / flicker on terminals lacking synchronized output.
- Higher TTY write volume than necessary at any repaint cadence.
Proposed fix
- Only
clear()/reset()/resize() when the terminal size actually changed (track the last applied size); for same-size redraws, go straight to render(rect) + display.update(...) and let JLine diff. Keeps resize correctness while restoring incremental updates for the common case.
- Optionally emit synchronized output (DECSET 2026 / BSU-ESU) around the write when the terminal advertises it, so even a full rewrite is presented atomically.
(1) is the higher-value change — it makes both the steady-state and resize paths write only what changed.
Notes
Found while diagnosing resize tearing in a downstream Compose-style TUI built on spring-shell-jline. Consumer-side fixes (view-rect sync, resize-event coalescing) reduce the symptom but cannot remove the per-frame full rewrite, which lives in TerminalUI.display().
Summary
In the JLine TUI layer,
TerminalUI.display()callsdisplay.clear()+display.reset()on every redraw in the fullScreen path, immediately beforedisplay.update(newLines, …).org.jline.utils.Display.reset()discards the cached previous frame, soupdate()has no diff base and rewrites the entire screen on every frame instead of only the changed cells. JLine's cell-level diffing is effectively disabled for every redraw.This is invisible for static frames (the redundant bytes render identical pixels), but very visible during a fast window resize, where each redraw re-lays out content: every frame becomes a full, non-atomic screen rewrite, producing tearing/flicker on terminals without synchronized output (e.g. macOS Terminal.app).
Environment
spring-shell-jline4.0.3-SNAPSHOTEvidence
org/springframework/shell/jline/tui/component/view/TerminalUI.java,display()(the body invoked for everyredraw()):Because
reset()runs beforeupdate()on every frame,update()always performs a full redraw.Impact
Proposed fix
clear()/reset()/resize()when the terminal size actually changed (track the last applied size); for same-size redraws, go straight torender(rect)+display.update(...)and let JLine diff. Keeps resize correctness while restoring incremental updates for the common case.(1) is the higher-value change — it makes both the steady-state and resize paths write only what changed.
Notes
Found while diagnosing resize tearing in a downstream Compose-style TUI built on
spring-shell-jline. Consumer-side fixes (view-rect sync, resize-event coalescing) reduce the symptom but cannot remove the per-frame full rewrite, which lives inTerminalUI.display().