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`.
This commit is contained in:
galz10
2026-02-10 11:19:56 -08:00
parent 00f496a61c
commit 7d655d978e
7 changed files with 273 additions and 123 deletions
@@ -18,25 +18,10 @@ import {
type GeminiCLIExtension,
coreEvents,
} from '@google/gemini-cli-core';
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
import { EXTENSION_SETTINGS_FILENAME, EXTENSIONS_CONFIG_FILENAME } from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { createTestMergedSettings } from '../settings.js';
vi.mock('node:fs', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = await importOriginal<any>();
return {
...actual,
default: {
...actual.default,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
existsSync: vi.fn((...args: any[]) => actual.existsSync(...args)),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -49,9 +34,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
log: vi.fn(),
},
coreEvents: {
emitFeedback: vi.fn(), // Mock emitFeedback
on: vi.fn(),
off: vi.fn(),
...actual.coreEvents,
emitFeedback: vi.fn(),
},
};
});
@@ -68,7 +52,6 @@ vi.mock('os', async (importOriginal) => {
describe('extensionUpdates', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let extensionDir: string;
let mockKeychainData: Record<string, Record<string, string>>;
beforeEach(() => {
@@ -111,18 +94,7 @@ describe('extensionUpdates', () => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
// Mock ExtensionStorage to rely on our temp extension dir
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
extensionDir,
);
// Mock getEnvFilePath is checking extensionDir/variables.env? No, it used ExtensionStorage logic.
// getEnvFilePath in extensionSettings.ts:
// if workspace, process.cwd()/.env (we need to mock process.cwd or move tempWorkspaceDir there)
// if user, ExtensionStorage(name).getEnvFilePath() -> joins extensionDir + '.env'
fs.mkdirSync(extensionDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
@@ -133,10 +105,19 @@ describe('extensionUpdates', () => {
vi.restoreAllMocks();
});
const createExtension = (config: ExtensionConfig, sourceDir: string) => {
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(
path.join(sourceDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
};
describe('getMissingSettings', () => {
it('should return empty list if all settings are present', async () => {
const extensionName = 'test-ext';
const config: ExtensionConfig = {
name: 'test-ext',
name: extensionName,
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
@@ -145,13 +126,17 @@ describe('extensionUpdates', () => {
};
const extensionId = '12345';
const extensionStorage = new ExtensionStorage(extensionName);
const extensionDir = extensionStorage.getExtensionDir();
fs.mkdirSync(extensionDir, { recursive: true });
// Setup User Env
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
const userEnvPath = extensionStorage.getEnvFilePath();
fs.writeFileSync(userEnvPath, 'VAR1=val1');
// Setup Keychain
const userKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext ${extensionId}`,
`Gemini CLI Extensions ${extensionName} ${extensionId}`,
);
await userKeychain.setSecret('VAR2', 'val2');
@@ -167,7 +152,7 @@ describe('extensionUpdates', () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
settings: [{ name: 's1', description: 'd1', envVar: 'UNIQUE_VAR_1' }],
};
const extensionId = '12345';
@@ -185,7 +170,7 @@ describe('extensionUpdates', () => {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
{ name: 's2', description: 'd2', envVar: 'UNIQUE_VAR_2', sensitive: true },
],
};
const extensionId = '12345';
@@ -203,7 +188,7 @@ describe('extensionUpdates', () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
settings: [{ name: 's1', description: 'd1', envVar: 'UNIQUE_VAR_3' }],
};
const extensionId = '12345';
@@ -212,7 +197,7 @@ describe('extensionUpdates', () => {
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
fs.writeFileSync(workspaceEnvPath, 'VAR1=val1');
fs.writeFileSync(workspaceEnvPath, 'UNIQUE_VAR_3=val1');
const missing = await getMissingSettings(
config,
@@ -225,82 +210,76 @@ describe('extensionUpdates', () => {
describe('ExtensionManager integration', () => {
it('should warn about missing settings after update', async () => {
// Mock ExtensionManager methods to avoid FS/Network usage
const extensionName = 'test-ext';
const sourceDir = path.join(tempWorkspaceDir, 'test-ext-source');
const newConfig: ExtensionConfig = {
name: 'test-ext',
name: extensionName,
version: '1.1.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
settings: [{ name: 's1', description: 'd1', envVar: 'UNIQUE_VAR_4' }],
};
const previousConfig: ExtensionConfig = {
name: 'test-ext',
name: extensionName,
version: '1.0.0',
settings: [],
};
createExtension(newConfig, sourceDir);
const installMetadata: ExtensionInstallMetadata = {
source: extensionDir,
source: sourceDir,
type: 'local',
autoUpdate: true,
};
const manager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
settings: createTestMergedSettings({
telemetry: { enabled: false },
experimental: { extensionConfig: true },
hooksConfig: { enabled: false },
}),
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null, // Simulate non-interactive
requestSetting: null,
});
// Mock methods called by installOrUpdateExtension
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
// We still need to mock some things because a full "live" load involves many moving parts (themes, MCP, etc.)
// but we are using much more of the real manager logic.
vi.spyOn(manager, 'getExtensions').mockReturnValue([
{
name: 'test-ext',
name: extensionName,
version: '1.0.0',
installMetadata,
path: extensionDir,
// Mocks for other required props
contextFiles: [],
mcpServers: {},
hooks: undefined,
path: sourceDir, // Mocking the path to point to our temp source
isActive: true,
id: 'test-id',
settings: [],
resolvedSettings: [],
skills: [],
contextFiles: [],
mcpServers: {},
} as unknown as GeminiCLIExtension,
]);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue(
{} as unknown as GeminiCLIExtension,
);
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
// Mock fs.promises for the operations inside installOrUpdateExtension
vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
vi.mocked(fs.existsSync).mockReturnValue(false); // No hooks
try {
await manager.installOrUpdateExtension(installMetadata, previousConfig);
} catch (_) {
// Ignore errors from copyExtension or others, we just want to verify the warning
}
// Mock things that would touch global state or fail in restricted environment
vi.spyOn(manager as any, 'loadExtension').mockResolvedValue({
name: extensionName,
id: 'test-id',
} as unknown as GeminiCLIExtension);
vi.spyOn(manager, 'enableExtension').mockResolvedValue(undefined);
vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
await manager.installOrUpdateExtension(installMetadata, previousConfig);
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Extension "test-ext" has missing settings: s1',
`Extension "${extensionName}" has missing settings: s1`,
),
);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'Please run "gemini extensions config test-ext [setting-name]"',
`Please run "gemini extensions config ${extensionName} [setting-name]"`,
),
);
});
@@ -89,12 +89,6 @@ export const BackgroundShellDisplay = ({
return;
}
// Set initial output from the shell object
const shell = shells.get(activePid);
if (shell) {
setOutput(shell.output);
}
subscribedRef.current = false;
// Subscribe to live updates for the active shell
@@ -123,7 +117,7 @@ export const BackgroundShellDisplay = ({
unsubscribe();
subscribedRef.current = false;
};
}, [activePid, shells]);
}, [activePid]);
// Sync highlightedPid with activePid when list opens
useEffect(() => {
@@ -382,6 +376,21 @@ export const BackgroundShellDisplay = ({
};
const renderOutput = () => {
if (activeShell?.isBinary) {
return (
<Box flexDirection="column" padding={1}>
<Text color={theme.status.warning}>
[Binary output detected. Halting stream...]
</Text>
{activeShell.binaryBytesReceived > 0 && (
<Text>
Received: {Math.round(activeShell.binaryBytesReceived / 1024)} KB
</Text>
)}
</Box>
);
}
const lines = typeof output === 'string' ? output.split('\n') : output;
return (
@@ -166,7 +166,10 @@ describe('ShellExecutionService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSerializeTerminalToObject.mockReturnValue([]);
ShellExecutionService.resetInitializationForTesting();
mockSerializeTerminalToObject.mockImplementation(() =>
createMockSerializeTerminalToObjectReturnValue(''),
);
mockIsBinary.mockReturnValue(false);
mockPlatform.mockReturnValue('linux');
mockResolveExecutable.mockImplementation(async (exe: string) => exe);
@@ -198,12 +201,16 @@ describe('ShellExecutionService', () => {
mockHeadlessTerminal = {
resize: vi.fn(),
scrollLines: vi.fn(),
scrollToTop: vi.fn(),
onScroll: vi.fn(),
getScrollbackLength: vi.fn().mockReturnValue(0),
rows: 24,
buffer: {
active: {
viewportY: 0,
},
},
};
} as any;
mockPtySpawn.mockReturnValue(mockPtyProcess);
});
@@ -435,6 +442,7 @@ describe('ShellExecutionService', () => {
ptyProcess: mockPtyProcess as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
headlessTerminal: mockHeadlessTerminal as any,
serializationCache: new Map(),
});
});
@@ -23,6 +23,8 @@ import { installBrowserShims } from '../utils/browser-shims.js';
import {
serializeTerminalToObject,
type AnsiOutput,
type AnsiLine,
type AnsiToken,
} from '../utils/terminalSerializer.js';
import {
sanitizeEnvironment,
@@ -130,6 +132,7 @@ interface ActivePty {
ptyProcess: IPty;
headlessTerminal: Terminal;
maxSerializedLines?: number;
serializationCache: Map<number, AnsiLine>;
}
interface ActiveChildProcess {
@@ -204,6 +207,63 @@ export class ShellExecutionService {
number,
Set<(event: ShellOutputEvent) => void>
>();
private static terminalInitializationPromise: Promise<void> | null = null;
static {
installBrowserShims();
}
private static getFullBufferText(
terminal: Terminal,
scrollbackLimit?: number,
): string {
const buffer = terminal.buffer.active;
const lines: string[] = [];
const scrollbackLength = terminal.getScrollbackLength();
const rows = terminal.rows;
const totalRelevantLines = scrollbackLength + rows;
for (let i = 0; i < totalRelevantLines; i++) {
const line = buffer.getLine(i);
if (!line) {
continue;
}
const lineContent = line.translateToString(false);
if (line.isWrapped && lines.length > 0) {
lines[lines.length - 1] += lineContent;
} else {
lines.push(lineContent);
}
}
// Remove trailing empty lines
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
if (scrollbackLimit !== undefined && lines.length > scrollbackLimit) {
return lines.slice(lines.length - scrollbackLimit).join('\n');
}
return lines.join('\n');
}
private static initializeGhostty(): Promise<void> {
if (!this.terminalInitializationPromise) {
this.terminalInitializationPromise = init();
}
return this.terminalInitializationPromise;
}
/** @internal */
static resetInitializationForTesting(): void {
this.terminalInitializationPromise = null;
}
/**
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
*
@@ -580,10 +640,8 @@ export class ShellExecutionService {
const result = new Promise<ShellExecutionResult>((resolve) => {
this.activeResolvers.set(ptyProcess.pid, resolve);
installBrowserShims();
const initializeTerminal = async () => {
await init();
await ShellExecutionService.initializeGhostty();
const scrollback =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
@@ -602,12 +660,17 @@ export class ShellExecutionService {
ptyProcess,
headlessTerminal,
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
serializationCache: new Map(),
});
return headlessTerminal;
};
const terminalPromise = initializeTerminal();
let headlessTerminalInstance: Terminal | null = null;
const terminalPromise = initializeTerminal().then((t) => {
headlessTerminalInstance = t;
return t;
});
let processingChain = Promise.resolve();
let decoder: TextDecoder | null = null;
@@ -623,20 +686,22 @@ export class ShellExecutionService {
let hasStartedOutput = false;
let renderTimeout: NodeJS.Timeout | null = null;
const renderFn = async () => {
const renderFn = (isFinal = false) => {
renderTimeout = null;
if (!isStreamingRawContent) {
if (!isStreamingRawContent || !headlessTerminalInstance) {
return;
}
const headlessTerminal = await terminalPromise;
const headlessTerminal = headlessTerminalInstance;
const activePty = ShellExecutionService.activePtys.get(ptyProcess.pid);
if (!activePty) return;
if (!shellExecutionConfig.disableDynamicLineTrimming) {
if (!hasStartedOutput) {
if (!hasStartedOutput && !isFinal) {
const scrollbackLimit =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
const bufferText = getFullBufferText(
const bufferText = ShellExecutionService.getFullBufferText(
headlessTerminal,
scrollbackLimit,
);
@@ -648,14 +713,15 @@ export class ShellExecutionService {
}
const buffer = headlessTerminal.buffer.active;
const endLine = buffer.length;
const endLine =
headlessTerminal.getScrollbackLength() + headlessTerminal.rows;
const scrollbackLimit =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
const startLine = Math.max(
0,
endLine -
Math.min(
scrollbackLimit + rows,
scrollbackLimit + headlessTerminal.rows,
shellExecutionConfig.maxSerializedLines ?? 2000,
),
);
@@ -666,11 +732,16 @@ export class ShellExecutionService {
headlessTerminal,
startLine,
endLine,
activePty.serializationCache,
);
} else {
newOutput = (
serializeTerminalToObject(headlessTerminal, startLine, endLine) ||
[]
serializeTerminalToObject(
headlessTerminal,
startLine,
endLine,
activePty.serializationCache,
) || []
).map((line) =>
line.map((token) => {
token.fg = '';
@@ -707,7 +778,35 @@ export class ShellExecutionService {
? newOutput
: trimmedOutput;
if (output !== finalOutput) {
const isLineEqual = (lineA: AnsiLine, lineB: AnsiLine): boolean => {
if (lineA === lineB) return true;
if (lineA.length !== lineB.length) return false;
return lineA.every((token: AnsiToken, i: number) => {
const other = lineB[i];
return (
token.text === other.text &&
token.fg === other.fg &&
token.bg === other.bg &&
token.bold === other.bold &&
token.italic === other.italic &&
token.underline === other.underline &&
token.dim === other.dim &&
token.inverse === other.inverse
);
});
};
const hasChanged =
isFinal ||
!output ||
!Array.isArray(output) ||
output.length !== finalOutput.length ||
finalOutput.some((line, i) => {
const prevLine = (output as AnsiOutput)[i];
return !prevLine || !isLineEqual(line, prevLine);
});
if (hasChanged) {
output = finalOutput;
const event: ShellOutputEvent = {
type: 'data',
@@ -723,9 +822,7 @@ export class ShellExecutionService {
if (renderTimeout) {
clearTimeout(renderTimeout);
}
renderFn().catch(() => {
// Ignore errors during final render
});
renderFn(true);
return;
}
@@ -734,19 +831,19 @@ export class ShellExecutionService {
}
renderTimeout = setTimeout(() => {
renderFn().catch(() => {
// Ignore errors during render
});
renderFn(false);
renderTimeout = null;
}, 68);
}, 100);
};
terminalPromise.then((headlessTerminal) => {
headlessTerminal.onScroll(() => {
if (!isWriting) {
render();
}
});
if (typeof (headlessTerminal as any).onScroll === 'function') {
(headlessTerminal as any).onScroll(() => {
if (!isWriting) {
render();
}
});
}
});
const handleOutput = (data: Buffer) => {
@@ -785,13 +882,21 @@ export class ShellExecutionService {
resolve();
return;
}
isWriting = true;
const headlessTerminal = await terminalPromise;
headlessTerminal.write(decodedChunk, () => {
render();
isWriting = false;
resolve();
});
const writeToTerminal = (term: Terminal) => {
isWriting = true;
term.write(decodedChunk, () => {
render();
isWriting = false;
resolve();
});
};
if (headlessTerminalInstance) {
writeToTerminal(headlessTerminalInstance);
} else {
terminalPromise.then(writeToTerminal);
}
} else {
const totalBytes = outputChunks.reduce(
(sum, chunk) => sum + chunk.length,
@@ -822,7 +927,7 @@ export class ShellExecutionService {
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
this.activePtys.delete(ptyProcess.pid);
// Attempt to destroy the PTY to ensure FD is closed
try {
(ptyProcess as IPty & { destroy?: () => void }).destroy?.();
@@ -832,6 +937,10 @@ export class ShellExecutionService {
const finalize = async () => {
render(true);
if (renderTimeout) {
clearTimeout(renderTimeout);
renderTimeout = null;
}
// Store exit info for late subscribers (e.g. backgrounding race condition)
this.exitedPtyInfo.set(ptyProcess.pid, { exitCode, signal });
+14 -9
View File
@@ -21,16 +21,17 @@ describe('browser-shims', () => {
it('should install document shim with createElement', () => {
expect(globalThis.document).toBeDefined();
expect(typeof globalThis.document.createElement).toBe('function');
const div = globalThis.document.createElement('div');
expect(div).toBeDefined();
});
it('should install fetch shim that handles file and data URLs', async () => {
expect(typeof globalThis.fetch).toBe('function');
// Test data URL (minimal WASM-like header)
const dataUrl = 'data:application/wasm;base64,AGFzbQEAAAABBgBgAX5/AX8DAgEABwcBA2xvZwAA';
const dataUrl =
'data:application/wasm;base64,AGFzbQEAAAABBgBgAX5/AX8DAgEABwcBA2xvZwAA';
const response = await globalThis.fetch(dataUrl);
expect(response.ok).toBe(true);
const buffer = await response.arrayBuffer();
@@ -40,20 +41,24 @@ describe('browser-shims', () => {
it('should allow ghostty-web to initialize and create a terminal', async () => {
await init();
const term = new Terminal({
cols: 80,
rows: 24
cols: 80,
rows: 24,
});
expect(term).toBeDefined();
// Terminal needs to be opened to write
// We use type casting to avoid 'any'
const parent = globalThis.document.createElement('div');
term.open(parent as unknown as HTMLElement);
term.write('Pickle Rick was here');
const line = term.buffer.active.getLine(0);
expect(line?.translateToString(true)).toContain('Pickle Rick');
});
it('should shim console.log', () => {
expect((console.log as any).__isShimmed).toBe(true);
});
});
+19
View File
@@ -303,4 +303,23 @@ export function installBrowserShims(): 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;
}
}
@@ -153,6 +153,7 @@ export function serializeTerminalToObject(
terminal: Terminal,
startLine?: number,
endLine?: number,
cache?: Map<number, AnsiLine>,
): AnsiOutput {
const buffer = terminal.buffer.active;
const cursorX = buffer.cursorX;
@@ -169,7 +170,24 @@ export function serializeTerminalToObject(
const effectiveStart = startLine ?? buffer.viewportY;
const effectiveEnd = endLine ?? buffer.viewportY + terminal.rows;
const absoluteCursorY = buffer.baseY + cursorY;
for (let y = effectiveStart; y < effectiveEnd; y++) {
// Skip dirty check for the cursor line as it always needs re-serialization
if (
cache &&
terminal &&
y !== absoluteCursorY &&
typeof (terminal as any).isRowDirty === 'function' &&
(terminal as any).isRowDirty(y) === false
) {
const cached = cache.get(y);
if (cached) {
result.push(cached);
continue;
}
}
const line = buffer.getLine(y);
const currentLine: AnsiLine = [];
if (!line) {
@@ -222,6 +240,9 @@ export function serializeTerminalToObject(
currentLine.push(token);
}
if (cache && y !== absoluteCursorY) {
cache.set(y, currentLine);
}
result.push(currentLine);
}