fix(tui): reduce unnecessary full redraws for better performance

- Remove height change detection (only width changes trigger full redraw)
- Change clearOnShrink default to false (use PI_CLEAR_ON_SHRINK=1 to enable)
- Fix viewport check to use previousLines.length instead of maxLinesRendered
  (prevents false positive redraws when appending lines after content shrunk)
- Add clearOnShrink setting to /settings in coding-agent
- Remove line truncation in custom message component (always show full content)
This commit is contained in:
Mario Zechner 2026-02-02 08:10:08 +01:00
parent 419c07fb19
commit 0925fafe3b
8 changed files with 80 additions and 48 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- **Clear on shrink setting**: New `terminal.clearOnShrink` setting (and `/settings` toggle) controls whether empty rows are cleared when content shrinks. Disabled by default to reduce flicker. Enable via settings or `PI_CLEAR_ON_SHRINK=1` env var.
## [0.51.0] - 2026-02-01
### Breaking Changes

View file

@ -21,6 +21,7 @@ export interface RetrySettings {
export interface TerminalSettings {
showImages?: boolean; // default: true (only relevant if terminal supports images)
clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks)
}
export interface ImageSettings {
@ -628,6 +629,23 @@ export class SettingsManager {
this.save();
}
getClearOnShrink(): boolean {
// Settings takes precedence, then env var, then default false
if (this.settings.terminal?.clearOnShrink !== undefined) {
return this.settings.terminal.clearOnShrink;
}
return process.env.PI_CLEAR_ON_SHRINK === "1";
}
setClearOnShrink(enabled: boolean): void {
if (!this.globalSettings.terminal) {
this.globalSettings.terminal = {};
}
this.globalSettings.terminal.clearOnShrink = enabled;
this.markModified("terminal", "clearOnShrink");
this.save();
}
getImageAutoResize(): boolean {
return this.settings.images?.autoResize ?? true;
}

View file

@ -90,14 +90,6 @@ export class CustomMessageComponent extends Container {
.join("\n");
}
// Limit lines when collapsed
if (!this._expanded) {
const lines = text.split("\n");
if (lines.length > 5) {
text = `${lines.slice(0, 5).join("\n")}\n...`;
}
}
this.box.addChild(
new Markdown(text, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),

View file

@ -40,6 +40,7 @@ export interface SettingsConfig {
editorPaddingX: number;
autocompleteMaxVisible: number;
quietStartup: boolean;
clearOnShrink: boolean;
}
export interface SettingsCallbacks {
@ -60,6 +61,7 @@ export interface SettingsCallbacks {
onEditorPaddingXChange: (padding: number) => void;
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
onQuietStartupChange: (enabled: boolean) => void;
onClearOnShrinkChange: (enabled: boolean) => void;
onCancel: () => void;
}
@ -312,6 +314,16 @@ export class SettingsSelectorComponent extends Container {
values: ["3", "5", "7", "10", "15", "20"],
});
// Clear on shrink toggle (insert after autocomplete-max-visible)
const autocompleteIndex = items.findIndex((item) => item.id === "autocomplete-max-visible");
items.splice(autocompleteIndex + 1, 0, {
id: "clear-on-shrink",
label: "Clear on shrink",
description: "Clear empty rows when content shrinks (may cause flicker)",
currentValue: config.clearOnShrink ? "true" : "false",
values: ["true", "false"],
});
// Add borders
this.addChild(new DynamicBorder());
@ -363,6 +375,9 @@ export class SettingsSelectorComponent extends Container {
case "autocomplete-max-visible":
callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10));
break;
case "clear-on-shrink":
callbacks.onClearOnShrinkChange(newValue === "true");
break;
}
},
callbacks.onCancel,

View file

