mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 20:14:44 -07:00
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:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user