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
+2
View File
@@ -993,6 +993,8 @@ export async function loadCliConfig(
trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useTerminalBuffer: settings.ui?.terminalBuffer,
useRenderProcess: settings.ui?.renderProcess,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellBackgroundCompletionBehavior: settings.tools?.shell
@@ -11,7 +11,8 @@ This extension will exclude the following core tools: tool1,tool2
The extension you are about to install may have been created by a third-party developer and sourced
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
of extensions. Please carefully inspect any extension and its source code before installing to
understand the permissions it requires and the actions it may perform."
understand the permissions it requires and the actions it may perform.
"
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = `
@@ -21,7 +22,8 @@ exports[`consent > maybeRequestConsentOrFail > consent string generation > shoul
The extension you are about to install may have been created by a third-party developer and sourced
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
of extensions. Please carefully inspect any extension and its source code before installing to
understand the permissions it requires and the actions it may perform."
understand the permissions it requires and the actions it may perform.
"
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
@@ -30,7 +32,8 @@ exports[`consent > maybeRequestConsentOrFail > consent string generation > shoul
The extension you are about to install may have been created by a third-party developer and sourced
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
of extensions. Please carefully inspect any extension and its source code before installing to
understand the permissions it requires and the actions it may perform."
understand the permissions it requires and the actions it may perform.
"
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
@@ -60,7 +63,8 @@ understand the permissions it requires and the actions it may perform.
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
prompt. This can change how the agent interprets your requests and interacts with your environment.
Review the skill definitions at the location(s) provided below to ensure they meet your security
standards."
standards.
"
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = `
@@ -82,7 +86,8 @@ understand the permissions it requires and the actions it may perform.
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
prompt. This can change how the agent interprets your requests and interacts with your environment.
Review the skill definitions at the location(s) provided below to ensure they meet your security
standards."
standards.
"
`;
exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = `
@@ -98,5 +103,6 @@ Install Destination: /mock/target/dir
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
prompt. This can change how the agent interprets your requests and interacts with your environment.
Review the skill definitions at the location(s) provided below to ensure they meet your security
standards."
standards.
"
`;
+18
View File
@@ -743,6 +743,24 @@ const SETTINGS_SCHEMA = {
'Use an alternate screen buffer for the UI, preserving shell history.',
showInDialog: true,
},
renderProcess: {
type: 'boolean',
label: 'Render Process',
category: 'UI',
requiresRestart: true,
default: true,
description: 'Enable Ink render process for the UI.',
showInDialog: true,
},
terminalBuffer: {
type: 'boolean',
label: 'Terminal Buffer',
category: 'UI',
requiresRestart: true,
default: true,
description: 'Use the new terminal buffer architecture for rendering.',
showInDialog: true,
},
useBackgroundColor: {
type: 'boolean',
label: 'Use Background Color',
+5 -3
View File
@@ -43,7 +43,6 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
@@ -64,7 +63,7 @@ export async function startInteractiveUI(
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(config),
config.getUseAlternateBuffer(),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
@@ -133,7 +132,6 @@ export async function startInteractiveUI(
// Wait a moment for shpool to stabilize terminal size and state.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -154,8 +152,12 @@ export async function startInteractiveUI(
}
profiler.reportFrameRendered();
},
standardReactLayoutTiming:
useAlternateBuffer || config.getUseTerminalBuffer(),
patchConsole: false,
alternateBuffer: useAlternateBuffer,
renderProcess: config.getUseRenderProcess(),
terminalBuffer: config.getUseTerminalBuffer(),
incrementalRendering:
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
@@ -36,15 +36,11 @@ export async function toMatchSvgSnapshot(
}
let textContent: string;
if (renderInstance.lastFrameRaw) {
textContent = renderInstance.lastFrameRaw({
allowEmpty: options?.allowEmpty,
});
} else if (renderInstance.lastFrame) {
if (renderInstance.lastFrame) {
textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty });
} else {
throw new Error(
'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame',
'toMatchSvgSnapshot requires a renderInstance with lastFrame',
);
}
const svgContent = renderInstance.generateSvg();
@@ -175,6 +175,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
getUseTerminalBuffer: vi.fn().mockReturnValue(false),
getUseRenderProcess: vi.fn().mockReturnValue(false),
...overrides,
}) as unknown as Config;
+6 -1
View File
@@ -254,7 +254,12 @@ class XtermStdout extends EventEmitter {
const isMatch = () => {
if (expectedFrame === '...') {
return currentFrame !== '';
// '...' is our fallback when output isn't in metrics, meaning Ink rendered *something*
// but we don't know what it is. If terminal has content, we consider it a match.
// However, if the component rendered null, both would be empty, but our fallback
// made expectedFrame '...'. In that case, we can't easily know if it's ready,
// but we can assume if there are no pending writes, it's ready.
return currentFrame !== '' || this.pendingWrites === 0;
}
// If Ink expects nothing (no new static content and no dynamic output),
+22 -7
View File
@@ -346,6 +346,7 @@ describe('AppContainer State Management', () => {
// Initialize mock stdout for terminal title tests
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
capturedUIState = null!;
@@ -470,6 +471,7 @@ describe('AppContainer State Management', () => {
// Mock Config
mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false);
// Mock config's getTargetDir to return consistent workspace directory
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
@@ -1356,6 +1358,7 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
// Reset mock stdout for each test
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
});
it('verifies useStdout is mocked', async () => {
@@ -2459,7 +2462,7 @@ describe('AppContainer State Management', () => {
});
});
describe('Copy Mode (CTRL+S)', () => {
describe('Copy Mode (F9)', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: Awaited<ReturnType<typeof render>>['stdin'];
@@ -2468,6 +2471,8 @@ describe('AppContainer State Management', () => {
isAlternateMode = false,
childHandler?: Mock,
) => {
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
isAlternateMode,
);
@@ -2512,6 +2517,8 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
vi.useFakeTimers();
});
@@ -2532,12 +2539,13 @@ describe('AppContainer State Management', () => {
modeName: 'Alternate Buffer Mode',
},
])('$modeName', ({ isAlternateMode, shouldEnable }) => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => {
await setupCopyModeTest(isAlternateMode);
mocks.mockStdout.write.mockClear(); // Clear initial enable call
(disableMouseEvents as import('vitest').Mock).mockClear();
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -2550,13 +2558,13 @@ describe('AppContainer State Management', () => {
});
if (shouldEnable) {
it('should toggle mouse back on when Ctrl+S is pressed again', async () => {
it('should toggle mouse back on when F9 is pressed again', async () => {
await setupCopyModeTest(isAlternateMode);
(writeToStdout as Mock).mockClear();
// Turn it on (disable mouse)
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
expect(disableMouseEvents).toHaveBeenCalled();
@@ -2576,7 +2584,7 @@ describe('AppContainer State Management', () => {
// Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -2656,7 +2664,7 @@ describe('AppContainer State Management', () => {
// 2. Enter copy mode
act(() => {
stdin.write('\x13'); // Ctrl+S
stdin.write('\x1b[20~'); // F9
});
rerender();
@@ -3093,6 +3101,7 @@ describe('AppContainer State Management', () => {
// Clear previous calls
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
const { unmount } = await act(async () => renderAppContainer());
@@ -3135,6 +3144,7 @@ describe('AppContainer State Management', () => {
// Reset mock stdout to clear any initial writes
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
@@ -3154,6 +3164,8 @@ describe('AppContainer State Management', () => {
);
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
const { unmount } = await act(async () =>
@@ -3170,6 +3182,7 @@ describe('AppContainer State Management', () => {
// Reset mock stdout
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
@@ -3403,6 +3416,8 @@ describe('AppContainer State Management', () => {
ui: { useAlternateBuffer: true },
});
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
const { unmount } = await act(async () =>
+77 -5
View File
@@ -11,6 +11,7 @@ import {
useEffect,
useRef,
useLayoutEffect,
useContext,
} from 'react';
import {
type DOMElement,
@@ -19,6 +20,7 @@ import {
useStdout,
useStdin,
type AppProps,
AppContext as InkAppContext,
} from 'ink';
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
@@ -38,6 +40,8 @@ import {
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
import {
type StartupWarning,
type EditorType,
@@ -209,12 +213,30 @@ export const AppContainer = (props: AppContainerProps) => {
const { reset } = useOverflowActions()!;
const notificationsEnabled = isNotificationsEnabled(settings);
const { setOptions, dumpCurrentFrame, startRecording, stopRecording } =
useContext(InkAppContext);
const recordingFilenameRef = useRef<string | null>(null);
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const [mouseMode, setMouseMode] = useState(() =>
config.getUseAlternateBuffer(),
);
useEffect(() => {
setOptions({
stickyHeadersInBackbuffer: mouseMode,
});
if (mouseMode) {
enableMouseEvents();
} else {
disableMouseEvents();
}
}, [mouseMode, setOptions]);
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -613,11 +635,11 @@ export const AppContainer = (props: AppContainerProps) => {
});
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
if (!isAlternateBuffer && !config.getUseTerminalBuffer()) {
stdout.write(ansiEscapes.clearTerminal);
setHistoryRemountKey((prev) => prev + 1);
}
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
}, [setHistoryRemountKey, isAlternateBuffer, stdout, config]);
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
isAlternateBuffer,
@@ -1426,6 +1448,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
!copyModeEnabled;
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
@@ -1724,6 +1754,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShortcutsHelpVisible(false);
}
if (keyMatchers[Command.TOGGLE_MOUSE_MODE](key)) {
setMouseMode((prev) => !prev);
return true;
}
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
setCopyModeEnabled(true);
disableMouseEvents();
@@ -1746,6 +1781,32 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleSuspend();
} else if (keyMatchers[Command.DUMP_FRAME](key)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `snapshot-${timestamp}.json`;
if (dumpCurrentFrame) {
dumpCurrentFrame(filename);
debugLogger.log(`Dumped frame to: ${filename}`);
}
return true;
} else if (keyMatchers[Command.START_RECORDING](key)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `recording-${timestamp}.json`;
if (startRecording) {
startRecording(filename);
recordingFilenameRef.current = filename;
debugLogger.log(`Started recording to: ${filename}`);
}
return true;
} else if (keyMatchers[Command.STOP_RECORDING](key)) {
if (stopRecording) {
stopRecording();
debugLogger.log(
`Stopped recording, saved to: ${recordingFilenameRef.current ?? 'unknown'}`,
);
recordingFilenameRef.current = null;
}
return true;
} else if (
keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
!isAlternateBuffer
@@ -1932,6 +1993,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyManager.history,
pendingHistoryItems,
toggleAllExpansion,
dumpCurrentFrame,
startRecording,
stopRecording,
],
);
@@ -1951,7 +2015,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setCopyModeEnabled(false);
enableMouseEvents();
if (mouseMode) {
enableMouseEvents();
}
return true;
},
{
@@ -2268,6 +2334,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
editorError,
isEditorDialogOpen,
showPrivacyNotice,
mouseMode,
corgiMode,
debugMessage,
quittingMessages,
@@ -2394,6 +2461,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
editorError,
isEditorDialogOpen,
showPrivacyNotice,
mouseMode,
corgiMode,
debugMessage,
quittingMessages,
@@ -2694,7 +2762,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<App key={`app-${forceRerenderKey}`} />
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
@@ -166,6 +166,7 @@ Implement a comprehensive authentication system with multiple providers.
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer,
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
@@ -466,6 +467,7 @@ Implement a comprehensive authentication system with multiple providers.
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
@@ -18,7 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({
}));
const mockedExit = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedRows = vi.hoisted(() => ({ current: 24 }));
vi.mock('node:process', async () => {
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Help Component', () => {
expect(output).toContain('Keyboard Shortcuts:');
expect(output).toContain('Ctrl+C');
expect(output).toContain('Ctrl+S');
expect(output).toContain('Shift+Tab');
expect(output).toContain('Page Up/Page Down');
unmount();
});
+63 -17
View File
@@ -12,6 +12,7 @@ import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useConfig } from '../contexts/ConfigContext.js';
import {
SCROLL_TO_ITEM_END,
type VirtualizedListRef,
@@ -34,6 +35,11 @@ export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const config = useConfig();
const useTerminalBuffer =
typeof config.getUseTerminalBuffer === 'function'
? config.getUseTerminalBuffer()
: false;
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
@@ -53,6 +59,7 @@ export const MainContent = () => {
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
mouseMode,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
@@ -284,24 +291,63 @@ export const MainContent = () => {
],
);
const estimatedItemHeight = useCallback(() => 100, []);
const keyExtractor = useCallback(
(item: (typeof virtualizedData)[number], _index: number) => {
if (item.type === 'header') return 'header';
if (item.type === 'history') return item.item.id.toString();
return 'pending';
},
[],
);
const isStaticItem = useCallback(
(item: (typeof virtualizedData)[number]) => item.type !== 'pending',
[],
);
const scrollableList = useMemo(() => {
if (isAlternateBuffer) {
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={
!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused
}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={estimatedItemHeight}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
renderStatic={useTerminalBuffer}
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
overflowToBackbuffer={useTerminalBuffer}
scrollbar={mouseMode}
/>
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
// but need to work out ensuring we only attempt it within a smaller range of scrollback vals.
);
}
return null;
}, [
isAlternateBuffer,
uiState.isEditorDialogOpen,
uiState.embeddedShellFocused,
uiState.terminalWidth,
virtualizedData,
renderItem,
estimatedItemHeight,
keyExtractor,
useTerminalBuffer,
isStaticItem,
mouseMode,
]);
if (isAlternateBuffer) {
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={() => 100}
keyExtractor={(item, _index) => {
if (item.type === 'header') return 'header';
if (item.type === 'history') return item.item.id.toString();
return 'pending';
}}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
/>
);
return scrollableList;
}
return (
@@ -22,7 +22,7 @@ import * as processUtils from '../../utils/processUtils.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
// Hoist mocks for dependencies of the usePermissionsModifyTrust hook
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());
+15 -1
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useRef, useState, useEffect } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import {
isUserVisibleHook,
@@ -77,6 +77,13 @@ export const StatusNode: React.FC<{
}) => {
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const onRefChange = useCallback(
(node: DOMElement | null) => {
if (observerRef.current) {
@@ -169,6 +176,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const [tipWidth, setTipWidth] = useState(0);
const tipObserverRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
tipObserverRef.current?.disconnect();
},
[],
);
const onTipRefChange = useCallback((node: DOMElement | null) => {
if (tipObserverRef.current) {
tipObserverRef.current.disconnect();
@@ -15,7 +15,8 @@ Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results"
4. Be specific for the best results
"
`;
exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
@@ -33,5 +34,6 @@ Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results"
4. Be specific for the best results
"
`;
@@ -42,7 +42,8 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] = `
@@ -133,7 +134,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is toggled off 1`] = `
@@ -177,5 +179,6 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -225,5 +225,6 @@ AppHeader(full)
│ Refining approach
│ And finally a third multiple line paragraph for the third thinking message to
│ refine the solution."
│ refine the solution.
"
`;
@@ -43,7 +43,8 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
@@ -89,7 +90,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings disabled' correctly 1`] = `
@@ -135,7 +137,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'default state' correctly 1`] = `
@@ -181,7 +184,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'file filtering settings configured' correctly 1`] = `
@@ -227,7 +231,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = `
@@ -273,7 +278,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and number settings' correctly 1`] = `
@@ -319,7 +325,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'tools and security settings' correctly 1`] = `
@@ -365,7 +372,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settings enabled' correctly 1`] = `
@@ -411,5 +419,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -4,17 +4,20 @@ exports[`Table > should render headers and data correctly 1`] = `
"ID Name
────────────────────────────────────────────────────────────────────────────────────────────────────
1 Alice
2 Bob"
2 Bob
"
`;
exports[`Table > should support custom cell rendering 1`] = `
"Value
────────────────────────────────────────────────────────────────────────────────────────────────────
20"
20
"
`;
exports[`Table > should support inverse text rendering 1`] = `
"Status
────────────────────────────────────────────────────────────────────────────────────────────────────
Active"
Active
"
`;
@@ -90,6 +90,13 @@ export const ToolConfirmationMessage: React.FC<
useState(0);
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const deceptiveUrlWarnings = useMemo(() => {
const urls: string[] = [];
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
@@ -51,7 +51,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
hasFocus = false,
overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const { renderMarkdown, terminalHeight } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = calculateToolContentMaxLines({
@@ -202,7 +202,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
if (isAlternateBuffer) {
// Virtualized path for large ANSI arrays
if (Array.isArray(resultDisplay)) {
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
const limit =
maxLines ?? availableHeight ?? terminalHeight ?? ACTIVE_SHELL_MAX_LINES;
const listHeight = Math.min(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(resultDisplay as AnsiOutput).length,
@@ -12,7 +12,8 @@ exports[`ThinkingMessage > filters out progress dots and empty lines 2`] = `
" Thinking...
│ Thinking
│ Done"
│ Done
"
`;
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
@@ -27,7 +28,8 @@ exports[`ThinkingMessage > normalizes escaped newline tokens 2`] = `
" Thinking...
│ Matching the Blocks
│ Some more text"
│ Some more text
"
`;
exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 1`] = `
@@ -42,7 +44,8 @@ exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is
" Thinking...
│ Summary line
│ First body line"
│ First body line
"
`;
exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
@@ -57,7 +60,8 @@ exports[`ThinkingMessage > renders full mode with left border and full text 2`]
" Thinking...
│ Planning
│ I am planning the solution."
│ I am planning the solution.
"
`;
exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 1`] = `
@@ -90,7 +94,8 @@ exports[`ThinkingMessage > renders multiple thinking messages sequentially corre
│ Refining approach
│ And finally a third multiple line paragraph for the third thinking message to
│ refine the solution."
│ refine the solution.
"
`;
exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 1`] = `
@@ -105,7 +110,8 @@ exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking
" Thinking...
│ Planning
│ test"
│ test
"
`;
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
@@ -118,5 +124,6 @@ exports[`ThinkingMessage > uses description when subject is empty 1`] = `
exports[`ThinkingMessage > uses description when subject is empty 2`] = `
" Thinking...
│ Processing details"
│ Processing details
"
`;
@@ -42,6 +42,14 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
const id = useId();
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const [contentHeight, setContentHeight] = useState(0);
const onRefChange = useCallback(
@@ -33,6 +33,9 @@ interface ScrollableProps {
scrollToBottom?: boolean;
flexGrow?: number;
reportOverflow?: boolean;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
}
export const Scrollable: React.FC<ScrollableProps> = ({
@@ -45,6 +48,9 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollToBottom,
flexGrow,
reportOverflow = false,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
@@ -91,6 +97,14 @@ export const Scrollable: React.FC<ScrollableProps> = ({
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
viewportObserverRef.current?.disconnect();
contentObserverRef.current?.disconnect();
},
[],
);
const viewportRefCallback = useCallback((node: DOMElement | null) => {
viewportObserverRef.current?.disconnect();
viewportRef.current = node;
@@ -247,6 +261,9 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollTop={scrollTop}
flexGrow={flexGrow}
scrollbarThumbColor={scrollbarColor}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
{/*
This inner box is necessary to prevent the parent from shrinking
@@ -16,6 +16,7 @@ import type React from 'react';
import {
VirtualizedList,
type VirtualizedListRef,
type VirtualizedListProps,
SCROLL_TO_ITEM_END,
} from './VirtualizedList.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
@@ -27,18 +28,13 @@ import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
const ANIMATION_FRAME_DURATION_MS = 33;
type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
keyExtractor: (item: T, index: number) => string;
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
};
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
hasFocus: boolean;
width?: string | number;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
isStatic?: boolean;
}
export type ScrollableListRef<T> = VirtualizedListRef<T>;
@@ -48,7 +44,7 @@ function ScrollableList<T>(
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const { hasFocus, width } = props;
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
@@ -258,17 +254,13 @@ function ScrollableList<T>(
useScrollable(scrollableEntry, true);
return (
<Box
ref={containerRef}
flexGrow={1}
flexDirection="column"
overflow="hidden"
width={width}
>
<Box ref={containerRef} flexGrow={1} flexDirection="column" width={width}>
<VirtualizedList
ref={virtualizedListRef}
{...props}
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
/>
</Box>
);
@@ -17,13 +17,6 @@ import {
useState,
} from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { UIState } from '../../contexts/UIStateContext.js';
vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({
copyModeEnabled: false,
})),
}));
describe('<VirtualizedList />', () => {
const keyExtractor = (item: string) => item;
@@ -324,11 +317,6 @@ describe('<VirtualizedList />', () => {
});
it('renders correctly in copyModeEnabled when scrolled', async () => {
const { useUIState } = await import('../../contexts/UIStateContext.js');
vi.mocked(useUIState).mockReturnValue({
copyModeEnabled: true,
} as Partial<UIState> as UIState);
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
// Use copy mode
const { lastFrame, unmount } = await render(
@@ -343,6 +331,7 @@ describe('<VirtualizedList />', () => {
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={50}
copyModeEnabled={true}
/>
</Box>,
);
@@ -12,17 +12,18 @@ import {
useImperativeHandle,
useMemo,
useCallback,
memo,
} from 'react';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { debugLogger } from '@google/gemini-cli-core';
import { type DOMElement, Box, ResizeObserver } from 'ink';
import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
type VirtualizedListProps<T> = {
export type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
@@ -30,6 +31,14 @@ type VirtualizedListProps<T> = {
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
scrollbarThumbColor?: string;
renderStatic?: boolean;
isStatic?: boolean;
isStaticItem?: (item: T, index: number) => boolean;
width?: number | string;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
};
export type VirtualizedListRef<T> = {
@@ -66,6 +75,43 @@ function findLastIndex<T>(
return -1;
}
const VirtualizedListItem = memo(
({
content,
shouldBeStatic,
width,
containerWidth,
itemKey,
itemRef,
}: {
content: React.ReactElement;
shouldBeStatic: boolean;
width: number | string | undefined;
containerWidth: number;
itemKey: string;
itemRef: (el: DOMElement | null) => void;
}) => (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
{shouldBeStatic ? (
<StaticRender
width={typeof width === 'number' ? width : containerWidth}
key={
itemKey +
'-static-' +
(typeof width === 'number' ? width : containerWidth)
}
>
{content}
</StaticRender>
) : (
content
)}
</Box>
),
);
VirtualizedListItem.displayName = 'VirtualizedListItem';
function VirtualizedList<T>(
props: VirtualizedListProps<T>,
ref: React.Ref<VirtualizedListRef<T>>,
@@ -77,8 +123,15 @@ function VirtualizedList<T>(
keyExtractor,
initialScrollIndex,
initialScrollOffsetInIndex,
renderStatic,
isStatic,
isStaticItem,
width,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
copyModeEnabled = false,
} = props;
const { copyModeEnabled } = useUIState();
const dataRef = useRef(data);
useLayoutEffect(() => {
dataRef.current = data;
@@ -119,6 +172,7 @@ function VirtualizedList<T>(
const containerRef = useRef<DOMElement | null>(null);
const [containerHeight, setContainerHeight] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const isInitialScrollSet = useRef(false);
@@ -133,7 +187,10 @@ function VirtualizedList<T>(
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerHeight(Math.round(entry.contentRect.height));
const newHeight = Math.round(entry.contentRect.height);
const newWidth = Math.round(entry.contentRect.width);
setContainerHeight((prev) => (prev !== newHeight ? newHeight : prev));
setContainerWidth((prev) => (prev !== newWidth ? newWidth : prev));
}
});
observer.observe(node);
@@ -242,7 +299,9 @@ function VirtualizedList<T>(
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
}
const listGrew = data.length > prevDataLength.current;
@@ -253,10 +312,16 @@ function VirtualizedList<T>(
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
) {
setScrollAnchor({
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
const newIndex = data.length > 0 ? data.length - 1 : 0;
if (
scrollAnchor.index !== newIndex ||
scrollAnchor.offset !== SCROLL_TO_ITEM_END
) {
setScrollAnchor({
index: newIndex,
offset: SCROLL_TO_ITEM_END,
});
}
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
@@ -266,9 +331,17 @@ function VirtualizedList<T>(
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
if (
scrollAnchor.index !== newAnchor.index ||
scrollAnchor.offset !== newAnchor.offset
) {
setScrollAnchor(newAnchor);
}
} else if (data.length === 0) {
setScrollAnchor({ index: 0, offset: 0 });
if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) {
setScrollAnchor({ index: 0, offset: 0 });
}
}
prevDataLength.current = data.length;
@@ -281,6 +354,7 @@ function VirtualizedList<T>(
actualScrollTop,
scrollableContainerHeight,
scrollAnchor.index,
scrollAnchor.offset,
getAnchorForScrollTop,
offsets,
isStickingToBottom,
@@ -348,9 +422,11 @@ function VirtualizedList<T>(
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
const topSpacerHeight = offsets[startIndex] ?? 0;
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
const topSpacerHeight =
renderStatic || overflowToBackbuffer ? 0 : (offsets[startIndex] ?? 0);
const bottomSpacerHeight = renderStatic
? 0
: totalHeight - (offsets[endIndex + 1] ?? totalHeight);
// Maintain a stable set of observed nodes using useLayoutEffect
const observedNodes = useRef<Set<DOMElement>>(new Set());
@@ -364,14 +440,16 @@ function VirtualizedList<T>(
const key = keyExtractor(item, i);
// Always update the key mapping because React can reuse nodes at different indices/keys
nodeToKeyRef.current.set(node, key);
if (!observedNodes.current.has(node)) {
if (!isStatic && !observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
itemsObserver.unobserve(node);
if (!isStatic) {
itemsObserver.unobserve(node);
}
nodeToKeyRef.current.delete(node);
}
}
@@ -379,25 +457,61 @@ function VirtualizedList<T>(
});
const renderedItems = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = data[i];
if (item) {
renderedItems.push(
<Box
key={keyExtractor(item, i)}
width="100%"
flexDirection="column"
flexShrink={0}
ref={(el) => {
itemRefs.current[i] = el;
}}
>
{renderItem({ item, index: i })}
</Box>,
);
const renderRangeStart =
renderStatic || overflowToBackbuffer ? 0 : startIndex;
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
let staticCount = 0;
// Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop.
// If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides.
// Wait, if it's not static and no width we need to wait for measure.
// BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts.
// We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true.
// If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender!
const isReady =
containerHeight > 0 &&
(containerWidth > 0 || (width !== undefined && typeof width === 'number'));
if (isReady) {
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
const item = data[i];
if (item) {
const isOutsideViewport = i < startIndex || i > endIndex;
const shouldBeStatic = !!(
(renderStatic && isOutsideViewport) ||
isStaticItem?.(item, i)
);
if (shouldBeStatic) {
staticCount++;
}
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
renderedItems.push(
<VirtualizedListItem
key={key}
itemKey={key}
content={content}
shouldBeStatic={shouldBeStatic}
width={width}
containerWidth={containerWidth}
itemRef={(el: DOMElement | null) => {
if (i >= startIndex && i <= endIndex) {
itemRefs.current[i] = el;
}
}}
/>,
);
}
}
}
debugLogger.log(
`VirtualizedList rendered items: ${renderedItems.length}, isStatic property: ${!!isStatic}, static elements: ${staticCount}`,
);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
useImperativeHandle(
@@ -539,6 +653,9 @@ function VirtualizedList<T>(
height="100%"
flexDirection="column"
paddingRight={copyModeEnabled ? 0 : 1}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
<Box
flexShrink={0}
@@ -2,26 +2,39 @@
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
components//and/then/some/more/components//and/...
"
`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `
"run: git commit -m "feat: add search"
"
`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `
"simple command
"
`;
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandableText > respects custom maxWidth 1`] = `
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz...
"
`;
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
"
`;
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
"
`;
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
"
`;
@@ -117,6 +117,7 @@ export interface UIState {
editorError: string | null;
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
mouseMode: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
@@ -191,7 +192,7 @@ export interface UIState {
sessionStats: SessionStatsState;
terminalWidth: number;
terminalHeight: number;
mainControlsRef: React.RefCallback<DOMElement | null>;
mainControlsRef: (node: DOMElement | null) => void;
// NOTE: This is for performance profiling only.
rootUiRef: React.MutableRefObject<DOMElement | null>;
currentIDE: IdeInfo | null;
@@ -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());
+19 -1
View File
@@ -85,6 +85,7 @@ export enum Command {
SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail',
TOGGLE_MARKDOWN = 'app.toggleMarkdown',
TOGGLE_COPY_MODE = 'app.toggleCopyMode',
TOGGLE_MOUSE_MODE = 'app.toggleMouseMode',
TOGGLE_YOLO = 'app.toggleYolo',
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
SHOW_MORE_LINES = 'app.showMoreLines',
@@ -109,6 +110,10 @@ export enum Command {
// Extension Controls
UPDATE_EXTENSION = 'extension.update',
LINK_EXTENSION = 'extension.link',
DUMP_FRAME = 'app.dumpFrame',
START_RECORDING = 'app.startRecording',
STOP_RECORDING = 'app.stopRecording',
}
/**
@@ -385,7 +390,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]],
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]],
[Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('f9')]],
[Command.TOGGLE_MOUSE_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_YOLO, [new KeyBinding('ctrl+y')]],
[Command.CYCLE_APPROVAL_MODE, [new KeyBinding('shift+tab')]],
[Command.SHOW_MORE_LINES, [new KeyBinding('ctrl+o')]],
@@ -396,6 +402,9 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
[Command.START_RECORDING, [new KeyBinding('f6')]],
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -512,6 +521,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SHOW_IDE_CONTEXT_DETAIL,
Command.TOGGLE_MARKDOWN,
Command.TOGGLE_COPY_MODE,
Command.TOGGLE_MOUSE_MODE,
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
@@ -535,6 +545,9 @@ export const commandCategories: readonly CommandCategory[] = [
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.DUMP_FRAME,
Command.START_RECORDING,
Command.STOP_RECORDING,
],
},
{
@@ -621,6 +634,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_MOUSE_MODE]: 'Toggle mouse mode (scrolling and clicking).',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
@@ -654,6 +668,10 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
// Extension Controls
[Command.UPDATE_EXTENSION]: 'Update the current extension if available.',
[Command.LINK_EXTENSION]: 'Link the current extension to a local path.',
[Command.DUMP_FRAME]: 'Dump the current frame as a snapshot.',
[Command.START_RECORDING]: 'Start recording the session.',
[Command.STOP_RECORDING]: 'Stop recording the session.',
};
const keybindingsSchema = z.array(
@@ -346,6 +346,11 @@ describe('keyMatchers', () => {
},
{
command: Command.TOGGLE_COPY_MODE,
positive: [createKey('f9')],
negative: [createKey('f8'), createKey('f10')],
},
{
command: Command.TOGGLE_MOUSE_MODE,
positive: [createKey('s', { ctrl: true })],
negative: [createKey('s'), createKey('s', { alt: true })],
},
@@ -34,7 +34,6 @@ export const DefaultAppLayout: React.FC = () => {
paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
ref={uiState.rootUiRef}
>
<MainContent />
@@ -29,7 +29,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
>
<Notifications />
<Footer />
<Box flexGrow={1} overflow="hidden">
<Box flexGrow={1}>
<MainContent />
</Box>
{uiState.dialogsVisible ? (
@@ -3,5 +3,6 @@
exports[`colorizeCode > does not let colors from ansi escape codes leak into colorized code 1`] = `
"line 1
line 2 with red background
line 3"
line 3
"
`;
@@ -21,6 +21,7 @@ describe('ui-sizing', () => {
(expected, width, altBuffer) => {
const mockConfig = {
getUseAlternateBuffer: () => altBuffer,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(calculateMainAreaWidth(width, mockConfig)).toBe(expected);
},
+14
View File
@@ -661,6 +661,8 @@ export interface ConfigParameters {
trustedFolder?: boolean;
useBackgroundColor?: boolean;
useAlternateBuffer?: boolean;
useTerminalBuffer?: boolean;
useRenderProcess?: boolean;
useRipgrep?: boolean;
enableInteractiveShell?: boolean;
shellBackgroundCompletionBehavior?: string;
@@ -878,6 +880,8 @@ export class Config implements McpContext, AgentLoopContext {
private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean;
private readonly useTerminalBuffer: boolean;
private readonly useRenderProcess: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
@@ -1218,6 +1222,8 @@ export class Config implements McpContext, AgentLoopContext {
this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
this.useTerminalBuffer = params.useTerminalBuffer ?? false;
this.useRenderProcess = params.useRenderProcess ?? true;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
const requestedBehavior = params.shellBackgroundCompletionBehavior;
@@ -3243,6 +3249,14 @@ export class Config implements McpContext, AgentLoopContext {
return this.useAlternateBuffer;
}
getUseTerminalBuffer(): boolean {
return this.useTerminalBuffer;
}
getUseRenderProcess(): boolean {
return this.useRenderProcess;
}
getEnableInteractiveShell(): boolean {
return this.enableInteractiveShell;
}
@@ -119,7 +119,7 @@ describe('terminalSerializer', () => {
await writeToTerminal(terminal, '\x1b[7mInverse text\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].inverse).toBe(true);
expect(result[0][0].text).toBe('Inverse text');
expect(result[0][0].text.trim()).toBe('Inverse text');
});
it('should handle foreground colors', async () => {
+114 -155
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { IBufferCell, Terminal } from '@xterm/headless';
import type { Terminal } from '@xterm/headless';
export interface AnsiToken {
text: string;
bold: boolean;
@@ -19,129 +19,12 @@ export interface AnsiToken {
export type AnsiLine = AnsiToken[];
export type AnsiOutput = AnsiLine[];
const enum Attribute {
inverse = 1,
bold = 2,
italic = 4,
underline = 8,
dim = 16,
}
export const enum ColorMode {
DEFAULT = 0,
PALETTE = 1,
RGB = 2,
}
class Cell {
private cell: IBufferCell | null = null;
private x = 0;
private y = 0;
private cursorX = 0;
private cursorY = 0;
private attributes: number = 0;
fg = 0;
bg = 0;
fgColorMode: ColorMode = ColorMode.DEFAULT;
bgColorMode: ColorMode = ColorMode.DEFAULT;
constructor(
cell: IBufferCell | null,
x: number,
y: number,
cursorX: number,
cursorY: number,
) {
this.update(cell, x, y, cursorX, cursorY);
}
update(
cell: IBufferCell | null,
x: number,
y: number,
cursorX: number,
cursorY: number,
) {
this.cell = cell;
this.x = x;
this.y = y;
this.cursorX = cursorX;
this.cursorY = cursorY;
this.attributes = 0;
if (!cell) {
return;
}
if (cell.isInverse()) {
this.attributes += Attribute.inverse;
}
if (cell.isBold()) {
this.attributes += Attribute.bold;
}
if (cell.isItalic()) {
this.attributes += Attribute.italic;
}
if (cell.isUnderline()) {
this.attributes += Attribute.underline;
}
if (cell.isDim()) {
this.attributes += Attribute.dim;
}
if (cell.isFgRGB()) {
this.fgColorMode = ColorMode.RGB;
} else if (cell.isFgPalette()) {
this.fgColorMode = ColorMode.PALETTE;
} else {
this.fgColorMode = ColorMode.DEFAULT;
}
if (cell.isBgRGB()) {
this.bgColorMode = ColorMode.RGB;
} else if (cell.isBgPalette()) {
this.bgColorMode = ColorMode.PALETTE;
} else {
this.bgColorMode = ColorMode.DEFAULT;
}
if (this.fgColorMode === ColorMode.DEFAULT) {
this.fg = -1;
} else {
this.fg = cell.getFgColor();
}
if (this.bgColorMode === ColorMode.DEFAULT) {
this.bg = -1;
} else {
this.bg = cell.getBgColor();
}
}
isCursor(): boolean {
return this.x === this.cursorX && this.y === this.cursorY;
}
getChars(): string {
return this.cell?.getChars() || ' ';
}
isAttribute(attribute: Attribute): boolean {
return (this.attributes & attribute) !== 0;
}
equals(other: Cell): boolean {
return (
this.attributes === other.attributes &&
this.fg === other.fg &&
this.bg === other.bg &&
this.fgColorMode === other.fgColorMode &&
this.bgColorMode === other.bgColorMode &&
this.isCursor() === other.isCursor()
);
}
}
export function serializeTerminalToObject(
terminal: Terminal,
startLine?: number,
@@ -155,10 +38,6 @@ export function serializeTerminalToObject(
const result: AnsiOutput = [];
// Reuse cell instances
const lastCell = new Cell(null, -1, -1, cursorX, cursorY);
const currentCell = new Cell(null, -1, -1, cursorX, cursorY);
const effectiveStart = startLine ?? buffer.viewportY;
const effectiveEnd = endLine ?? buffer.viewportY + terminal.rows;
@@ -170,49 +49,129 @@ export function serializeTerminalToObject(
continue;
}
// Reset lastCell for new line
lastCell.update(null, -1, -1, cursorX, cursorY);
let currentText = '';
let currentBold = false;
let currentItalic = false;
let currentUnderline = false;
let currentDim = false;
let currentInverse = false;
let currentFgMode = ColorMode.DEFAULT;
let currentFgColor = -1;
let currentBgMode = ColorMode.DEFAULT;
let currentBgColor = -1;
let currentFg = '';
let currentBg = '';
for (let x = 0; x < terminal.cols; x++) {
const cellData = line.getCell(x);
currentCell.update(cellData || null, x, y, cursorX, cursorY);
if (x > 0 && !currentCell.equals(lastCell)) {
if (currentText) {
const token: AnsiToken = {
text: currentText,
bold: lastCell.isAttribute(Attribute.bold),
italic: lastCell.isAttribute(Attribute.italic),
underline: lastCell.isAttribute(Attribute.underline),
dim: lastCell.isAttribute(Attribute.dim),
inverse:
lastCell.isAttribute(Attribute.inverse) || lastCell.isCursor(),
fg: convertColorToHex(lastCell.fg, lastCell.fgColorMode, defaultFg),
bg: convertColorToHex(lastCell.bg, lastCell.bgColorMode, defaultBg),
};
currentLine.push(token);
}
currentText = '';
const isCursor = x === cursorX && y === cursorY;
const bold = cellData ? !!cellData.isBold() : false;
const italic = cellData ? !!cellData.isItalic() : false;
const underline = cellData ? !!cellData.isUnderline() : false;
const dim = cellData ? !!cellData.isDim() : false;
const inverse = (cellData ? !!cellData.isInverse() : false) || isCursor;
let fgMode = ColorMode.DEFAULT;
let bgMode = ColorMode.DEFAULT;
let fgColor = -1;
let bgColor = -1;
if (cellData) {
if (cellData.isFgRGB()) fgMode = ColorMode.RGB;
else if (cellData.isFgPalette()) fgMode = ColorMode.PALETTE;
if (cellData.isBgRGB()) bgMode = ColorMode.RGB;
else if (cellData.isBgPalette()) bgMode = ColorMode.PALETTE;
if (fgMode !== ColorMode.DEFAULT) fgColor = cellData.getFgColor();
if (bgMode !== ColorMode.DEFAULT) bgColor = cellData.getBgColor();
}
// Handle wide characters correctly. Wide characters take 2 cells.
// The second cell has a width of 0 and an empty string for getChars().
// For a regular empty cell (width=1), we output a space.
let char = ' ';
if (cellData) {
if (cellData.getWidth() === 0) {
char = '';
} else {
char = cellData.getChars() || ' ';
}
}
if (x === 0) {
currentText = char;
currentBold = bold;
currentItalic = italic;
currentUnderline = underline;
currentDim = dim;
currentInverse = inverse;
currentFgMode = fgMode;
currentFgColor = fgColor;
currentBgMode = bgMode;
currentBgColor = bgColor;
currentFg = convertColorToHex(fgColor, fgMode, defaultFg);
currentBg = convertColorToHex(bgColor, bgMode, defaultBg);
} else {
if (
currentBold !== bold ||
currentItalic !== italic ||
currentUnderline !== underline ||
currentDim !== dim ||
currentInverse !== inverse ||
currentFgMode !== fgMode ||
currentFgColor !== fgColor ||
currentBgMode !== bgMode ||
currentBgColor !== bgColor
) {
if (currentText) {
currentLine.push({
text: currentText,
bold: currentBold,
italic: currentItalic,
underline: currentUnderline,
dim: currentDim,
inverse: currentInverse,
fg: currentFg,
bg: currentBg,
});
}
currentText = char;
currentBold = bold;
currentItalic = italic;
currentUnderline = underline;
currentDim = dim;
currentInverse = inverse;
if (currentFgMode !== fgMode || currentFgColor !== fgColor) {
currentFg = convertColorToHex(fgColor, fgMode, defaultFg);
currentFgMode = fgMode;
currentFgColor = fgColor;
}
if (currentBgMode !== bgMode || currentBgColor !== bgColor) {
currentBg = convertColorToHex(bgColor, bgMode, defaultBg);
currentBgMode = bgMode;
currentBgColor = bgColor;
}
} else {
currentText += char;
}
}
currentText += currentCell.getChars();
// Copy state from currentCell to lastCell. Since we can't easily deep copy
// without allocating, we just update lastCell with the same data.
lastCell.update(cellData || null, x, y, cursorX, cursorY);
}
if (currentText) {
const token: AnsiToken = {
currentLine.push({
text: currentText,
bold: lastCell.isAttribute(Attribute.bold),
italic: lastCell.isAttribute(Attribute.italic),
underline: lastCell.isAttribute(Attribute.underline),
dim: lastCell.isAttribute(Attribute.dim),
inverse: lastCell.isAttribute(Attribute.inverse) || lastCell.isCursor(),
fg: convertColorToHex(lastCell.fg, lastCell.fgColorMode, defaultFg),
bg: convertColorToHex(lastCell.bg, lastCell.bgColorMode, defaultBg),
};
currentLine.push(token);
bold: currentBold,
italic: currentItalic,
underline: currentUnderline,
dim: currentDim,
inverse: currentInverse,
fg: currentFg,
bg: currentBg,
});
}
result.push(currentLine);