diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 20c0a240..1500849a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 8419455f..6111da1d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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; } diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index 733c0708..b007dfcf 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -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), diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 9ba96d7d..0c2f9769 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 35065b32..3ac56d36 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6691a0f8..1e07609c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ed9ffd98..98db807b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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; } /** diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 32ae4ea4..f72f65ca 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -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);