feat(tui): Experimental support for limited repainting

This commit is contained in:
Armin Ronacher 2026-07-23 15:38:13 +02:00
parent 34f3719a94
commit 1bf40a09c8
10 changed files with 229 additions and 9 deletions

View file

@ -47,7 +47,7 @@ export const loadOpenRouterOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.openrouter();
return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth;
};
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.kimiCoding();
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;

View file

@ -178,6 +178,7 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic
| `terminal.showImages` | boolean | `true` | Show images in terminal (if supported) |
| `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells |
| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) |
| `terminal.limitedRepaint` | number | - | Maximum recent rows emitted during a full repaint. The visible viewport is always repainted, and incremental scrollback remains unbounded |
| `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max |
| `images.blockImages` | boolean | `false` | Block all images from being sent to LLM |

View file

@ -81,6 +81,7 @@ export async function createStartupTui(settingsManager: SettingsManager): Promis
setKeybindings(KeybindingsManager.create());
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor(), getAgentDir());
ui.setClearOnShrink(settingsManager.getClearOnShrink());
ui.setLimitedRepaint(settingsManager.getLimitedRepaint());
return ui;
}

View file

@ -36,6 +36,7 @@ export interface TerminalSettings {
showImages?: boolean; // default: true (only relevant if terminal supports images)
imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells)
clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks)
limitedRepaint?: number; // max rows emitted during full repaints; default: unlimited
showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators)
}
@ -1107,6 +1108,24 @@ export class SettingsManager {
this.save();
}
getLimitedRepaint(): number | undefined {
const maxLines = this.settings.terminal?.limitedRepaint;
if (typeof maxLines !== "number" || !Number.isFinite(maxLines) || maxLines <= 0) {
return undefined;
}
return Math.floor(maxLines);
}
setLimitedRepaint(maxLines: number | undefined): void {
if (!this.globalSettings.terminal) {
this.globalSettings.terminal = {};
}
this.globalSettings.terminal.limitedRepaint =
maxLines === undefined || !Number.isFinite(maxLines) || maxLines <= 0 ? undefined : Math.floor(maxLines);
this.markModified("terminal", "limitedRepaint");
this.save();
}
getShowTerminalProgress(): boolean {
return this.settings.terminal?.showTerminalProgress ?? false;
}

View file

