Files
sproutclaw/packages/tui/test/terminal-image.test.ts
Omair Ahmed e8743e870b feat(tui): use OSC 8 hyperlinks in Markdown when terminal supports them (#3248)
TerminalCapabilities already tracks hyperlinks: boolean and returns true
for Ghostty, Kitty, WezTerm, and iTerm2, but nothing generated OSC 8
sequences. This completes that stub.

Changes to packages/tui:
- terminal-image.ts: add hyperlink(text, url) and setCapabilities()
- index.ts: export hyperlink and setCapabilities
- utils.ts: extend AnsiCodeTracker to track active OSC 8 URLs
  - process() now handles OSC 8 open/close sequences
  - getActiveCodes() re-emits the OSC 8 open at each line start
  - getLineEndReset() closes the OSC 8 hyperlink before each line break
  This ensures hyperlinks wrap correctly across multiple lines.
- components/markdown.ts: link renderer uses hyperlink() when
  getCapabilities().hyperlinks is true; falls back to (url) text
- Tests: new wrap-ansi tests for OSC 8 line-wrapping; terminal-image
  tests for hyperlink(); markdown tests covering both code paths;
  table-cell width test pinned to hyperlinks:false (checks raw columns)

closes #3239

Co-authored-by: AI (Pi/Claude Sonnet 4.6) <noreply@pi.dev>
Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
2026-04-16 22:13:36 +02:00

180 lines
7.3 KiB
TypeScript

/**
* Tests for terminal image detection and line handling
*/
import assert from "node:assert";
import { describe, it } from "node:test";
import { hyperlink, 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);
});
});
});
describe("hyperlink", () => {
it("wraps text in OSC 8 open and close sequences", () => {
const result = hyperlink("click me", "https://example.com");
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\");
});
it("preserves ANSI styling inside the hyperlink", () => {
const styled = "\x1b[4m\x1b[34mclick me\x1b[0m";
const result = hyperlink(styled, "https://example.com");
assert.ok(result.startsWith("\x1b]8;;https://example.com\x1b\\"));
assert.ok(result.includes(styled));
assert.ok(result.endsWith("\x1b]8;;\x1b\\"));
});
it("works with empty text", () => {
const result = hyperlink("", "https://example.com");
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\");
});
it("works with file:// URIs", () => {
const result = hyperlink("README.md", "file:///home/user/README.md");
assert.ok(result.includes("file:///home/user/README.md"));
assert.ok(result.includes("README.md"));
});
});