perf(tui): optimize image line detection and box cache (#1084)

- Add isImageLine() to terminal-image.ts with single startsWith check based on detected terminal protocol
- Replace dual includes() checks in tui.ts with imported isImageLine()
- Add image line handling to markdown.ts to skip wrapping and margins for image escapes
- Consolidate Box cache into RenderCache type with childLines/width/bgSample/lines fields
- Use in-place mutation in applyLineResets() to avoid array allocation
This commit is contained in:
Can Bölük 2026-01-30 02:25:19 +01:00 committed by GitHub
parent e045a9f142
commit 4058346a64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 73 additions and 30 deletions

10
package-lock.json generated
View file

@ -6084,6 +6084,15 @@
], ],
"license": "BSD-3-Clause" "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": { "node_modules/immediate": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@ -9051,6 +9060,7 @@
"diff": "^8.0.2", "diff": "^8.0.2",
"file-type": "^21.1.1", "file-type": "^21.1.1",
"glob": "^11.0.3", "glob": "^11.0.3",
"ignore": "^7.0.5",
"marked": "^15.0.12", "marked": "^15.0.12",
"minimatch": "^10.1.1", "minimatch": "^10.1.1",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",

View file

@ -1,6 +1,13 @@
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth } from "../utils.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 * 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; private bgFn?: (text: string) => string;
// Cache for rendered output // Cache for rendered output
private cachedWidth?: number; private cache?: RenderCache;
private cachedChildLines?: string;
private cachedBgSample?: string;
private cachedLines?: string[];
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
this.paddingX = paddingX; this.paddingX = paddingX;
@ -46,10 +50,18 @@ export class Box implements Component {
} }
private invalidateCache(): void { private invalidateCache(): void {
this.cachedWidth = undefined; this.cache = undefined;
this.cachedChildLines = undefined; }
this.cachedBgSample = undefined;
this.cachedLines = 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 { invalidate(): void {
@ -84,14 +96,8 @@ export class Box implements Component {
const bgSample = this.bgFn ? this.bgFn("test") : undefined; const bgSample = this.bgFn ? this.bgFn("test") : undefined;
// Check cache validity // Check cache validity
const childLinesKey = childLines.join("\n"); if (this.matchCache(width, childLines, bgSample)) {
if ( return this.cache!.lines;
this.cachedLines &&
this.cachedWidth === width &&
this.cachedChildLines === childLinesKey &&
this.cachedBgSample === bgSample
) {
return this.cachedLines;
} }
// Apply background and padding // Apply background and padding
@ -113,10 +119,7 @@ export class Box implements Component {
} }
// Update cache // Update cache
this.cachedWidth = width; this.cache = { childLines, width, bgSample, lines: result };
this.cachedChildLines = childLinesKey;
this.cachedBgSample = bgSample;
this.cachedLines = result;
return result; return result;
} }

View file

@ -1,4 +1,5 @@
import { marked, type Token } from "marked"; import { marked, type Token } from "marked";
import { isImageLine } from "../terminal-image.js";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js"; import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
@ -121,7 +122,11 @@ export class Markdown implements Component {
// Wrap lines (NO padding, NO background yet) // Wrap lines (NO padding, NO background yet)
const wrappedLines: string[] = []; const wrappedLines: string[] = [];
for (const line of renderedLines) { 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 // Add margins and background to each wrapped line
@ -131,6 +136,11 @@ export class Markdown implements Component {
const contentLines: string[] = []; const contentLines: string[] = [];
for (const line of wrappedLines) { for (const line of wrappedLines) {
if (isImageLine(line)) {
contentLines.push(line);
continue;
}
const lineWithMargins = leftMargin + line + rightMargin; const lineWithMargins = leftMargin + line + rightMargin;
if (bgFn) { if (bgFn) {

View file

@ -79,6 +79,24 @@ export function getCapabilities(): TerminalCapabilities {
export function resetCapabilitiesCache(): void { export function resetCapabilitiesCache(): void {
cachedCapabilities = null; 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);
} }
/** /**

View file

@ -7,7 +7,7 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { isKeyRelease, matchesKey } from "./keys.js"; import { isKeyRelease, matchesKey } from "./keys.js";
import type { Terminal } from "./terminal.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"; import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
/** /**
@ -489,10 +489,6 @@ export class TUI extends Container {
return result; return result;
} }
private containsImage(line: string): boolean {
return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
}
/** /**
* Resolve overlay layout from options. * Resolve overlay layout from options.
* Returns { width, row, col, maxHeight } for rendering. * Returns { width, row, col, maxHeight } for rendering.
@ -712,7 +708,13 @@ export class TUI extends Container {
private applyLineResets(lines: string[]): string[] { private applyLineResets(lines: string[]): string[] {
const reset = TUI.SEGMENT_RESET; 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. */ /** 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, overlayWidth: number,
totalWidth: number, totalWidth: number,
): string { ): string {
if (this.containsImage(baseLine)) return baseLine; if (isImageLine(baseLine)) return baseLine;
// Single pass through baseLine extracts both before and after segments // Single pass through baseLine extracts both before and after segments
const afterStart = startCol + overlayWidth; const afterStart = startCol + overlayWidth;
@ -971,8 +973,8 @@ export class TUI extends Container {
if (i > firstChanged) buffer += "\r\n"; if (i > firstChanged) buffer += "\r\n";
buffer += "\x1b[2K"; // Clear current line buffer += "\x1b[2K"; // Clear current line
const line = newLines[i]; const line = newLines[i];
const isImageLine = this.containsImage(line); const isImage = isImageLine(line);
if (!isImageLine && visibleWidth(line) > width) { if (!isImage && visibleWidth(line) > width) {
// Log all lines to crash file for debugging // Log all lines to crash file for debugging
const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
const crashData = [ const crashData = [