mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 17:01:02 +00:00
fix(tui): isImageLine should detect image escape sequences anywhere in line (#1091)
This commit is contained in:
commit
283ad4d370
4 changed files with 440 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
153
packages/tui/test/terminal-image.test.ts
Normal file
153
packages/tui/test/terminal-image.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue