diff --git a/package-lock.json b/package-lock.json index d066cc19..8d448def 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6084,6 +6084,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -9051,6 +9060,7 @@ "diff": "^8.0.2", "file-type": "^21.1.1", "glob": "^11.0.3", + "ignore": "^7.0.5", "marked": "^15.0.12", "minimatch": "^10.1.1", "proper-lockfile": "^4.1.2", diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 3d4f2a88..c99b8600 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -1,6 +1,13 @@ import type { Component } from "../tui.js"; import { applyBackgroundToLine, visibleWidth } from "../utils.js"; +type RenderCache = { + childLines: string[]; + width: number; + bgSample: string | undefined; + lines: string[]; +}; + /** * Box component - a container that applies padding and background to all children */ @@ -11,10 +18,7 @@ export class Box implements Component { private bgFn?: (text: string) => string; // Cache for rendered output - private cachedWidth?: number; - private cachedChildLines?: string; - private cachedBgSample?: string; - private cachedLines?: string[]; + private cache?: RenderCache; constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { this.paddingX = paddingX; @@ -46,10 +50,18 @@ export class Box implements Component { } private invalidateCache(): void { - this.cachedWidth = undefined; - this.cachedChildLines = undefined; - this.cachedBgSample = undefined; - this.cachedLines = undefined; + this.cache = undefined; + } + + private matchCache(width: number, childLines: string[], bgSample: string | undefined): boolean { + const cache = this.cache; + return ( + !!cache && + cache.width === width && + cache.bgSample === bgSample && + cache.childLines.length === childLines.length && + cache.childLines.every((line, i) => line === childLines[i]) + ); } invalidate(): void { @@ -84,14 +96,8 @@ export class Box implements Component { const bgSample = this.bgFn ? this.bgFn("test") : undefined; // Check cache validity - const childLinesKey = childLines.join("\n"); - if ( - this.cachedLines && - this.cachedWidth === width && - this.cachedChildLines === childLinesKey && - this.cachedBgSample === bgSample - ) { - return this.cachedLines; + if (this.matchCache(width, childLines, bgSample)) { + return this.cache!.lines; } // Apply background and padding @@ -113,10 +119,7 @@ export class Box implements Component { } // Update cache - this.cachedWidth = width; - this.cachedChildLines = childLinesKey; - this.cachedBgSample = bgSample; - this.cachedLines = result; + this.cache = { childLines, width, bgSample, lines: result }; return result; } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 7e450920..2ebdc309 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,4 +1,5 @@ import { marked, type Token } from "marked"; +import { isImageLine } from "../terminal-image.js"; import type { Component } from "../tui.js"; import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js"; @@ -121,7 +122,11 @@ export class Markdown implements Component { // Wrap lines (NO padding, NO background yet) const wrappedLines: string[] = []; for (const line of renderedLines) { - wrappedLines.push(...wrapTextWithAnsi(line, contentWidth)); + if (isImageLine(line)) { + wrappedLines.push(line); + } else { + wrappedLines.push(...wrapTextWithAnsi(line, contentWidth)); + } } // Add margins and background to each wrapped line @@ -131,6 +136,11 @@ export class Markdown implements Component { const contentLines: string[] = []; for (const line of wrappedLines) { + if (isImageLine(line)) { + contentLines.push(line); + continue; + } + const lineWithMargins = leftMargin + line + rightMargin; if (bgFn) { diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index ef48e21b..0a504a46 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -79,6 +79,24 @@ 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); } /** diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index f872cb05..3a44efbd 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -7,7 +7,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { isKeyRelease, matchesKey } from "./keys.js"; import type { Terminal } from "./terminal.js"; -import { getCapabilities, setCellDimensions } from "./terminal-image.js"; +import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js"; import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js"; /** @@ -489,10 +489,6 @@ export class TUI extends Container { return result; } - private containsImage(line: string): boolean { - return line.includes("\x1b_G") || line.includes("\x1b]1337;File="); - } - /** * Resolve overlay layout from options. * Returns { width, row, col, maxHeight } for rendering. @@ -712,7 +708,13 @@ export class TUI extends Container { private applyLineResets(lines: string[]): string[] { const reset = TUI.SEGMENT_RESET; - return lines.map((line) => (this.containsImage(line) ? line : line + reset)); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!isImageLine(line)) { + lines[i] = line + reset; + } + } + return lines; } /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ @@ -723,7 +725,7 @@ export class TUI extends Container { overlayWidth: number, totalWidth: number, ): string { - if (this.containsImage(baseLine)) return baseLine; + if (isImageLine(baseLine)) return baseLine; // Single pass through baseLine extracts both before and after segments const afterStart = startCol + overlayWidth; @@ -971,8 +973,8 @@ export class TUI extends Container { if (i > firstChanged) buffer += "\r\n"; buffer += "\x1b[2K"; // Clear current line const line = newLines[i]; - const isImageLine = this.containsImage(line); - if (!isImageLine && visibleWidth(line) > width) { + const isImage = isImageLine(line); + if (!isImage && visibleWidth(line) > width) { // Log all lines to crash file for debugging const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); const crashData = [