mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
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:
parent
419c07fb19
commit
0925fafe3b
8 changed files with 80 additions and 48 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.51.0] - 2026-02-01
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface RetrySettings {
|
||||||
|
|
||||||
export interface TerminalSettings {
|
export interface TerminalSettings {
|
||||||
showImages?: boolean; // default: true (only relevant if terminal supports images)
|
showImages?: boolean; // default: true (only relevant if terminal supports images)
|
||||||
|
clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageSettings {
|
export interface ImageSettings {
|
||||||
|
|
@ -628,6 +629,23 @@ export class SettingsManager {
|
||||||
this.save();
|
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 {
|
getImageAutoResize(): boolean {
|
||||||
return this.settings.images?.autoResize ?? true;
|
return this.settings.images?.autoResize ?? true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,6 @@ export class CustomMessageComponent extends Container {
|
||||||
.join("\n");
|
.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(
|
this.box.addChild(
|
||||||
new Markdown(text, 0, 0, this.markdownTheme, {
|
new Markdown(text, 0, 0, this.markdownTheme, {
|
||||||
color: (text: string) => theme.fg("customMessageText", text),
|
color: (text: string) => theme.fg("customMessageText", text),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export interface SettingsConfig {
|
||||||
editorPaddingX: number;
|
editorPaddingX: number;
|
||||||
autocompleteMaxVisible: number;
|
autocompleteMaxVisible: number;
|
||||||
quietStartup: boolean;
|
quietStartup: boolean;
|
||||||
|
clearOnShrink: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsCallbacks {
|
export interface SettingsCallbacks {
|
||||||
|
|
@ -60,6 +61,7 @@ export interface SettingsCallbacks {
|
||||||
onEditorPaddingXChange: (padding: number) => void;
|
onEditorPaddingXChange: (padding: number) => void;
|
||||||
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
|
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
|
||||||
onQuietStartupChange: (enabled: boolean) => void;
|
onQuietStartupChange: (enabled: boolean) => void;
|
||||||
|
onClearOnShrinkChange: (enabled: boolean) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,6 +314,16 @@ export class SettingsSelectorComponent extends Container {
|
||||||
values: ["3", "5", "7", "10", "15", "20"],
|
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
|
// Add borders
|
||||||
this.addChild(new DynamicBorder());
|
this.addChild(new DynamicBorder());
|
||||||
|
|
||||||
|
|
@ -363,6 +375,9 @@ export class SettingsSelectorComponent extends Container {
|
||||||
case "autocomplete-max-visible":
|
case "autocomplete-max-visible":
|
||||||
callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10));
|
callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10));
|
||||||
break;
|
break;
|
||||||
|
case "clear-on-shrink":
|
||||||
|
callbacks.onClearOnShrinkChange(newValue === "true");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
callbacks.onCancel,
|
callbacks.onCancel,
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,7 @@ export class InteractiveMode {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.version = VERSION;
|
this.version = VERSION;
|
||||||
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
|
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
|
||||||
|
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
|
||||||
this.headerContainer = new Container();
|
this.headerContainer = new Container();
|
||||||
this.chatContainer = new Container();
|
this.chatContainer = new Container();
|
||||||
this.pendingMessagesContainer = new Container();
|
this.pendingMessagesContainer = new Container();
|
||||||
|
|
@ -2996,6 +2997,7 @@ export class InteractiveMode {
|
||||||
editorPaddingX: this.settingsManager.getEditorPaddingX(),
|
editorPaddingX: this.settingsManager.getEditorPaddingX(),
|
||||||
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
|
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
|
||||||
quietStartup: this.settingsManager.getQuietStartup(),
|
quietStartup: this.settingsManager.getQuietStartup(),
|
||||||
|
clearOnShrink: this.settingsManager.getClearOnShrink(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onAutoCompactChange: (enabled) => {
|
onAutoCompactChange: (enabled) => {
|
||||||
|
|
@ -3084,6 +3086,10 @@ export class InteractiveMode {
|
||||||
this.editor.setAutocompleteMaxVisible(maxVisible);
|
this.editor.setAutocompleteMaxVisible(maxVisible);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onClearOnShrinkChange: (enabled) => {
|
||||||
|
this.settingsManager.setClearOnShrink(enabled);
|
||||||
|
this.ui.setClearOnShrink(enabled);
|
||||||
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
done();
|
done();
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,15 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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.51.0] - 2026-02-01
|
||||||
|
|
||||||
## [0.50.9] - 2026-02-01
|
## [0.50.9] - 2026-02-01
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,6 @@ export class TUI extends Container {
|
||||||
public terminal: Terminal;
|
public terminal: Terminal;
|
||||||
private previousLines: string[] = [];
|
private previousLines: string[] = [];
|
||||||
private previousWidth = 0;
|
private previousWidth = 0;
|
||||||
private previousHeight = 0;
|
|
||||||
private focusedComponent: Component | null = null;
|
private focusedComponent: Component | null = null;
|
||||||
|
|
||||||
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
/** 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 inputBuffer = ""; // Buffer for parsing terminal responses
|
||||||
private cellSizeQueryPending = false;
|
private cellSizeQueryPending = false;
|
||||||
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
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 maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
||||||
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
||||||
private fullRedrawCount = 0;
|
private fullRedrawCount = 0;
|
||||||
|
|
@ -248,6 +248,19 @@ export class TUI extends Container {
|
||||||
this.requestRender();
|
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 {
|
setFocus(component: Component | null): void {
|
||||||
// Clear focused flag on old component
|
// Clear focused flag on old component
|
||||||
if (isFocusable(this.focusedComponent)) {
|
if (isFocusable(this.focusedComponent)) {
|
||||||
|
|
@ -397,7 +410,6 @@ export class TUI extends Container {
|
||||||
if (force) {
|
if (force) {
|
||||||
this.previousLines = [];
|
this.previousLines = [];
|
||||||
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
||||||
this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
|
||||||
this.cursorRow = 0;
|
this.cursorRow = 0;
|
||||||
this.hardwareCursorRow = 0;
|
this.hardwareCursorRow = 0;
|
||||||
this.maxLinesRendered = 0;
|
this.maxLinesRendered = 0;
|
||||||
|
|
@ -827,9 +839,8 @@ export class TUI extends Container {
|
||||||
|
|
||||||
newLines = this.applyLineResets(newLines);
|
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 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
|
// Helper to clear scrollback and viewport and render all new lines
|
||||||
const fullRender = (clear: boolean): void => {
|
const fullRender = (clear: boolean): void => {
|
||||||
|
|
@ -854,24 +865,24 @@ export class TUI extends Container {
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
this.previousHeight = height;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// First render - just output everything without clearing (assumes clean screen)
|
// 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);
|
fullRender(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Width or height changed - full re-render
|
// Width changed - full re-render (line wrapping changes)
|
||||||
if (widthChanged || heightChanged) {
|
if (widthChanged) {
|
||||||
fullRender(true);
|
fullRender(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content shrunk below the working area and no overlays - re-render to clear empty rows
|
// 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)
|
// (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);
|
fullRender(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -941,15 +952,15 @@ export class TUI extends Container {
|
||||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
this.previousHeight = height;
|
|
||||||
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if firstChanged is outside the viewport
|
// Check if firstChanged is above what was previously visible
|
||||||
// Viewport is based on max lines ever rendered (terminal's working area)
|
// Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
|
||||||
if (firstChanged < viewportTop) {
|
const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
|
||||||
// First change is above viewport - need full re-render
|
if (firstChanged < previousContentViewportTop) {
|
||||||
|
// First change is above previous viewport - need full re-render
|
||||||
fullRender(true);
|
fullRender(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1088,7 +1099,6 @@ export class TUI extends Container {
|
||||||
|
|
||||||
this.previousLines = newLines;
|
this.previousLines = newLines;
|
||||||
this.previousWidth = width;
|
this.previousWidth = width;
|
||||||
this.previousHeight = height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -23,31 +23,6 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TUI resize handling", () => {
|
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 () => {
|
it("triggers full re-render when terminal width changes", async () => {
|
||||||
const terminal = new VirtualTerminal(40, 10);
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
const tui = new TUI(terminal);
|
const tui = new TUI(terminal);
|
||||||
|
|
@ -75,6 +50,7 @@ describe("TUI content shrinkage", () => {
|
||||||
it("clears empty rows when content shrinks significantly", async () => {
|
it("clears empty rows when content shrinks significantly", async () => {
|
||||||
const terminal = new VirtualTerminal(40, 10);
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
const tui = new TUI(terminal);
|
const tui = new TUI(terminal);
|
||||||
|
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||||
const component = new TestComponent();
|
const component = new TestComponent();
|
||||||
tui.addChild(component);
|
tui.addChild(component);
|
||||||
|
|
||||||
|
|
@ -106,6 +82,7 @@ describe("TUI content shrinkage", () => {
|
||||||
it("handles shrink to single line", async () => {
|
it("handles shrink to single line", async () => {
|
||||||
const terminal = new VirtualTerminal(40, 10);
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
const tui = new TUI(terminal);
|
const tui = new TUI(terminal);
|
||||||
|
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||||
const component = new TestComponent();
|
const component = new TestComponent();
|
||||||
tui.addChild(component);
|
tui.addChild(component);
|
||||||
|
|
||||||
|
|
@ -128,6 +105,7 @@ describe("TUI content shrinkage", () => {
|
||||||
it("handles shrink to empty", async () => {
|
it("handles shrink to empty", async () => {
|
||||||
const terminal = new VirtualTerminal(40, 10);
|
const terminal = new VirtualTerminal(40, 10);
|
||||||
const tui = new TUI(terminal);
|
const tui = new TUI(terminal);
|
||||||
|
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||||
const component = new TestComponent();
|
const component = new TestComponent();
|
||||||
tui.addChild(component);
|
tui.addChild(component);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue