diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index 43b19d1228..db1eec40b2 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -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(); - 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(); @@ -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>; 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]"`, ), ); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index 03cd10823d..cb010b634c 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -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 ( + + + [Binary output detected. Halting stream...] + + {activeShell.binaryBytesReceived > 0 && ( + + Received: {Math.round(activeShell.binaryBytesReceived / 1024)} KB + + )} + + ); + } + const lines = typeof output === 'string' ? output.split('\n') : output; return ( diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index aaf007e697..124e6b0f40 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -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(), }); }); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 736ae691d5..d2a554819a 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -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; } interface ActiveChildProcess { @@ -204,6 +207,63 @@ export class ShellExecutionService { number, Set<(event: ShellOutputEvent) => void> >(); + + private static terminalInitializationPromise: Promise | 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 { + 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((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 }); diff --git a/packages/core/src/utils/browser-shims.test.ts b/packages/core/src/utils/browser-shims.test.ts index 3f65beb772..4e10846310 100644 --- a/packages/core/src/utils/browser-shims.test.ts +++ b/packages/core/src/utils/browser-shims.test.ts @@ -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); + }); }); diff --git a/packages/core/src/utils/browser-shims.ts b/packages/core/src/utils/browser-shims.ts index f591ac1eca..3b700a9e01 100644 --- a/packages/core/src/utils/browser-shims.ts +++ b/packages/core/src/utils/browser-shims.ts @@ -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; + } } diff --git a/packages/core/src/utils/terminalSerializer.ts b/packages/core/src/utils/terminalSerializer.ts index c7c7cd0a0b..4d63736706 100644 --- a/packages/core/src/utils/terminalSerializer.ts +++ b/packages/core/src/utils/terminalSerializer.ts @@ -153,6 +153,7 @@ export function serializeTerminalToObject( terminal: Terminal, startLine?: number, endLine?: number, + cache?: Map, ): 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); }