feat: add config and keybindings for new ink terminal buffer mode

test: update test utils and resolve snapshot differences for ink changes

feat: refactor VirtualizedList to support static rendering and terminal buffers

feat: wire up AppContainer mouse mode and recording state for ink buffer

Fix stale ref in ScrollProvider breaking scrolling.

The ScrollProvider was getting stuck with a stale reference because the
`scrollables` Map was being registered and unregistered whenever the entry
identity changed. The underlying `ScrollableList` component dynamically created
a new ID which caused React remount cycles, while the `ScrollProvider` itself
suffered from state lag where the event handler ref was one tick behind the
actual registration.

This commit resolves these issues by:
1. Adding a stable `id` and `key` to `ScrollableList` in `MainContent.tsx`
2. Making `scrollablesRef` synchronously update in `ScrollProvider.tsx`
3. Using a proxy entry in `useScrollable` to avoid constant re-registering.

chore: add useEffect cleanup for ResizeObservers

Checkpoint fixing terminal buffer support.

Termina Serializer Optimization
This commit is contained in:
jacob314
2026-03-26 18:33:09 -07:00
parent 597778e55f
commit 5fbb5e011c
45 changed files with 665 additions and 313 deletions
@@ -28,6 +28,7 @@ describe('useAlternateBuffer', () => {
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
@@ -37,6 +38,7 @@ describe('useAlternateBuffer', () => {
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
@@ -46,6 +48,7 @@ describe('useAlternateBuffer', () => {
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
@@ -65,6 +68,7 @@ describe('isAlternateBufferEnabled', () => {
it('should return true when config.getUseAlternateBuffer returns true', () => {
const config = {
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(true);
@@ -73,6 +77,7 @@ describe('isAlternateBufferEnabled', () => {
it('should return false when config.getUseAlternateBuffer returns false', () => {
const config = {
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(false);
@@ -8,7 +8,7 @@ import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
config.getUseAlternateBuffer() || config.getUseTerminalBuffer();
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useLayoutEffect, useRef, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import { interpolateColor } from '../themes/color-utils.js';
import { debugState } from '../debug.js';
@@ -107,7 +107,7 @@ export function useAnimatedScrollbar(
}, [cleanup]);
const wasFocused = useRef(isFocused);
useEffect(() => {
useLayoutEffect(() => {
if (isFocused && !wasFocused.current) {
flashScrollbar();
} else if (!isFocused && wasFocused.current) {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef, useEffect, useCallback } from 'react';
import { useRef, useLayoutEffect, useCallback } from 'react';
/**
* A hook to manage batched scroll state updates.
@@ -17,7 +17,7 @@ export function useBatchedScroll(currentScrollTop: number) {
// and not depend on the currentScrollTop value directly in its dependency array.
const currentScrollTopRef = useRef(currentScrollTop);
useEffect(() => {
useLayoutEffect(() => {
currentScrollTopRef.current = currentScrollTop;
pendingScrollTopRef.current = null;
});
@@ -28,7 +28,7 @@ import * as trustedFolders from '../../config/trustedFolders.js';
import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedExit = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async () => {
@@ -24,7 +24,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { coreEvents } from '@google/gemini-cli-core';
// Hoist mocks
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
const mockedUseSettings = vi.hoisted(() => vi.fn());