mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-24 21:10:43 -07:00
- Expose Ink rootNode in AppRig and render utilities for tree traversal - Add toVisuallyContain matcher to verify component presence in the Ink DOM - Inject component metadata as comments into generated SVGs for auditing - Add waitForComponent to AppRig for deterministic UI state synchronization - Implement visual journey test for SuggestionsDisplay
237 lines
7.0 KiB
TypeScript
237 lines
7.0 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type { Terminal } from '@xterm/headless';
|
|
import { type DOMElement } from 'ink';
|
|
|
|
export const generateSvgForTerminal = (
|
|
terminal: Terminal,
|
|
options: { rootNode?: DOMElement } = {},
|
|
): string => {
|
|
const activeBuffer = terminal.buffer.active;
|
|
const { rootNode } = options;
|
|
|
|
const getHexColor = (
|
|
isRGB: boolean,
|
|
isPalette: boolean,
|
|
isDefault: boolean,
|
|
colorCode: number,
|
|
): string | null => {
|
|
if (isDefault) return null;
|
|
if (isRGB) {
|
|
return `#${colorCode.toString(16).padStart(6, '0')}`;
|
|
}
|
|
if (isPalette) {
|
|
if (colorCode >= 0 && colorCode <= 15) {
|
|
return (
|
|
[
|
|
'#000000',
|
|
'#cd0000',
|
|
'#00cd00',
|
|
'#cdcd00',
|
|
'#0000ee',
|
|
'#cd00cd',
|
|
'#00cdcd',
|
|
'#e5e5e5',
|
|
'#7f7f7f',
|
|
'#ff0000',
|
|
'#00ff00',
|
|
'#ffff00',
|
|
'#5c5cff',
|
|
'#ff00ff',
|
|
'#00ffff',
|
|
'#ffffff',
|
|
][colorCode] || null
|
|
);
|
|
} else if (colorCode >= 16 && colorCode <= 231) {
|
|
const v = [0, 95, 135, 175, 215, 255];
|
|
const c = colorCode - 16;
|
|
const b = v[c % 6];
|
|
const g = v[Math.floor(c / 6) % 6];
|
|
const r = v[Math.floor(c / 36) % 6];
|
|
return `#${[r, g, b].map((x) => x?.toString(16).padStart(2, '0')).join('')}`;
|
|
} else if (colorCode >= 232 && colorCode <= 255) {
|
|
const gray = 8 + (colorCode - 232) * 10;
|
|
const hex = gray.toString(16).padStart(2, '0');
|
|
return `#${hex}${hex}${hex}`;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const escapeXml = (unsafe: string): string =>
|
|
// eslint-disable-next-line no-control-regex
|
|
unsafe.replace(/[<>&'"\x00-\x08\x0B-\x0C\x0E-\x1F]/g, (c) => {
|
|
switch (c) {
|
|
case '<':
|
|
return '<';
|
|
case '>':
|
|
return '>';
|
|
case '&':
|
|
return '&';
|
|
case "'":
|
|
return ''';
|
|
case '"':
|
|
return '"';
|
|
default:
|
|
return '';
|
|
}
|
|
});
|
|
|
|
const charWidth = 9;
|
|
const charHeight = 17;
|
|
const padding = 10;
|
|
|
|
// Find the actual number of rows with content to avoid rendering trailing blank space.
|
|
let contentRows = terminal.rows;
|
|
for (let y = terminal.rows - 1; y >= 0; y--) {
|
|
const line = activeBuffer.getLine(y);
|
|
if (line && line.translateToString(true).trim().length > 0) {
|
|
contentRows = y + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (contentRows === 0) contentRows = 1; // Minimum 1 row
|
|
|
|
const width = terminal.cols * charWidth + padding * 2;
|
|
const height = contentRows * charHeight + padding * 2;
|
|
|
|
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
`;
|
|
svg += ` <style>
|
|
`;
|
|
svg += ` text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
|
`;
|
|
svg += ` </style>
|
|
`;
|
|
svg += ` <rect width="${width}" height="${height}" fill="#000000" />
|
|
`; // Terminal background
|
|
svg += ` <g transform="translate(${padding}, ${padding})">
|
|
`;
|
|
|
|
for (let y = 0; y < contentRows; y++) {
|
|
const line = activeBuffer.getLine(y);
|
|
if (!line) continue;
|
|
|
|
let currentFgHex: string | null = null;
|
|
let currentBgHex: string | null = null;
|
|
let currentIsBold = false;
|
|
let currentIsItalic = false;
|
|
let currentIsUnderline = false;
|
|
let currentBlockStartCol = -1;
|
|
let currentBlockText = '';
|
|
let currentBlockNumCells = 0;
|
|
|
|
const finalizeBlock = (_endCol: number) => {
|
|
if (currentBlockStartCol !== -1) {
|
|
if (currentBlockText.length > 0) {
|
|
const xPos = currentBlockStartCol * charWidth;
|
|
const yPos = y * charHeight;
|
|
|
|
if (currentBgHex) {
|
|
const rectWidth = currentBlockNumCells * charWidth;
|
|
svg += ` <rect x="${xPos}" y="${yPos}" width="${rectWidth}" height="${charHeight}" fill="${currentBgHex}" />
|
|
`;
|
|
}
|
|
if (currentBlockText.trim().length > 0 || currentIsUnderline) {
|
|
const fill = currentFgHex || '#ffffff'; // Default text color
|
|
const textWidth = currentBlockNumCells * charWidth;
|
|
|
|
let extraAttrs = '';
|
|
if (currentIsBold) extraAttrs += ' font-weight="bold"';
|
|
if (currentIsItalic) extraAttrs += ' font-style="italic"';
|
|
if (currentIsUnderline)
|
|
extraAttrs += ' text-decoration="underline"';
|
|
|
|
// Use textLength to ensure the block fits exactly into its designated cells
|
|
const textElement = `<text x="${xPos}" y="${yPos + 2}" fill="${fill}" textLength="${textWidth}" lengthAdjust="spacingAndGlyphs"${extraAttrs}>${escapeXml(currentBlockText)}</text>`;
|
|
|
|
svg += ` ${textElement}\n`;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
for (let x = 0; x < line.length; x++) {
|
|
const cell = line.getCell(x);
|
|
if (!cell) continue;
|
|
const cellWidth = cell.getWidth();
|
|
if (cellWidth === 0) continue; // Skip continuation cells of wide characters
|
|
|
|
let fgHex = getHexColor(
|
|
cell.isFgRGB(),
|
|
cell.isFgPalette(),
|
|
cell.isFgDefault(),
|
|
cell.getFgColor(),
|
|
);
|
|
let bgHex = getHexColor(
|
|
cell.isBgRGB(),
|
|
cell.isBgPalette(),
|
|
cell.isBgDefault(),
|
|
cell.getBgColor(),
|
|
);
|
|
|
|
if (cell.isInverse()) {
|
|
const tempFgHex = fgHex;
|
|
fgHex = bgHex || '#000000';
|
|
bgHex = tempFgHex || '#ffffff';
|
|
}
|
|
|
|
const isBold = !!cell.isBold();
|
|
const isItalic = !!cell.isItalic();
|
|
const isUnderline = !!cell.isUnderline();
|
|
|
|
let chars = cell.getChars();
|
|
if (chars === '') chars = ' '.repeat(cellWidth);
|
|
|
|
if (
|
|
fgHex !== currentFgHex ||
|
|
bgHex !== currentBgHex ||
|
|
isBold !== currentIsBold ||
|
|
isItalic !== currentIsItalic ||
|
|
isUnderline !== currentIsUnderline ||
|
|
currentBlockStartCol === -1
|
|
) {
|
|
finalizeBlock(x);
|
|
currentFgHex = fgHex;
|
|
currentBgHex = bgHex;
|
|
currentIsBold = isBold;
|
|
currentIsItalic = isItalic;
|
|
currentIsUnderline = isUnderline;
|
|
currentBlockStartCol = x;
|
|
currentBlockText = chars;
|
|
currentBlockNumCells = cellWidth;
|
|
} else {
|
|
currentBlockText += chars;
|
|
currentBlockNumCells += cellWidth;
|
|
}
|
|
}
|
|
finalizeBlock(line.length);
|
|
}
|
|
|
|
// Inject unique component names from the tree as comments for auditing
|
|
if (rootNode) {
|
|
const components = new Set<string>();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const collect = (node: any) => {
|
|
if (node.internal_componentName)
|
|
components.add(node.internal_componentName);
|
|
if (node.internal_testId) components.add(node.internal_testId);
|
|
if (node.childNodes) {
|
|
for (const child of node.childNodes) collect(child);
|
|
}
|
|
};
|
|
collect(rootNode);
|
|
for (const name of components) {
|
|
svg += ` <!-- component: ${name} -->\n`;
|
|
}
|
|
}
|
|
|
|
svg += ` </g>\n</svg>`;
|
|
return svg;
|
|
};
|