Files
gemini-cli/packages/core/src/utils/browser-shims.ts
T
galz10 7d655d978e feat(core): optimize ghostty integration and terminal serialization
- Integrate `ghostty-web` terminal initialization into `ShellExecutionService`.
   - Implement a `serializationCache` in `TerminalSerializer` using `isRowDirty` to significantly reduce CPU usage during terminal updates.
   - Add binary output detection and halting to `BackgroundShellDisplay` to prevent UI lag from large binary streams.
   - Shim `console.log` to silence verbose `[ghostty-vt]` internal warnings in the Node.js environment.
   - Refactor `extensionUpdates.test.ts` to use a more realistic `ExtensionManager` and reduce reliance on fragile FS mocks.
   - Improve scrollback handling and terminal state synchronization in `ShellExecutionService`.
2026-02-10 11:19:56 -08:00

326 lines
8.7 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
interface ShimmedElement {
style: Record<string, string>;
classList: {
add: () => void;
remove: () => void;
contains: () => boolean;
toggle: () => void;
};
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
appendChild: (node: ShimmedElement) => void;
removeChild: (node: ShimmedElement) => void;
setAttribute: (name: string, value: string) => void;
removeAttribute: (name: string, value: string) => void;
hasAttribute: (name: string) => boolean;
getAttribute: (name: string) => string | null;
getBoundingClientRect: () => {
width: number;
height: number;
top: number;
left: number;
right: number;
bottom: number;
};
focus: () => void;
blur: () => void;
getContext?: () => {
measureText: () => { width: number; height: number };
fillRect: () => void;
fillText: () => void;
drawImage: () => void;
beginPath: () => void;
moveTo: () => void;
lineTo: () => void;
stroke: () => void;
arc: () => void;
fill: () => void;
setTransform: () => void;
save: () => void;
restore: () => void;
scale: () => void;
clearRect: () => void;
};
width?: number;
height?: number;
}
interface ShimmedDocument {
createElement: (tag: string) => ShimmedElement;
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
getElementById: (id: string) => ShimmedElement | null;
body: ShimmedElement;
documentElement: ShimmedElement;
}
interface ShimmedNavigator {
userAgent: string;
clipboard: {
writeText: (text: string) => Promise<void>;
readText: () => Promise<string>;
};
}
interface ShimmedResponse {
ok: boolean;
status: number;
statusText: string;
arrayBuffer: () => Promise<ArrayBufferLike>;
}
interface GlobalWithShims {
self: Window & GlobalWithShims;
window: Window & GlobalWithShims;
name: string;
location: Location & {
href: string;
origin: string;
protocol: string;
host: string;
hostname: string;
port: string;
pathname: string;
search: string;
hash: string;
toString: () => string;
};
document: Document & ShimmedDocument;
navigator: Navigator & ShimmedNavigator;
fetch: (url: string | URL) => Promise<ShimmedResponse>;
ResizeObserver: new () => {
observe: () => void;
unobserve: () => void;
disconnect: () => void;
};
requestAnimationFrame: (cb: (time: number) => void) => number;
cancelAnimationFrame: (id: number) => void;
}
/**
* Minimal browser shims to allow web-focused libraries (like ghostty-web)
* to run in a Node.js environment.
*/
export function installBrowserShims(): void {
// Use a targeted cast to avoid 'any' or 'unknown'
// We cast to our specific interface which is a subset of globalThis
const global = globalThis as typeof globalThis & GlobalWithShims;
if (typeof global.self === 'undefined') {
global.self = global as any;
}
if (typeof global.window === 'undefined') {
global.window = global as any;
}
if (typeof global.name === 'undefined') {
global.name = 'gemini-cli-shim';
}
if (typeof global.location === 'undefined') {
global.location = {
href: `file://${process.cwd()}/`,
origin: 'file://',
protocol: 'file:',
host: '',
hostname: '',
port: '',
pathname: process.cwd() + '/',
search: '',
hash: '',
ancestorOrigins: {
length: 0,
contains: () => false,
item: () => null,
} as any,
assign: () => {},
reload: () => {},
replace: () => {},
toString() {
return this.href;
},
} as any;
}
const createBaseElement = (): any => ({
style: {},
classList: {
add: (): void => {},
remove: (): void => {},
contains: (): boolean => false,
toggle: (): void => {},
},
addEventListener: (): void => {},
removeEventListener: (): void => {},
appendChild: (node: any): any => node,
removeChild: (node: any): any => node,
setAttribute: (): void => {},
removeAttribute: (): void => {},
hasAttribute: (): boolean => false,
getAttribute: (): string | null => null,
getBoundingClientRect: () => ({
width: 800,
height: 600,
top: 0,
left: 0,
right: 800,
bottom: 600,
}),
focus: (): void => {},
blur: (): void => {},
// HTMLElement properties stubs
accessKey: '',
accessKeyLabel: '',
autocapitalize: '',
dir: '',
draggable: false,
hidden: false,
inert: false,
lang: '',
spellcheck: false,
title: '',
});
if (typeof global.document === 'undefined') {
global.document = {
createElement: (tag: string): any => {
const el = createBaseElement();
if (tag === 'canvas') {
return {
...el,
getContext: () => ({
measureText: () => ({
width: 10,
height: 20,
}),
fillRect: (): void => {},
fillText: (): void => {},
drawImage: (): void => {},
beginPath: (): void => {},
moveTo: (): void => {},
lineTo: (): void => {},
stroke: (): void => {},
arc: (): void => {},
fill: (): void => {},
setTransform: (): void => {},
save: (): void => {},
restore: (): void => {},
scale: (): void => {},
clearRect: (): void => {},
}),
width: 800,
height: 600,
};
}
return el;
},
createElementNS: (_ns: string, tag: string): any => {
return createBaseElement();
},
addEventListener: (): void => {},
removeEventListener: (): void => {},
getElementById: (): null => null,
body: createBaseElement(),
documentElement: createBaseElement(),
} as any;
}
if (typeof global.navigator === 'undefined') {
global.navigator = {
userAgent: 'Node.js',
clipboard: {
writeText: async (): Promise<void> => {},
readText: async (): Promise<string> => '',
},
} as any;
}
if (typeof global.fetch === 'undefined') {
global.fetch = (async (url: string | URL): Promise<ShimmedResponse> => {
const urlStr = url.toString();
if (urlStr.startsWith('file://')) {
const filePath = fileURLToPath(urlStr);
const buffer = fs.readFileSync(filePath);
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async (): Promise<ArrayBufferLike> =>
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
),
};
}
if (urlStr.startsWith('data:')) {
const commaIndex = urlStr.indexOf(',');
if (commaIndex === -1) {
throw new Error('Invalid data URL');
}
const base64 = urlStr.slice(commaIndex + 1);
const buffer = Buffer.from(base64, 'base64');
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async (): Promise<ArrayBufferLike> =>
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
),
};
}
throw new Error(`Fetch not implemented for URL: ${urlStr}`);
}) as any;
}
if (typeof global.ResizeObserver === 'undefined') {
global.ResizeObserver = class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
} as any;
}
if (typeof global.requestAnimationFrame === 'undefined') {
global.requestAnimationFrame = (cb: (time: number) => void): number => {
return setTimeout(() => cb(Date.now()), 16) as any;
};
}
if (typeof global.cancelAnimationFrame === 'undefined') {
global.cancelAnimationFrame = (id: number): void => {
clearTimeout(id);
};
}
// Silence noisy ghostty-vt warnings in Node.js environment
if (!(console.log as any).__isShimmed) {
const originalLog = console.log;
const shimmedLog = (...args: any[]) => {
const isGhosttyWarning =
args.length > 0 &&
typeof args[0] === 'string' &&
args[0].includes('[ghostty-vt]') &&
args.some((arg) => typeof arg === 'string' && arg.includes('warning'));
if (isGhosttyWarning) {
return;
}
originalLog.apply(console, args);
};
(shimmedLog as any).__isShimmed = true;
console.log = shimmedLog;
}
}