From 11239140db802ccb165df40a53d18d4927482841 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sun, 5 Apr 2026 10:30:13 -0700 Subject: [PATCH 1/2] fix(tui): break infinite re-render loop for images in cmux --- .../interactive/components/tool-execution.ts | 24 +++++++++++++++---- packages/pi-tui/src/components/image.ts | 5 ++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts index 10bd5f02c..e07fcb7c7 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts @@ -3,6 +3,7 @@ import { Container, getCapabilities, Image, + type ImageDimensions, imageFallback, Spacer, Text, @@ -88,6 +89,9 @@ export class ToolExecutionComponent extends Container { private editDiffArgsKey?: string; // Track which args the preview is for // Cached converted images for Kitty protocol (which requires PNG), keyed by index private convertedImages: Map = new Map(); + // Cached resolved image dimensions to avoid re-triggering async parsing + // when updateDisplay() recreates Image components (#3455). + private resolvedImageDimensions: Map = new Map(); // Incremental syntax highlighting cache for write tool call args private writeHighlightCache?: WriteHighlightCache; // When true, this component intentionally renders no lines @@ -472,16 +476,28 @@ export class ToolExecutionComponent extends Container { const spacer = new Spacer(1); this.addChild(spacer); this.imageSpacers.push(spacer); + // Pass cached dimensions to avoid re-triggering async parsing + // when updateDisplay() recreates Image components (#3455). + const cachedDims = this.resolvedImageDimensions.get(i); const imageComponent = new Image( imageData, imageMimeType, { fallbackColor: (s: string) => theme.fg("toolOutput", s) }, { maxWidthCells: 60 }, + cachedDims, ); - imageComponent.setOnDimensionsResolved(() => { - this.updateDisplay(); - this.ui.requestRender(); - }); + if (!cachedDims) { + const imgIdx = i; + imageComponent.setOnDimensionsResolved(() => { + // Cache resolved dimensions so future updateDisplay() calls + // don't re-trigger async parsing → infinite loop (#3455). + const dims = imageComponent.getDimensions?.(); + if (dims) this.resolvedImageDimensions.set(imgIdx, dims); + // Just re-render — don't call updateDisplay() which would + // destroy and recreate all Image components. + this.ui.requestRender(); + }); + } this.imageComponents.push(imageComponent); this.addChild(imageComponent); } diff --git a/packages/pi-tui/src/components/image.ts b/packages/pi-tui/src/components/image.ts index c789a0a5b..814167605 100644 --- a/packages/pi-tui/src/components/image.ts +++ b/packages/pi-tui/src/components/image.ts @@ -72,6 +72,11 @@ export class Image implements Component { return this.imageId; } + /** Get the resolved image dimensions (for caching across recreations). */ + getDimensions(): ImageDimensions | undefined { + return this.dimensionsResolved ? this.dimensions : undefined; + } + invalidate(): void { this.cachedLines = undefined; this.cachedWidth = undefined; From 48dc32eeb575b401fe734edc9e9a5f72255593b4 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sun, 5 Apr 2026 11:54:47 -0700 Subject: [PATCH 2/2] test(tui): add Image dimension caching regression test --- packages/pi-tui/src/components/image.test.ts | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/pi-tui/src/components/image.test.ts diff --git a/packages/pi-tui/src/components/image.test.ts b/packages/pi-tui/src/components/image.test.ts new file mode 100644 index 000000000..3bef04a85 --- /dev/null +++ b/packages/pi-tui/src/components/image.test.ts @@ -0,0 +1,36 @@ +/** + * Regression test for #3455: Image component must not trigger infinite + * re-render loop when dimensions resolve in cmux sessions. + */ + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { Image } from "./image.js"; + +describe("Image component (#3455)", () => { + const theme = { fallbackColor: (s: string) => s }; + + test("getDimensions returns undefined before resolution", () => { + // Pass explicit dimensions to avoid async parsing + const img = new Image("base64data", "image/png", theme, {}); + // Without explicit dims, getDimensions should be undefined until async resolve + // But we can't easily test async here, so verify the method exists + assert.equal(typeof img.getDimensions, "function"); + }); + + test("getDimensions returns dimensions when provided at construction", () => { + const dims = { widthPx: 100, heightPx: 200 }; + const img = new Image("base64data", "image/png", theme, {}, dims); + const result = img.getDimensions(); + assert.deepEqual(result, dims, "Should return provided dimensions"); + }); + + test("onDimensionsResolved callback is not called when dimensions provided", () => { + let callCount = 0; + const dims = { widthPx: 100, heightPx: 200 }; + const img = new Image("base64data", "image/png", theme, {}, dims); + img.setOnDimensionsResolved(() => { callCount++; }); + // With pre-resolved dims, the async path is skipped entirely + assert.equal(callCount, 0, "Callback should not fire for pre-resolved dimensions"); + }); +});