mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-09 16:36:40 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6dc734d52 | |||
| 7d655d978e | |||
| 00f496a61c |
Generated
+7
-7
@@ -5385,12 +5385,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
|
||||
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -9726,6 +9720,12 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ghostty-web": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ghostty-web/-/ghostty-web-0.4.0.tgz",
|
||||
"integrity": "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
@@ -18224,7 +18224,6 @@
|
||||
"@opentelemetry/sdk-node": "^0.203.0",
|
||||
"@types/glob": "^8.1.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
@@ -18232,6 +18231,7 @@
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"glob": "^12.0.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
updateSetting,
|
||||
ExtensionSettingScope,
|
||||
getScopedEnvContents,
|
||||
getMissingSettings,
|
||||
} from './extensionSettings.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { ExtensionStorage } from './storage.js';
|
||||
@@ -891,4 +892,104 @@ describe('extensionSettings', () => {
|
||||
expect(actualContent).toContain('VAR1="value with \\"quotes\\""');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMissingSettings', () => {
|
||||
it('should return empty list if all settings are present', async () => {
|
||||
const extensionName = 'test-ext';
|
||||
const config: ExtensionConfig = {
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
|
||||
],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const extensionStorage = new ExtensionStorage(extensionName);
|
||||
// We already mocked getExtensionDir to return extensionDir in beforeEach
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
|
||||
// Setup User Env
|
||||
const userEnvPath = extensionStorage.getEnvFilePath();
|
||||
fs.writeFileSync(userEnvPath, 'VAR1=val1');
|
||||
|
||||
// Setup Keychain
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions ${extensionName} ${extensionId}`,
|
||||
);
|
||||
await userKeychain.setSecret('VAR2', 'val2');
|
||||
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should identify missing non-sensitive settings', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'UNIQUE_VAR_1' }],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].name).toBe('s1');
|
||||
});
|
||||
|
||||
it('should identify missing sensitive settings', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 's2',
|
||||
description: 'd2',
|
||||
envVar: 'UNIQUE_VAR_2',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(missing[0].name).toBe('s2');
|
||||
});
|
||||
|
||||
it('should respect settings present in workspace', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [{ name: 's1', description: 'd1', envVar: 'UNIQUE_VAR_3' }],
|
||||
};
|
||||
const extensionId = '12345';
|
||||
|
||||
// Setup Workspace Env
|
||||
const workspaceEnvPath = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSION_SETTINGS_FILENAME,
|
||||
);
|
||||
fs.writeFileSync(workspaceEnvPath, 'UNIQUE_VAR_3=val1');
|
||||
|
||||
const missing = await getMissingSettings(
|
||||
config,
|
||||
extensionId,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,14 +145,15 @@ describe('extensionUpdates', () => {
|
||||
describe('ExtensionManager integration', () => {
|
||||
it('should warn about missing settings after update', async () => {
|
||||
// 1. Setup Data
|
||||
const extensionName = 'test-ext';
|
||||
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: [],
|
||||
};
|
||||
@@ -169,6 +170,7 @@ describe('extensionUpdates', () => {
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
hooksConfig: { enabled: false },
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
@@ -178,7 +180,7 @@ describe('extensionUpdates', () => {
|
||||
vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
|
||||
vi.spyOn(manager, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
name: 'test-ext',
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
installMetadata,
|
||||
path: '/mock/extensions/test-ext',
|
||||
@@ -217,13 +219,13 @@ describe('extensionUpdates', () => {
|
||||
// 6. Assert
|
||||
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 (
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"@opentelemetry/sdk-node": "^0.203.0",
|
||||
"@types/glob": "^8.1.0",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@xterm/headless": "5.5.0",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
@@ -49,6 +48,7 @@
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"glob": "^12.0.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
@@ -79,8 +79,8 @@
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"keytar": "^7.9.0"
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -307,8 +314,8 @@ describe('ShellExecutionService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should capture large output (10000 lines)', async () => {
|
||||
const lineCount = 10000;
|
||||
it('should capture large output (1000 lines)', async () => {
|
||||
const lineCount = 1000;
|
||||
const lines = Array.from({ length: lineCount }, (_, i) => `line ${i}`);
|
||||
const expectedOutput = lines.join('\n');
|
||||
|
||||
@@ -317,7 +324,7 @@ describe('ShellExecutionService', () => {
|
||||
(pty) => {
|
||||
// Send data in chunks to simulate realistic streaming
|
||||
// Use \r\n to ensure the terminal moves the cursor to the start of the line
|
||||
const chunkSize = 1000;
|
||||
const chunkSize = 200;
|
||||
for (let i = 0; i < lineCount; i += chunkSize) {
|
||||
const chunk = lines.slice(i, i + chunkSize).join('\r\n') + '\r\n';
|
||||
pty.onData.mock.calls[0][0](chunk);
|
||||
@@ -389,11 +396,8 @@ describe('ShellExecutionService', () => {
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
// The terminal should keep the *last* 'scrollbackLimit' lines + lines in the viewport.
|
||||
// xterm.js scrollback is the number of lines *above* the viewport.
|
||||
// So total lines retained = scrollback + rows.
|
||||
// However, our `getFullBufferText` implementation iterates the *active* buffer.
|
||||
// In headless xterm, the buffer length grows.
|
||||
// Let's verify that we have fewer lines than totalLines.
|
||||
// In ghostty-web, the buffer length includes scrollback + rows.
|
||||
// Let's verify that the output contains the expected number of lines.
|
||||
|
||||
const outputLines = result.output
|
||||
.trim()
|
||||
@@ -438,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(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,17 +18,19 @@ import {
|
||||
type ShellType,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { isBinary } from '../utils/textUtils.js';
|
||||
import pkg from '@xterm/headless';
|
||||
import { init, Terminal } from 'ghostty-web';
|
||||
import { installBrowserShims } from '../utils/browser-shims.js';
|
||||
import {
|
||||
serializeTerminalToObject,
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
type AnsiToken,
|
||||
} from '../utils/terminalSerializer.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
import { killProcessGroup } from '../utils/process-utils.js';
|
||||
const { Terminal } = pkg;
|
||||
|
||||
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
|
||||
|
||||
@@ -128,8 +130,9 @@ export type ShellOutputEvent =
|
||||
|
||||
interface ActivePty {
|
||||
ptyProcess: IPty;
|
||||
headlessTerminal: pkg.Terminal;
|
||||
headlessTerminal: Terminal;
|
||||
maxSerializedLines?: number;
|
||||
serializationCache: Map<number, AnsiLine>;
|
||||
}
|
||||
|
||||
interface ActiveChildProcess {
|
||||
@@ -141,27 +144,27 @@ interface ActiveChildProcess {
|
||||
};
|
||||
}
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
const getFullBufferText = (
|
||||
terminal: Terminal,
|
||||
scrollbackLimit?: number,
|
||||
): string => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
|
||||
// ghostty-web buffer length includes the full possible scrollback + rows.
|
||||
// We only want the lines that have actually been written.
|
||||
// The current active screen lines are at the end of the scrollback.
|
||||
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;
|
||||
}
|
||||
// If the NEXT line is wrapped, it means it's a continuation of THIS line.
|
||||
// We should not trim the right side of this line because trailing spaces
|
||||
// might be significant parts of the wrapped content.
|
||||
// If it's not wrapped, we trim normally.
|
||||
let trimRight = true;
|
||||
if (i + 1 < buffer.length) {
|
||||
const nextLine = buffer.getLine(i + 1);
|
||||
if (nextLine?.isWrapped) {
|
||||
trimRight = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lineContent = line.translateToString(trimRight);
|
||||
const lineContent = line.translateToString(false);
|
||||
|
||||
if (line.isWrapped && lines.length > 0) {
|
||||
lines[lines.length - 1] += lineContent;
|
||||
@@ -171,10 +174,15 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
}
|
||||
|
||||
// Remove trailing empty lines
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
// Manual truncation if the library doesn't respect scrollbackLimit
|
||||
if (scrollbackLimit !== undefined && lines.length > scrollbackLimit) {
|
||||
return lines.slice(lines.length - scrollbackLimit).join('\n');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
@@ -199,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.
|
||||
*
|
||||
@@ -575,18 +640,36 @@ export class ShellExecutionService {
|
||||
const result = new Promise<ShellExecutionResult>((resolve) => {
|
||||
this.activeResolvers.set(ptyProcess.pid, resolve);
|
||||
|
||||
const headlessTerminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
cols,
|
||||
rows,
|
||||
scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT,
|
||||
});
|
||||
headlessTerminal.scrollToTop();
|
||||
const initializeTerminal = async () => {
|
||||
await ShellExecutionService.initializeGhostty();
|
||||
|
||||
this.activePtys.set(ptyProcess.pid, {
|
||||
ptyProcess,
|
||||
headlessTerminal,
|
||||
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
|
||||
const scrollback =
|
||||
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
|
||||
const headlessTerminal = new Terminal({
|
||||
cols,
|
||||
rows,
|
||||
scrollback,
|
||||
});
|
||||
|
||||
headlessTerminal.open(
|
||||
globalThis.document.createElement('div') as any,
|
||||
);
|
||||
headlessTerminal.scrollToTop();
|
||||
|
||||
this.activePtys.set(ptyProcess.pid, {
|
||||
ptyProcess,
|
||||
headlessTerminal,
|
||||
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
|
||||
serializationCache: new Map(),
|
||||
});
|
||||
|
||||
return headlessTerminal;
|
||||
};
|
||||
|
||||
let headlessTerminalInstance: Terminal | null = null;
|
||||
const terminalPromise = initializeTerminal().then((t) => {
|
||||
headlessTerminalInstance = t;
|
||||
return t;
|
||||
});
|
||||
|
||||
let processingChain = Promise.resolve();
|
||||
@@ -603,16 +686,25 @@ export class ShellExecutionService {
|
||||
let hasStartedOutput = false;
|
||||
let renderTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const renderFn = () => {
|
||||
const renderFn = (isFinal = false) => {
|
||||
renderTimeout = null;
|
||||
|
||||
if (!isStreamingRawContent) {
|
||||
if (!isStreamingRawContent || !headlessTerminalInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headlessTerminal = headlessTerminalInstance;
|
||||
const activePty = ShellExecutionService.activePtys.get(ptyProcess.pid);
|
||||
if (!activePty) return;
|
||||
|
||||
if (!shellExecutionConfig.disableDynamicLineTrimming) {
|
||||
if (!hasStartedOutput) {
|
||||
const bufferText = getFullBufferText(headlessTerminal);
|
||||
if (!hasStartedOutput && !isFinal) {
|
||||
const scrollbackLimit =
|
||||
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
|
||||
const bufferText = ShellExecutionService.getFullBufferText(
|
||||
headlessTerminal,
|
||||
scrollbackLimit,
|
||||
);
|
||||
if (bufferText.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -621,10 +713,17 @@ 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 - (shellExecutionConfig.maxSerializedLines ?? 2000),
|
||||
endLine -
|
||||
Math.min(
|
||||
scrollbackLimit + headlessTerminal.rows,
|
||||
shellExecutionConfig.maxSerializedLines ?? 2000,
|
||||
),
|
||||
);
|
||||
|
||||
let newOutput: AnsiOutput;
|
||||
@@ -633,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 = '';
|
||||
@@ -674,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',
|
||||
@@ -690,7 +822,7 @@ export class ShellExecutionService {
|
||||
if (renderTimeout) {
|
||||
clearTimeout(renderTimeout);
|
||||
}
|
||||
renderFn();
|
||||
renderFn(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -699,14 +831,18 @@ export class ShellExecutionService {
|
||||
}
|
||||
|
||||
renderTimeout = setTimeout(() => {
|
||||
renderFn();
|
||||
renderFn(false);
|
||||
renderTimeout = null;
|
||||
}, 68);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
headlessTerminal.onScroll(() => {
|
||||
if (!isWriting) {
|
||||
render();
|
||||
terminalPromise.then((headlessTerminal) => {
|
||||
if (typeof (headlessTerminal as any).onScroll === 'function') {
|
||||
(headlessTerminal as any).onScroll(() => {
|
||||
if (!isWriting) {
|
||||
render();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -714,54 +850,70 @@ export class ShellExecutionService {
|
||||
processingChain = processingChain.then(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (!decoder) {
|
||||
const encoding = getCachedEncodingForBuffer(data);
|
||||
try {
|
||||
decoder = new TextDecoder(encoding);
|
||||
} catch {
|
||||
decoder = new TextDecoder('utf-8');
|
||||
const inner = async () => {
|
||||
if (!decoder) {
|
||||
const encoding = getCachedEncodingForBuffer(data);
|
||||
try {
|
||||
decoder = new TextDecoder(encoding);
|
||||
} catch {
|
||||
decoder = new TextDecoder('utf-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputChunks.push(data);
|
||||
outputChunks.push(data);
|
||||
|
||||
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
|
||||
sniffedBytes = sniffBuffer.length;
|
||||
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
|
||||
sniffedBytes = sniffBuffer.length;
|
||||
|
||||
if (isBinary(sniffBuffer)) {
|
||||
isStreamingRawContent = false;
|
||||
const event: ShellOutputEvent = { type: 'binary_detected' };
|
||||
if (isBinary(sniffBuffer)) {
|
||||
isStreamingRawContent = false;
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'binary_detected',
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
}
|
||||
}
|
||||
|
||||
if (isStreamingRawContent) {
|
||||
const decodedChunk = decoder.decode(data, { stream: true });
|
||||
if (decodedChunk.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
0,
|
||||
);
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'binary_progress',
|
||||
bytesReceived: totalBytes,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
}
|
||||
}
|
||||
|
||||
if (isStreamingRawContent) {
|
||||
const decodedChunk = decoder.decode(data, { stream: true });
|
||||
if (decodedChunk.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
isWriting = true;
|
||||
headlessTerminal.write(decodedChunk, () => {
|
||||
render();
|
||||
isWriting = false;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
const totalBytes = outputChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'binary_progress',
|
||||
bytesReceived: totalBytes,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(ptyProcess.pid, event);
|
||||
};
|
||||
inner().catch(() => {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -775,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?.();
|
||||
@@ -783,8 +935,12 @@ export class ShellExecutionService {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
const finalize = () => {
|
||||
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 });
|
||||
@@ -809,9 +965,12 @@ export class ShellExecutionService {
|
||||
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
|
||||
const headlessTerminal = await terminalPromise;
|
||||
const scrollbackLimit =
|
||||
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: getFullBufferText(headlessTerminal),
|
||||
output: getFullBufferText(headlessTerminal, scrollbackLimit),
|
||||
exitCode,
|
||||
signal: signal ?? null,
|
||||
error,
|
||||
@@ -822,7 +981,9 @@ export class ShellExecutionService {
|
||||
};
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
finalize();
|
||||
finalize().catch(() => {
|
||||
// Ignore errors during finalization
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -839,7 +1000,9 @@ export class ShellExecutionService {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
Promise.race([processingComplete, abortFired]).then(() => {
|
||||
finalize();
|
||||
finalize().catch(() => {
|
||||
// Ignore errors during finalization
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1010,7 +1173,9 @@ export class ShellExecutionService {
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
|
||||
if (activePty) {
|
||||
output = getFullBufferText(activePty.headlessTerminal);
|
||||
const scrollbackLimit =
|
||||
activePty.headlessTerminal.options.scrollback ?? SCROLLBACK_LIMIT;
|
||||
output = getFullBufferText(activePty.headlessTerminal, scrollbackLimit);
|
||||
resolve({
|
||||
rawOutput,
|
||||
output,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { installBrowserShims } from './browser-shims.js';
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { init, Terminal } from 'ghostty-web';
|
||||
|
||||
describe('browser-shims', () => {
|
||||
beforeAll(() => {
|
||||
installBrowserShims();
|
||||
});
|
||||
|
||||
it('should install self and window shims', () => {
|
||||
expect(globalThis.self).toBeDefined();
|
||||
expect(globalThis.window).toBeDefined();
|
||||
});
|
||||
|
||||
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 response = await globalThis.fetch(dataUrl);
|
||||
expect(response.ok).toBe(true);
|
||||
const buffer = await response.arrayBuffer();
|
||||
expect(buffer).toBeInstanceOf(ArrayBuffer);
|
||||
});
|
||||
|
||||
it('should allow ghostty-web to initialize and create a terminal', async () => {
|
||||
await init();
|
||||
const term = new Terminal({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Terminal } from '@xterm/headless';
|
||||
import { beforeAll, describe, it, expect } from 'vitest';
|
||||
import { init, Terminal } from 'ghostty-web';
|
||||
import { installBrowserShims } from './browser-shims.js';
|
||||
import {
|
||||
serializeTerminalToObject,
|
||||
convertColorToHex,
|
||||
@@ -22,13 +23,18 @@ function writeToTerminal(terminal: Terminal, data: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe('terminalSerializer', () => {
|
||||
beforeAll(async () => {
|
||||
installBrowserShims();
|
||||
await init();
|
||||
});
|
||||
|
||||
describe('serializeTerminalToObject', () => {
|
||||
it('should handle an empty terminal', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result).toHaveLength(24);
|
||||
result.forEach((line) => {
|
||||
@@ -43,8 +49,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, 'Hello, world!');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].text).toContain('Hello, world!');
|
||||
@@ -54,8 +60,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 7,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, 'Line 1\r\nLine 2');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].text).toBe('Line 1 ');
|
||||
@@ -66,8 +72,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, '\x1b[1mBold text\x1b[0m');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].bold).toBe(true);
|
||||
@@ -78,8 +84,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, '\x1b[3mItalic text\x1b[0m');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].italic).toBe(true);
|
||||
@@ -90,8 +96,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, '\x1b[4mUnderlined text\x1b[0m');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].underline).toBe(true);
|
||||
@@ -102,8 +108,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, '\x1b[2mDim text\x1b[0m');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].dim).toBe(true);
|
||||
@@ -114,8 +120,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, '\x1b[7mInverse text\x1b[0m');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].inverse).toBe(true);
|
||||
@@ -126,11 +132,11 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, `${RED_FG}Red text${RESET}`);
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].fg).toBe('#800000');
|
||||
expect(result[0][0].fg).toBe('#cc6666');
|
||||
expect(result[0][0].text).toBe('Red text');
|
||||
});
|
||||
|
||||
@@ -138,11 +144,11 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, '\x1b[42mGreen background\x1b[0m');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].bg).toBe('#008000');
|
||||
expect(result[0][0].bg).toBe('#b5bd68');
|
||||
expect(result[0][0].text).toBe('Green background');
|
||||
});
|
||||
|
||||
@@ -150,8 +156,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, '\x1b[38;2;100;200;50mRGB text\x1b[0m');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].fg).toBe('#64c832');
|
||||
@@ -162,13 +168,13 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, '\x1b[1;31;42mStyled text\x1b[0m');
|
||||
const result = serializeTerminalToObject(terminal);
|
||||
expect(result[0][0].bold).toBe(true);
|
||||
expect(result[0][0].fg).toBe('#800000');
|
||||
expect(result[0][0].bg).toBe('#008000');
|
||||
expect(result[0][0].fg).toBe('#cc6666');
|
||||
expect(result[0][0].bg).toBe('#b5bd68');
|
||||
expect(result[0][0].text).toBe('Styled text');
|
||||
});
|
||||
|
||||
@@ -176,8 +182,8 @@ describe('terminalSerializer', () => {
|
||||
const terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.open(globalThis.document.createElement('div') as any);
|
||||
await writeToTerminal(terminal, 'Cursor test');
|
||||
// Move cursor to the start of the line (0,0) using ANSI escape code
|
||||
await writeToTerminal(terminal, '\x1b[H');
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { IBufferCell, Terminal } from '@xterm/headless';
|
||||
import type { Terminal } from 'ghostty-web';
|
||||
|
||||
type IBuffer = Terminal['buffer']['active'];
|
||||
type IBufferLine = ReturnType<IBuffer['getLine']>;
|
||||
type IBufferCell = ReturnType<Exclude<IBufferLine, undefined>['getCell']>;
|
||||
|
||||
export interface AnsiToken {
|
||||
text: string;
|
||||
bold: boolean;
|
||||
@@ -85,21 +90,23 @@ class Cell {
|
||||
if (cell.isUnderline()) {
|
||||
this.attributes += Attribute.underline;
|
||||
}
|
||||
if (cell.isDim()) {
|
||||
if (cell.isFaint()) {
|
||||
this.attributes += Attribute.dim;
|
||||
}
|
||||
|
||||
if (cell.isFgRGB()) {
|
||||
const fgMode = cell.getFgColorMode();
|
||||
if (fgMode === -1) {
|
||||
this.fgColorMode = ColorMode.RGB;
|
||||
} else if (cell.isFgPalette()) {
|
||||
} else if (fgMode >= 0) {
|
||||
this.fgColorMode = ColorMode.PALETTE;
|
||||
} else {
|
||||
this.fgColorMode = ColorMode.DEFAULT;
|
||||
}
|
||||
|
||||
if (cell.isBgRGB()) {
|
||||
const bgMode = cell.getBgColorMode();
|
||||
if (bgMode === -1) {
|
||||
this.bgColorMode = ColorMode.RGB;
|
||||
} else if (cell.isBgPalette()) {
|
||||
} else if (bgMode >= 0) {
|
||||
this.bgColorMode = ColorMode.PALETTE;
|
||||
} else {
|
||||
this.bgColorMode = ColorMode.DEFAULT;
|
||||
@@ -146,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;
|
||||
@@ -162,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) {
|
||||
@@ -215,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