From 1bf40a09c8c0b2a33bd757822b9c070604bfa2ae Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 23 Jul 2026 15:38:13 +0200 Subject: [PATCH] feat(tui): Experimental support for limited repainting --- packages/ai/src/auth/oauth/load.ts | 2 +- packages/coding-agent/docs/settings.md | 1 + packages/coding-agent/src/cli/startup-ui.ts | 1 + .../coding-agent/src/core/settings-manager.ts | 19 +++ .../components/settings-selector.ts | 17 ++- .../src/modes/interactive/interactive-mode.ts | 8 ++ .../test/settings-manager.test.ts | 23 ++++ .../5943-session-start-notify.test.ts | 4 + packages/tui/src/tui.ts | 54 +++++++-- packages/tui/test/tui-render.test.ts | 109 ++++++++++++++++++ 10 files changed, 229 insertions(+), 9 deletions(-) diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index b21b6b3a2..e9c72d87d 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -47,7 +47,7 @@ export const loadOpenRouterOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.openrouter(); return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth; }; - + export const loadKimiCodingOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.kimiCoding(); return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth; diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 72337631e..45a9bddff 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 | diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 12669e791..a73badb20 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -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; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 3306d71bc..1bb3d27e4 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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; } 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 9cfe184cb..0b33f87bd 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4285297ec..811f0b36e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); } diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 46387997d..e7bd535a7 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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"); diff --git a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts index b020205d1..578bb20b6 100644 --- a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts +++ b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts @@ -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, diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 954256a1d..2bfd38684 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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; } diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 484639ba8..e1102c32a 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -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);