diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index cf985f22..ee72172e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `isImageLine()` to check for image escape sequences anywhere in a line, not just at the start. This prevents TUI crashes when rendering lines containing image data. ([#1091](https://github.com/badlogic/pi-mono/pull/1091) by [@zedrdave](https://github.com/zedrdave)) + ## [0.50.4] - 2026-01-30 ### Added diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 0a504a46..0199ac94 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -79,24 +79,12 @@ export function getCapabilities(): TerminalCapabilities { export function resetCapabilitiesCache(): void { cachedCapabilities = null; - imageEscapePrefix = undefined; -} - -let imageEscapePrefix: string | null | undefined; - -function getImageEscapePrefix(): string | null { - if (imageEscapePrefix === undefined) { - const protocol = getCapabilities().images; - if (protocol === "kitty") imageEscapePrefix = "\x1b_G"; - else if (protocol === "iterm2") imageEscapePrefix = "\x1b]1337;File="; - else imageEscapePrefix = null; - } - return imageEscapePrefix; } export function isImageLine(line: string): boolean { - const prefix = getImageEscapePrefix(); - return prefix !== null && line.startsWith(prefix); + // Check for Kitty or iTerm2 image escape sequences anywhere in the line + // This prevents width checks from failing on lines containing image data + return line.includes("\x1b_G") || line.includes("\x1b]1337;File="); } /** diff --git a/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts b/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts new file mode 100644 index 00000000..e5a5e417 --- /dev/null +++ b/packages/tui/test/bug-regression-isimageline-startswith-bug.test.ts @@ -0,0 +1,280 @@ +/** + * Bug regression test for isImageLine() crash scenario + * + * Bug: When isImageLine() used startsWith() and terminal doesn't support images, + * it would return false for lines containing image escape sequences, causing TUI to + * crash with "Rendered line exceeds terminal width" error. + * + * Fix: Changed to use includes() to detect escape sequences anywhere in the line. + * + * This test demonstrates: + * 1. The bug scenario with the old implementation + * 2. That the fix works correctly + */ + +import assert from "node:assert"; +import { describe, it } from "node:test"; + +describe("Bug regression: isImageLine() crash with image escape sequences", () => { + describe("Bug scenario: Terminal without image support", () => { + it("old implementation would return false, causing crash", () => { + /** + * OLD IMPLEMENTATION (buggy): + * ```typescript + * export function isImageLine(line: string): boolean { + * const prefix = getImageEscapePrefix(); + * return prefix !== null && line.startsWith(prefix); + * } + * ``` + * + * When terminal doesn't support images: + * - getImageEscapePrefix() returns null + * - isImageLine() returns false even for lines containing image sequences + * - TUI performs width check on line containing 300KB+ of base64 data + * - Crash: "Rendered line exceeds terminal width (304401 > 115)" + */ + + // Simulate old implementation behavior + const oldIsImageLine = (line: string, imageEscapePrefix: string | null): boolean => { + return imageEscapePrefix !== null && line.startsWith(imageEscapePrefix); + }; + + // When terminal doesn't support images, prefix is null + const terminalWithoutImageSupport = null; + + // Line containing image escape sequence with text before it (common bug scenario) + const lineWithImageSequence = + "Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07"; + + // Old implementation would return false (BUG!) + const oldResult = oldIsImageLine(lineWithImageSequence, terminalWithoutImageSupport); + assert.strictEqual( + oldResult, + false, + "Bug: old implementation returns false for line containing image sequence when terminal has no image support", + ); + }); + + it("new implementation returns true correctly", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + // Line containing image escape sequence with text before it + const lineWithImageSequence = + "Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07"; + + // New implementation should return true (FIX!) + const newResult = isImageLine(lineWithImageSequence); + assert.strictEqual( + newResult, + true, + "Fix: new implementation returns true for line containing image sequence", + ); + }); + + it("new implementation detects Kitty sequences in any position", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + const scenarios = [ + "At start: \x1b_Ga=T,f=100,data...\x1b\\", + "Prefix \x1b_Ga=T,data...\x1b\\", + "Suffix text \x1b_Ga=T,data...\x1b\\ suffix", + "Middle \x1b_Ga=T,data...\x1b\\ more text", + // Very long line (simulating 300KB+ crash scenario) + "Text before " + + "\x1b_Ga=T,f=100" + + "A".repeat(300000) + + " text after", + ]; + + for (const line of scenarios) { + assert.strictEqual( + isImageLine(line), + true, + `Should detect Kitty sequence in: ${line.slice(0, 50)}...`, + ); + } + }); + + it("new implementation detects iTerm2 sequences in any position", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + const scenarios = [ + "At start: \x1b]1337;File=size=100,100:base64...\x07", + "Prefix \x1b]1337;File=inline=1:data==\x07", + "Suffix text \x1b]1337;File=inline=1:data==\x07 suffix", + "Middle \x1b]1337;File=inline=1:data==\x07 more text", + // Very long line (simulating 304KB crash scenario) + "Text before " + + "\x1b]1337;File=size=800,600;inline=1:" + + "B".repeat(300000) + + " text after", + ]; + + for (const line of scenarios) { + assert.strictEqual( + isImageLine(line), + true, + `Should detect iTerm2 sequence in: ${line.slice(0, 50)}...`, + ); + } + }); + }); + + describe("Integration: Tool execution scenario", () => { + /** + * This simulates what happens when the `read` tool reads an image file. + * The tool result contains both text and image content: + * + * ```typescript + * { + * content: [ + * { type: "text", text: "Read image file [image/jpeg]\n800x600" }, + * { type: "image", data: "base64...", mimeType: "image/jpeg" } + * ] + * } + * ``` + * + * When this is rendered, the image component creates escape sequences. + * If isImageLine() doesn't detect them, TUI crashes. + */ + + it("detects image sequences in read tool output", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + // Simulate output when read tool processes an image + // The line might have text from the read result plus the image escape sequence + const toolOutputLine = + "Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64image...\x07"; + + assert.strictEqual( + isImageLine(toolOutputLine), + true, + "Should detect image sequence in tool output line", + ); + }); + + it("detects Kitty sequences from Image component", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + // Kitty image component creates multi-line output with escape sequences + const kittyLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\"; + + assert.strictEqual( + isImageLine(kittyLine), + true, + "Should detect Kitty image component output", + ); + }); + + it("handles ANSI codes before image sequences", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + // Line might have styling (error, warning, etc.) before image data + const lines = [ + "\x1b[31mError\x1b[0m: \x1b]1337;File=inline=1:base64==\x07", + "\x1b[33mWarning\x1b[0m: \x1b_Ga=T,data...\x1b\\", + "\x1b[1mBold\x1b[0m \x1b]1337;File=:base64==\x07\x1b[0m", + ]; + + for (const line of lines) { + assert.strictEqual( + isImageLine(line), + true, + `Should detect image sequence after ANSI codes: ${line.slice(0, 30)}...`, + ); + } + }); + }); + + describe("Crash scenario simulation", () => { + it("does NOT crash on very long lines with image sequences", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + /** + * Simulate the exact crash scenario: + * - Line is 304,401 characters (the crash log showed 58649 > 115) + * - Contains image escape sequence somewhere in the middle + * - Old implementation would return false, causing TUI to do width check + * - New implementation returns true, skipping width check (preventing crash) + */ + + const base64Char = "A".repeat(100); + const iterm2Sequence = "\x1b]1337;File=size=800,600;inline=1:"; + + // Build a line that would cause the crash + const crashLine = + "Output: " + + iterm2Sequence + + base64Char.repeat(3040) + // ~304,000 chars + " end of output"; + + // Verify line is very long + assert(crashLine.length > 300000, "Test line should be > 300KB"); + + // New implementation should detect it (prevents crash) + const detected = isImageLine(crashLine); + assert.strictEqual( + detected, + true, + "Should detect image sequence in very long line, preventing TUI crash", + ); + }); + + it("handles lines exactly matching crash log dimensions", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + /** + * Crash log showed: line 58649 chars wide, terminal width 115 + * Let's create a line with similar characteristics + */ + + const targetWidth = 58649; + const prefix = "Text"; + const sequence = "\x1b_Ga=T,f=100"; + const suffix = "End"; + const padding = "A".repeat(targetWidth - prefix.length - sequence.length - suffix.length); + const line = `${prefix}${sequence}${padding}${suffix}`; + + assert.strictEqual(line.length, 58649); + assert.strictEqual( + isImageLine(line), + true, + "Should detect image sequence in 58649-char line", + ); + }); + }); + + describe("Negative cases: Don't false positive", () => { + it("does not detect images in regular long text", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + // Very long line WITHOUT image sequences + const longText = "A".repeat(100000); + + assert.strictEqual( + isImageLine(longText), + false, + "Should not detect images in plain long text", + ); + }); + + it("does not detect images in lines with file paths", async () => { + const { isImageLine } = await import("../src/terminal-image.js"); + + const filePaths = [ + "/path/to/1337/image.jpg", + "/usr/local/bin/File_converter", + "~/Documents/1337File_backup.png", + "./_G_test_file.txt", + ]; + + for (const path of filePaths) { + assert.strictEqual( + isImageLine(path), + false, + `Should not falsely detect image sequence in path: ${path}`, + ); + } + }); + }); +}); diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts new file mode 100644 index 00000000..b573837c --- /dev/null +++ b/packages/tui/test/terminal-image.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for terminal image detection and line handling + */ + +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { isImageLine } from "../src/terminal-image.js"; + +describe("isImageLine", () => { + describe("iTerm2 image protocol", () => { + it("should detect iTerm2 image escape sequence at start of line", () => { + // iTerm2 image escape sequence: ESC ]1337;File=... + const iterm2ImageLine = "\x1b]1337;File=size=100,100;inline=1:base64encodeddata==\x07"; + assert.strictEqual(isImageLine(iterm2ImageLine), true); + }); + + it("should detect iTerm2 image escape sequence with text before it", () => { + // Simulating a line that has text then image data (bug scenario) + const lineWithTextAndImage = "Some text \x1b]1337;File=size=100,100;inline=1:base64data==\x07 more text"; + assert.strictEqual(isImageLine(lineWithTextAndImage), true); + }); + + it("should detect iTerm2 image escape sequence in middle of long line", () => { + // Simulate a very long line with image data in the middle + const longLineWithImage = + "Text before image..." + "\x1b]1337;File=inline=1:verylongbase64data==" + "...text after"; + assert.strictEqual(isImageLine(longLineWithImage), true); + }); + + it("should detect iTerm2 image escape sequence at end of line", () => { + const lineWithImageAtEnd = "Regular text ending with \x1b]1337;File=inline=1:base64data==\x07"; + assert.strictEqual(isImageLine(lineWithImageAtEnd), true); + }); + + it("should detect minimal iTerm2 image escape sequence", () => { + const minimalImageLine = "\x1b]1337;File=:\x07"; + assert.strictEqual(isImageLine(minimalImageLine), true); + }); + }); + + describe("Kitty image protocol", () => { + it("should detect Kitty image escape sequence at start of line", () => { + // Kitty image escape sequence: ESC _G + const kittyImageLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\"; + assert.strictEqual(isImageLine(kittyImageLine), true); + }); + + it("should detect Kitty image escape sequence with text before it", () => { + // Bug scenario: text + image data in same line + const lineWithTextAndKittyImage = "Output: \x1b_Ga=T,f=100;data...\x1b\\\x1b_Gm=i=1;\x1b\\"; + assert.strictEqual(isImageLine(lineWithTextAndKittyImage), true); + }); + + it("should detect Kitty image escape sequence with padding", () => { + // Kitty protocol adds padding to escape sequences + const kittyWithPadding = " \x1b_Ga=T,f=100...\x1b\\\x1b_Gm=i=1;\x1b\\ "; + assert.strictEqual(isImageLine(kittyWithPadding), true); + }); + }); + + describe("Bug regression tests", () => { + it("should detect image sequences in very long lines (304k+ chars)", () => { + // This simulates the crash scenario: a line with 304,401 chars + // containing image escape sequences somewhere + const base64Char = "A".repeat(100); // 100 chars of base64-like data + const imageSequence = "\x1b]1337;File=size=800,600;inline=1:"; + + // Build a long line with image sequence + const longLine = + "Text prefix " + + imageSequence + + base64Char.repeat(3000) + // ~300,000 chars + " suffix"; + + assert.strictEqual(longLine.length > 300000, true); + assert.strictEqual(isImageLine(longLine), true); + }); + + it("should detect image sequences when terminal doesn't support images", () => { + // The bug occurred when getImageEscapePrefix() returned null + // isImageLine should still detect image sequences regardless + const lineWithImage = "Read image file [image/jpeg]\x1b]1337;File=inline=1:base64data==\x07"; + assert.strictEqual(isImageLine(lineWithImage), true); + }); + + it("should detect image sequences with ANSI codes before them", () => { + // Text might have ANSI styling before image data + const lineWithAnsiAndImage = "\x1b[31mError output \x1b]1337;File=inline=1:image==\x07"; + assert.strictEqual(isImageLine(lineWithAnsiAndImage), true); + }); + + it("should detect image sequences with ANSI codes after them", () => { + const lineWithImageAndAnsi = "\x1b_Ga=T,f=100:data...\x1b\\\x1b_Gm=i=1;\x1b\\\x1b[0m reset"; + assert.strictEqual(isImageLine(lineWithImageAndAnsi), true); + }); + }); + + describe("Negative cases - lines without images", () => { + it("should not detect images in plain text lines", () => { + const plainText = "This is just a regular text line without any escape sequences"; + assert.strictEqual(isImageLine(plainText), false); + }); + + it("should not detect images in lines with only ANSI codes", () => { + const ansiText = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m"; + assert.strictEqual(isImageLine(ansiText), false); + }); + + it("should not detect images in lines with cursor movement codes", () => { + const cursorCodes = "\x1b[1A\x1b[2KLine cleared and moved up"; + assert.strictEqual(isImageLine(cursorCodes), false); + }); + + it("should not detect images in lines with partial iTerm2 sequences", () => { + // Similar prefix but missing the complete sequence + const partialSequence = "Some text with ]1337;File but missing ESC at start"; + assert.strictEqual(isImageLine(partialSequence), false); + }); + + it("should not detect images in lines with partial Kitty sequences", () => { + // Similar prefix but missing the complete sequence + const partialSequence = "Some text with _G but missing ESC at start"; + assert.strictEqual(isImageLine(partialSequence), false); + }); + + it("should not detect images in empty lines", () => { + assert.strictEqual(isImageLine(""), false); + }); + + it("should not detect images in lines with newlines only", () => { + assert.strictEqual(isImageLine("\n"), false); + assert.strictEqual(isImageLine("\n\n"), false); + }); + }); + + describe("Mixed content scenarios", () => { + it("should detect images when line has both Kitty and iTerm2 sequences", () => { + const mixedLine = "Kitty: \x1b_Ga=T...\x1b\\\x1b_Gm=i=1;\x1b\\ iTerm2: \x1b]1337;File=inline=1:data==\x07"; + assert.strictEqual(isImageLine(mixedLine), true); + }); + + it("should detect image in line with multiple text and image segments", () => { + const complexLine = "Start \x1b]1337;File=img1==\x07 middle \x1b]1337;File=img2==\x07 end"; + assert.strictEqual(isImageLine(complexLine), true); + }); + + it("should not falsely detect image in line with file path containing keywords", () => { + // File path might contain "1337" or "File" but without escape sequences + const filePathLine = "/path/to/File_1337_backup/image.jpg"; + assert.strictEqual(isImageLine(filePathLine), false); + }); + }); +});