@ -78,6 +78,7 @@ export interface SettingsConfig {
quietStartup: boolean;
defaultProjectTrust: DefaultProjectTrust;
clearOnShrink: boolean;
limitedRepaint: number | undefined;
showTerminalProgress: boolean;
warnings: WarningSettings;
}
@ -109,6 +110,7 @@ export interface SettingsCallbacks {
onQuietStartupChange: (enabled: boolean) => void;
onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void;
onClearOnShrinkChange: (enabled: boolean) => void;
onLimitedRepaintChange: (maxLines: number | undefined) => void;
onShowTerminalProgressChange: (enabled: boolean) => void;
onWarningsChange: (warnings: WarningSettings) => void;
onCancel: () => void;
@ -718,9 +720,19 @@ export class SettingsSelectorComponent extends Container {
values: ["true", "false"],
});
// Terminal progress toggle (insert after clear-on-shrink)
// Limited repaint setting (insert after clear-on-shrink)
const clearOnShrinkIndex = items.findIndex((item) => item.id === "clear-on-shrink");
items.splice(clearOnShrinkIndex + 1, 0, {
id: "limited-repaint",
label: "Limited repaint",
description: "Limit full repaints to recent rows; incremental scrollback remains unbounded",
currentValue: config.limitedRepaint === undefined ? "off" : String(config.limitedRepaint),
values: ["off", "100", "500", "1000", "5000"],
});
// Terminal progress toggle (insert after limited-repaint)
const limitedRepaintIndex = items.findIndex((item) => item.id === "limited-repaint");
items.splice(limitedRepaintIndex + 1, 0, {
id: "terminal-progress",
label: "Terminal progress",
description: "Show OSC 9;4 progress indicators in the terminal tab bar",
@ -816,6 +828,9 @@ export class SettingsSelectorComponent extends Container {
case "clear-on-shrink":
callbacks.onClearOnShrinkChange(newValue === "true");
break;
case "limited-repaint":
callbacks.onLimitedRepaintChange(newValue === "off" ? undefined : parseInt(newValue, 10));
break;
case "terminal-progress":
callbacks.onShowTerminalProgressChange(newValue === "true");
break;

View file

@ -453,6 +453,7 @@ export class InteractiveMode {
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor(), getAgentDir());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
this.ui.setLimitedRepaint(this.settingsManager.getLimitedRepaint());
this.headerContainer = new Container();
this.loadedResourcesContainer = new Container();
this.chatContainer = new Container();
@ -1711,6 +1712,7 @@ export class InteractiveMode {
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
const clearOnShrink = this.settingsManager.getClearOnShrink();
this.ui.setClearOnShrink(clearOnShrink);
this.ui.setLimitedRepaint(this.settingsManager.getLimitedRepaint());
if (!clearOnShrink && !this.activeStatusIndicator) {
this.statusContainer.clear();
}
@ -4140,6 +4142,7 @@ export class InteractiveMode {
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
quietStartup: this.settingsManager.getQuietStartup(),
clearOnShrink: this.settingsManager.getClearOnShrink(),
limitedRepaint: this.settingsManager.getLimitedRepaint(),
showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
warnings: this.settingsManager.getWarnings(),
},
@ -4274,6 +4277,10 @@ export class InteractiveMode {
this.statusContainer.clear();
}
},
onLimitedRepaintChange: (maxLines) => {
this.settingsManager.setLimitedRepaint(maxLines);
this.ui.setLimitedRepaint(maxLines);
},
onShowTerminalProgressChange: (enabled) => {
this.settingsManager.setShowTerminalProgress(enabled);
},
@ -5348,6 +5355,7 @@ export class InteractiveMode {
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
const clearOnShrink = this.settingsManager.getClearOnShrink();
this.ui.setClearOnShrink(clearOnShrink);
this.ui.setLimitedRepaint(this.settingsManager.getLimitedRepaint());
if (!clearOnShrink && !this.activeStatusIndicator) {
this.statusContainer.clear();
}

View file

@ -420,6 +420,29 @@ describe("SettingsManager", () => {
});
});
describe("limitedRepaint", () => {
it("defaults to unlimited and persists a positive row limit", async () => {
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getLimitedRepaint()).toBeUndefined();
manager.setLimitedRepaint(500.9);
await manager.flush();
expect(manager.getLimitedRepaint()).toBe(500);
const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
expect(savedSettings.terminal.limitedRepaint).toBe(500);
});
it("treats invalid row limits as unlimited", () => {
expect(SettingsManager.inMemory({ terminal: { limitedRepaint: 0 } }).getLimitedRepaint()).toBeUndefined();
expect(SettingsManager.inMemory({ terminal: { limitedRepaint: -10 } }).getLimitedRepaint()).toBeUndefined();
expect(
SettingsManager.inMemory({ terminal: { limitedRepaint: Number.POSITIVE_INFINITY } }).getLimitedRepaint(),
).toBeUndefined();
});
});
describe("shellCommandPrefix", () => {
it("should load shellCommandPrefix from settings", () => {
const settingsPath = join(agentDir, "settings.json");

View file

@ -102,6 +102,7 @@ type ReloadCommandContext = {
getAutocompleteMaxVisible: () => number;
getShowHardwareCursor: () => boolean;
getClearOnShrink: () => boolean;
getLimitedRepaint: () => number | undefined;
};
keybindings: { reload: () => void };
customHeader?: unknown;
@ -112,6 +113,7 @@ type ReloadCommandContext = {
requestRender: (force?: boolean) => void;
setShowHardwareCursor: (enabled: boolean) => void;
setClearOnShrink: (enabled: boolean) => void;
setLimitedRepaint: (maxLines: number | undefined) => void;
};
editor: unknown;
defaultEditor: { setPaddingX: (padding: number) => void; setAutocompleteMaxVisible: (maxVisible: number) => void };
@ -174,6 +176,7 @@ function createReloadCommandContext(overrides: ReloadCommandContextOverrides = {
getAutocompleteMaxVisible: () => 10,
getShowHardwareCursor: () => false,
getClearOnShrink: () => false,
getLimitedRepaint: () => undefined,
...overrides.settingsManager,
},
keybindings: { reload: () => {}, ...overrides.keybindings },
@ -183,6 +186,7 @@ function createReloadCommandContext(overrides: ReloadCommandContextOverrides = {
requestRender: () => {},
setShowHardwareCursor: () => {},
setClearOnShrink: () => {},
setLimitedRepaint: () => {},
...overrides.ui,
},
editor,

View file

@ -311,6 +311,8 @@ export class TUI extends Container {
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
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 limitedRepaint: number | undefined;
private renderedBufferStart = 0; // First logical line retained after the last full repaint
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;
@ -365,6 +367,19 @@ export class TUI extends Container {
this.clearOnShrink = enabled;
}
getLimitedRepaint(): number | undefined {
return this.limitedRepaint;
}
/**
* Limit full repaints to the most recent number of rendered rows.
* The visible viewport is always repainted in full, and differential appends remain unbounded.
*/
setLimitedRepaint(maxLines: number | undefined): void {
this.limitedRepaint =
maxLines === undefined || !Number.isFinite(maxLines) || maxLines <= 0 ? undefined : Math.floor(maxLines);
}
setFocus(component: Component | null): void {
this.setFocusInternal({ component, overlayFocusRestore: "clear" });
}
@ -1139,6 +1154,21 @@ export class TUI extends Container {
return reservedRows;
}
private getLimitedRepaintStart(lines: string[], height: number): number {
if (this.limitedRepaint === undefined) return 0;
let start = Math.max(0, lines.length - Math.max(height, this.limitedRepaint));
for (let i = 0; i < start; i++) {
if (!isImageLine(lines[i] ?? "") || extractKittyImageRows(lines[i] ?? "") <= 1) continue;
const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
if (blockEnd >= start) {
start = i;
break;
}
}
return start;
}
private expandChangedRangeForKittyImages(
firstChanged: number,
lastChanged: number,
@ -1287,13 +1317,14 @@ export class TUI extends Container {
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
const renderStart = clear ? this.getLimitedRepaintStart(newLines, height) : 0;
let buffer = "\x1b[?2026h"; // Begin synchronized output
if (clear) {
buffer += this.deleteKittyImages(this.previousKittyImageIds);
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
}
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
for (let i = renderStart; i < newLines.length; i++) {
if (i > renderStart) buffer += "\r\n";
const line = newLines[i];
const isImage = isImageLine(line);
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
@ -1323,7 +1354,8 @@ export class TUI extends Container {
this.previousViewportTop = Math.max(0, bufferLength - height);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousKittyImageIds = this.collectKittyImageIds(newLines.slice(renderStart));
this.renderedBufferStart = renderStart;
this.previousWidth = width;
this.previousHeight = height;
};
@ -1353,8 +1385,16 @@ export class TUI extends Container {
// Height changes normally need a full re-render to keep the visible viewport aligned,
// but Termux changes height when the software keyboard shows or hides.
// In that environment, a full redraw causes the entire history to replay on every toggle.
if (heightChanged && !isTermuxSession()) {
// In that environment, only repaint if a larger viewport would expose history omitted
// by a previous limited repaint.
const retainedBufferLines = this.previousLines.length - this.renderedBufferStart;
const termuxNeedsRepaint =
isTermuxSession() &&
heightChanged &&
height > this.previousHeight &&
this.renderedBufferStart > 0 &&
height > retainedBufferLines;
if (heightChanged && (!isTermuxSession() || termuxNeedsRepaint)) {
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
fullRender(true);
return;
@ -1448,7 +1488,7 @@ export class TUI extends Container {
}
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousKittyImageIds = this.collectKittyImageIds(newLines.slice(this.renderedBufferStart));
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = prevViewportTop;
@ -1619,7 +1659,7 @@ export class TUI extends Container {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousKittyImageIds = this.collectKittyImageIds(newLines.slice(this.renderedBufferStart));
this.previousWidth = width;
this.previousHeight = height;
}

View file

@ -426,6 +426,115 @@ describe("TUI resize handling", () => {
});
});
describe("TUI limited repaint", () => {
it("limits full repaint output while leaving later appends unbounded", async () => {
const terminal = new LoggingVirtualTerminal(40, 5);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = Array.from({ length: 20 }, (_, i) => `row-${String(i).padStart(3, "0")}|`);
tui.start();
await terminal.waitForRender();
tui.setLimitedRepaint(7);
terminal.clearWrites();
tui.requestRender(true);
await terminal.waitForRender();
const repaintWrites = terminal.getWrites();
assert.ok(repaintWrites.includes("\x1b[2J\x1b[H\x1b[3J"), "limited repaint should still clear scrollback");
assert.ok(!repaintWrites.includes("row-012|"), "rows before the repaint limit should not be emitted");
assert.ok(repaintWrites.includes("row-013|"), "the configured repaint tail should be emitted");
assert.ok(repaintWrites.includes("row-019|"), "the latest row should be emitted");
const redrawsBeforeHiddenChange = tui.fullRedraws;
terminal.clearWrites();
component.lines[0] = "changed-row-000|";
tui.requestRender();
await terminal.waitForRender();
const hiddenChangeWrites = terminal.getWrites();
assert.ok(tui.fullRedraws > redrawsBeforeHiddenChange, "a hidden change should trigger a full repaint");
assert.ok(!hiddenChangeWrites.includes("changed-row-000|"), "limited repaint should still omit old changed rows");
assert.ok(hiddenChangeWrites.includes("row-013|"), "limited repaint should reconstruct the retained tail");
const redrawsAfterRepaint = tui.fullRedraws;
terminal.clearWrites();
component.lines.push("row-020|");
tui.requestRender();
await terminal.waitForRender();
const appendWrites = terminal.getWrites();
assert.strictEqual(tui.fullRedraws, redrawsAfterRepaint, "append should stay on the differential path");
assert.ok(!appendWrites.includes("\x1b[2J"), "append should not clear the terminal");
assert.ok(appendWrites.includes("row-020|"), "append should emit the new row");
assert.deepStrictEqual(terminal.getViewport(), ["row-016|", "row-017|", "row-018|", "row-019|", "row-020|"]);
tui.stop();
});
it("repaints omitted rows when a Termux viewport grows beyond the retained buffer", async () => {
await withEnv({ TERMUX_VERSION: "1" }, async () => {
const terminal = new LoggingVirtualTerminal(40, 5);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = Array.from({ length: 20 }, (_, i) => `row-${String(i).padStart(3, "0")}|`);
tui.start();
await terminal.waitForRender();
tui.setLimitedRepaint(5);
tui.requestRender(true);
await terminal.waitForRender();
const redrawsBeforeResize = tui.fullRedraws;
terminal.clearWrites();
terminal.resize(40, 8);
await terminal.waitForRender();
assert.ok(tui.fullRedraws > redrawsBeforeResize, "larger viewport should trigger a limited repaint");
assert.ok(terminal.getWrites().includes("row-012|"), "newly visible rows should be reconstructed");
assert.deepStrictEqual(terminal.getViewport(), [
"row-012|",
"row-013|",
"row-014|",
"row-015|",
"row-016|",
"row-017|",
"row-018|",
"row-019|",
]);
tui.stop();
});
});
it("always repaints at least the visible viewport", async () => {
const terminal = new LoggingVirtualTerminal(40, 5);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = Array.from({ length: 20 }, (_, i) => `row-${String(i).padStart(3, "0")}|`);
tui.start();
await terminal.waitForRender();
tui.setLimitedRepaint(2);
terminal.clearWrites();
tui.requestRender(true);
await terminal.waitForRender();
const writes = terminal.getWrites();
assert.ok(!writes.includes("row-014|"), "rows above the viewport should not be emitted");
assert.ok(writes.includes("row-015|"), "the complete viewport should be emitted");
assert.deepStrictEqual(terminal.getViewport(), ["row-015|", "row-016|", "row-017|", "row-018|", "row-019|"]);
tui.stop();
});
});
describe("TUI content shrinkage", () => {
it("clears empty rows when content shrinks significantly", async () => {
const terminal = new VirtualTerminal(40, 10);