@ -258,6 +258,7 @@ export class InteractiveMode {
this.session = session;
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
this.headerContainer = new Container();
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
@ -2996,6 +2997,7 @@ export class InteractiveMode {
editorPaddingX: this.settingsManager.getEditorPaddingX(),
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
quietStartup: this.settingsManager.getQuietStartup(),
clearOnShrink: this.settingsManager.getClearOnShrink(),
},
{
onAutoCompactChange: (enabled) => {
@ -3084,6 +3086,10 @@ export class InteractiveMode {
this.editor.setAutocompleteMaxVisible(maxVisible);
}
},
onClearOnShrinkChange: (enabled) => {
this.settingsManager.setClearOnShrink(enabled);
this.ui.setClearOnShrink(enabled);
},
onCancel: () => {
done();
this.ui.requestRender();

View file

@ -2,6 +2,15 @@
## [Unreleased]
### Changed
- Terminal height changes no longer trigger full redraws, reducing flicker on resize
- `clearOnShrink` now defaults to `false` (use `PI_CLEAR_ON_SHRINK=1` or `setClearOnShrink(true)` to enable)
### Fixed
- Fixed unnecessary full redraws when appending many lines after content had previously shrunk (viewport check now uses actual previous content size instead of stale maximum)
## [0.51.0] - 2026-02-01
## [0.50.9] - 2026-02-01

View file

@ -199,7 +199,6 @@ export class TUI extends Container {
public terminal: Terminal;
private previousLines: string[] = [];
private previousWidth = 0;
private previousHeight = 0;
private focusedComponent: Component | null = null;
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
@ -210,6 +209,7 @@ export class TUI extends Container {
private inputBuffer = ""; // Buffer for parsing terminal responses
private cellSizeQueryPending = false;
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
private fullRedrawCount = 0;
@ -248,6 +248,19 @@ export class TUI extends Container {
this.requestRender();
}
getClearOnShrink(): boolean {
return this.clearOnShrink;
}
/**
* Set whether to trigger full re-render when content shrinks.
* When true (default), empty rows are cleared when content shrinks.
* When false, empty rows remain (reduces redraws on slower terminals).
*/
setClearOnShrink(enabled: boolean): void {
this.clearOnShrink = enabled;
}
setFocus(component: Component | null): void {
// Clear focused flag on old component
if (isFocusable(this.focusedComponent)) {
@ -397,7 +410,6 @@ export class TUI extends Container {
if (force) {
this.previousLines = [];
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
this.cursorRow = 0;
this.hardwareCursorRow = 0;
this.maxLinesRendered = 0;
@ -827,9 +839,8 @@ export class TUI extends Container {
newLines = this.applyLineResets(newLines);
// Width or height changed - need full re-render
// Width changed - need full re-render (line wrapping changes)
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
@ -854,24 +865,24 @@ export class TUI extends Container {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
};
// First render - just output everything without clearing (assumes clean screen)
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
if (this.previousLines.length === 0 && !widthChanged) {
fullRender(false);
return;
}
// Width or height changed - full re-render
if (widthChanged || heightChanged) {
// Width changed - full re-render (line wrapping changes)
if (widthChanged) {
fullRender(true);
return;
}
// Content shrunk below the working area and no overlays - re-render to clear empty rows
// (overlays need the padding, so only do this when no overlays are active)
if (newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
fullRender(true);
return;
}
@ -941,15 +952,15 @@ export class TUI extends Container {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
return;
}
// Check if firstChanged is outside the viewport
// Viewport is based on max lines ever rendered (terminal's working area)
if (firstChanged < viewportTop) {
// First change is above viewport - need full re-render
// Check if firstChanged is above what was previously visible
// Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
if (firstChanged < previousContentViewportTop) {
// First change is above previous viewport - need full re-render
fullRender(true);
return;
}
@ -1088,7 +1099,6 @@ export class TUI extends Container {
this.previousLines = newLines;
this.previousWidth = width;
this.previousHeight = height;
}
/**

View file

@ -23,31 +23,6 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num
}
describe("TUI resize handling", () => {
it("triggers full re-render when terminal height changes", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
const initialRedraws = tui.fullRedraws;
// Resize height
terminal.resize(40, 15);
await terminal.flush();
// Should have triggered a full redraw
assert.ok(tui.fullRedraws > initialRedraws, "Height change should trigger full redraw");
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "Content preserved after height change");
tui.stop();
});
it("triggers full re-render when terminal width changes", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
@ -75,6 +50,7 @@ describe("TUI content shrinkage", () => {
it("clears empty rows when content shrinks significantly", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
@ -106,6 +82,7 @@ describe("TUI content shrinkage", () => {
it("handles shrink to single line", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
@ -128,6 +105,7 @@ describe("TUI content shrinkage", () => {
it("handles shrink to empty", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);