diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 0f01558d2e..d1b4e0dced 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -74,6 +74,8 @@ they appear in the UI. | Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` | | Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` | | Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` | +| Render Process | `ui.renderProcess` | Enable Ink render process for the UI. | `true` | +| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `true` | | Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` | | Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | | Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 87433ef4f1..cb073b2cf6 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -337,6 +337,16 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`ui.renderProcess`** (boolean): + - **Description:** Enable Ink render process for the UI. + - **Default:** `true` + - **Requires restart:** Yes + +- **`ui.terminalBuffer`** (boolean): + - **Description:** Use the new terminal buffer architecture for rendering. + - **Default:** `true` + - **Requires restart:** Yes + - **`ui.useBackgroundColor`** (boolean): - **Description:** Whether to use background colors in the UI. - **Default:** `true` diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index e87c8682df..68b3d884fe 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -102,7 +102,8 @@ available combinations. | `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` | | `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` | | `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` | -| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `Ctrl+S` | +| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `F9` | +| `app.toggleMouseMode` | Toggle mouse mode (scrolling and clicking). | `Ctrl+S` | | `app.toggleYolo` | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` | | `app.cycleApprovalMode` | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` | | `app.showMoreLines` | Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` | @@ -126,6 +127,9 @@ available combinations. | `background.unfocus` | Move focus from background shell to Gemini. | `Shift+Tab` | | `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` | | `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` | +| `app.dumpFrame` | Dump the current frame as a snapshot. | `F8` | +| `app.startRecording` | Start recording the session. | `F6` | +| `app.stopRecording` | Stop recording the session. | `F7` | #### Extension Controls diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ff2f1f9d25..bc9500aa65 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 diff --git a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap index 59b00995eb..ff19d367de 100644 --- a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap +++ b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap @@ -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. +" `; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 371be2afd1..17531a75d4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index b2fa2139fd..4bbc7e7648 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -327,6 +327,7 @@ describe('gemini.tsx main function cleanup', () => { refreshAuth: vi.fn(), getRemoteAdminSettings: vi.fn(() => undefined), getUseAlternateBuffer: vi.fn(() => false), + getUseTerminalBuffer: vi.fn(() => false), ...overrides, } as unknown as Config; } diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index 2e0cd25619..418f58b193 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -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'] ? ( @@ -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 && diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index d34576cf3f..ba356081ac 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -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(); diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index daf109d928..29255cbd4d 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -175,6 +175,8 @@ export const createMockConfig = (overrides: Partial = {}): 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; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 817921e83a..c9982103d3 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -223,7 +223,7 @@ class XtermStdout extends EventEmitter { this.once('render', resolve), ); const timeoutPromise = new Promise((resolve) => - setTimeout(resolve, 50), + setTimeout(resolve, 1000), ); await Promise.race([renderPromise, timeoutPromise]); } @@ -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), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0e436cc645..7388405df2 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -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>['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 () => diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4da8acfdb7..69767fdefa 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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(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(''); @@ -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(null); + + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + const [controlsHeight, setControlsHeight] = useState(0); const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0); @@ -1724,6 +1754,17 @@ Logging in with Google... Restarting Gemini CLI to continue. setShortcutsHelpVisible(false); } + if (keyMatchers[Command.TOGGLE_MOUSE_MODE](key)) { + setMouseMode((prev) => { + const next = !prev; + if (!next && !isAlternateBuffer) { + appEvents.emit(AppEvent.ScrollToBottom); + } + return next; + }); + return true; + } + if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { setCopyModeEnabled(true); disableMouseEvents(); @@ -1746,6 +1787,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 +1999,9 @@ Logging in with Google... Restarting Gemini CLI to continue. historyManager.history, pendingHistoryItems, toggleAllExpansion, + dumpCurrentFrame, + startRecording, + stopRecording, ], ); @@ -1951,7 +2021,9 @@ Logging in with Google... Restarting Gemini CLI to continue. } setCopyModeEnabled(false); - enableMouseEvents(); + if (mouseMode) { + enableMouseEvents(); + } return true; }, { @@ -2268,6 +2340,7 @@ Logging in with Google... Restarting Gemini CLI to continue. editorError, isEditorDialogOpen, showPrivacyNotice, + mouseMode, corgiMode, debugMessage, quittingMessages, @@ -2394,6 +2467,7 @@ Logging in with Google... Restarting Gemini CLI to continue. editorError, isEditorDialogOpen, showPrivacyNotice, + mouseMode, corgiMode, debugMessage, quittingMessages, @@ -2694,7 +2768,11 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleAllExpansion={toggleAllExpansion} > - + + + + + diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 590d1e9c6b..b9e1749bfd 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -26,6 +26,7 @@ import { OverflowProvider } from '../contexts/OverflowContext.js'; import { ConfigInitDisplay } from './ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; import { useComposerStatus } from '../hooks/useComposerStatus.js'; +import { appEvents, AppEvent } from '../../utils/events.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const uiState = useUIState(); @@ -55,6 +56,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const { setShortcutsHelpVisible } = uiActions; + useEffect(() => { + if (hasPendingActionRequired) { + appEvents.emit(AppEvent.ScrollToBottom); + } + }, [hasPendingActionRequired]); + useEffect(() => { if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) { setShortcutsHelpVisible(false); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index d6fc23dd70..523f15516c 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -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 }, diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index de6e8096ec..26cd93ba5d 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -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 () => { diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index ed685f76c9..058fb0db55 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -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(); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index f078dbc7d6..cce8bf2eaa 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -338,6 +338,10 @@ export const InputPrompt: React.FC = ({ const showCursor = focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled; + useEffect(() => { + appEvents.emit(AppEvent.ScrollToBottom); + }, [buffer.text, buffer.cursor]); + // Notify parent component about escape prompt state changes useEffect(() => { if (onEscapePromptChange) { diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index c4e395c612..94d5856f1f 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -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, @@ -22,6 +23,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { isTopicTool } from './messages/TopicMessage.js'; +import { appEvents, AppEvent } from '../../utils/events.js'; const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedAppHeader = memo(AppHeader); @@ -33,7 +35,10 @@ const MemoizedAppHeader = memo(AppHeader); export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); - const isAlternateBuffer = useAlternateBuffer(); + const isAlternateBufferOrTerminalBuffer = useAlternateBuffer(); + const config = useConfig(); + const useTerminalBuffer = config.getUseTerminalBuffer(); + const isAlternateBuffer = config.getUseAlternateBuffer(); const confirmingTool = useConfirmingTool(); const showConfirmationQueue = confirmingTool !== null; @@ -47,12 +52,23 @@ export const MainContent = () => { } }, [showConfirmationQueue, confirmingToolCallId]); + useEffect(() => { + const handleScroll = () => { + scrollableListRef.current?.scrollToEnd(); + }; + appEvents.on(AppEvent.ScrollToBottom, handleScroll); + return () => { + appEvents.off(AppEvent.ScrollToBottom, handleScroll); + }; + }, []); + const { pendingHistoryItems, mainAreaWidth, staticAreaMaxItemHeight, availableTerminalHeight, cleanUiDetailsVisible, + mouseMode, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; @@ -284,24 +300,68 @@ export const MainContent = () => { ], ); - if (isAlternateBuffer) { - return ( - 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} - /> - ); + 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 (isAlternateBufferOrTerminalBuffer) { + return ( + + // TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()} + // as that will reduce the # of cases where we will have to clear the + // scrollback buffer due to the scrollback size changing but we need to + // work out ensuring we only attempt it within a smaller range of + // scrollback vals. Right now it sometimes triggers adding more white + // space than it should. + ); + } + return null; + }, [ + isAlternateBufferOrTerminalBuffer, + uiState.isEditorDialogOpen, + uiState.embeddedShellFocused, + uiState.terminalWidth, + virtualizedData, + renderItem, + estimatedItemHeight, + keyExtractor, + useTerminalBuffer, + isStaticItem, + mouseMode, + isAlternateBuffer, + ]); + + if (isAlternateBufferOrTerminalBuffer) { + return scrollableList; } return ( diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx index acb7897ba1..2e2ec16a94 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx @@ -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()); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index adaa339a64..2f059086b0 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -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(null); + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + const onRefChange = useCallback( (node: DOMElement | null) => { if (observerRef.current) { @@ -169,6 +176,13 @@ export const StatusRow: React.FC = ({ const [tipWidth, setTipWidth] = useState(0); const tipObserverRef = useRef(null); + useEffect( + () => () => { + tipObserverRef.current?.disconnect(); + }, + [], + ); + const onTipRefChange = useCallback((node: DOMElement | null) => { if (tipObserverRef.current) { tipObserverRef.current.disconnect(); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 490fa0d4a1..451d0f4bb7 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -59,6 +59,7 @@ describe('ToolConfirmationQueue', () => { getPlansDir: () => '/mock/temp/plans', }, getUseAlternateBuffer: () => false, + getUseTerminalBuffer: () => false, } as unknown as Config; beforeEach(() => { diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap index c8c4c53c89..0592c7bfe4 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap @@ -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 +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap index 6df246dede..4fa5fb7901 100644 --- a/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap @@ -4,13 +4,15 @@ exports[`Banner > handles newlines in text 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Line 1 │ │ Line 2 │ -╰──────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`Banner > renders in info mode 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Info Message │ -╰──────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`Banner > renders in multi-line warning 1`] = ` @@ -18,11 +20,13 @@ exports[`Banner > renders in multi-line warning 1`] = ` │ Title Line │ │ Body Line 1 │ │ Body Line 2 │ -╰──────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`Banner > renders in warning mode 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Warning Message │ -╰──────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap index 626ae93359..914f9bb0ed 100644 --- a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap @@ -42,7 +42,8 @@ exports[` > highlights the active item in the preview 1`] │ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[` > renders correctly with default settings 1`] = ` @@ -133,7 +134,8 @@ exports[` > renders correctly with default settings 2`] = │ │ ~/project/path main docker gemini-2.5-pro 97% │ │ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[` > updates the preview when Show footer labels is toggled off 1`] = ` @@ -177,5 +179,6 @@ exports[` > updates the preview when Show footer labels is │ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap index 5e04b96cb8..8820d6ca6d 100644 --- a/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HookStatusDisplay.test.tsx.snap @@ -1,6 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > matches SVG snapshot for single hook 1`] = `"Executing Hook: test-hook"`; +exports[` > matches SVG snapshot for single hook 1`] = ` +"Executing Hook: test-hook +" +`; exports[` > should render a single executing hook 1`] = ` "Executing Hook: test-hook diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index ab6fe9b928..38127b05ac 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -4,14 +4,16 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > first line │ │ second line │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the end of a line' in a multiline block 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > first line │ │ second line │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'in the middle of a line' in a multiline block 1`] = ` @@ -19,7 +21,8 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > │ > first line │ │ second line │ │ third line │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = ` @@ -27,67 +30,78 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > │ > first line │ │ │ │ third line │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'after multi-byte unicode characters' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > 👍A │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the beginning of the line' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > hello │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a line with unicode cha…' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > hello 👍 │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a short line with unico…' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > 👍 │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > hello │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > hello 👍 world │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'mid-word' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > hello world │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > run @path/to/file │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a space between words' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > hello world │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on an empty line' 1`] = ` "──────────────────────────────────────────────────────────────────────────────────────────────────── │ > Type your message or @path/to/file │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = ` @@ -173,13 +187,15 @@ exports[`InputPrompt > multiline rendering > should correctly render multiline i │ > hello │ │ │ │ world │ -────────────────────────────────────────────────────────────────────────────────────────────────────" +──────────────────────────────────────────────────────────────────────────────────────────────────── +" `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Type your message or @path/to/file -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index f0260ddc91..362246423c 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -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. +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index d585c9a918..7c57df0085 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -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) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap index 8356ef4345..2c98e41b5e 100644 --- a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap @@ -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 +" `; diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx index 1767eb10ad..47237f73ea 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx @@ -6,6 +6,8 @@ import { describe, it, expect } from 'vitest'; import { renderWithProviders } from '../../../test-utils/render.js'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import { waitFor } from '../../../test-utils/async.js'; import { DenseToolMessage } from './DenseToolMessage.js'; import { CoreToolCallStatus, @@ -21,8 +23,6 @@ import type { ToolResultDisplay, } from '../../types.js'; -import { createMockSettings } from '../../../test-utils/settings.js'; - describe('DenseToolMessage', () => { const defaultProps = { callId: 'call-1', @@ -92,17 +92,23 @@ describe('DenseToolMessage', () => { model_removed_chars: 40, }, }; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( , - {}, + { + settings: createMockSettings({ + merged: { useAlternateBuffer: false, useTerminalBuffer: false }, + }), + }, ); - await waitUntilReady(); + await waitFor(() => expect(lastFrame()).toContain('test-tool')); + await waitFor(() => + expect(lastFrame()).toContain('test.ts → Accepted (+15, -6)'), + ); + await waitFor(() => expect(lastFrame()).toContain('diff content')); const output = lastFrame(); - expect(output).toContain('test.ts → Accepted (+15, -6)'); - expect(output).toContain('diff content'); expect(output).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 631bbf032d..fa565bc103 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -90,6 +90,13 @@ export const ToolConfirmationMessage: React.FC< useState(0); const observerRef = useRef(null); + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + const deceptiveUrlWarnings = useMemo(() => { const urls: string[] = []; if (confirmationDetails.type === 'info' && confirmationDetails.urls) { diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 4b51ae8ab8..7bb3934e94 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -51,7 +51,7 @@ export const ToolResultDisplay: React.FC = ({ 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 = ({ 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, diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap index d08b84c1a9..b81409c606 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap @@ -13,7 +13,10 @@ exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff " `; -exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`; +exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = ` +" - read_file Reading important.txt +" +`; exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = ` " ✓ edit test.ts → Accepted (+1, -1) diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap index f9eea8fb0a..1c4a9fe920 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap @@ -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 +" `; diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index 0e3869a3f0..7aa40cfc62 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -42,6 +42,14 @@ export const MaxSizedBox: React.FC = ({ const id = useId(); const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {}; const observerRef = useRef(null); + + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + const [contentHeight, setContentHeight] = useState(0); const onRefChange = useCallback( diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index a95d2ff112..d9c3fb8c7a 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -33,6 +33,9 @@ interface ScrollableProps { scrollToBottom?: boolean; flexGrow?: number; reportOverflow?: boolean; + overflowToBackbuffer?: boolean; + scrollbar?: boolean; + stableScrollback?: boolean; } export const Scrollable: React.FC = ({ @@ -45,6 +48,9 @@ export const Scrollable: React.FC = ({ 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 = ({ const viewportObserverRef = useRef(null); const contentObserverRef = useRef(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 = ({ scrollTop={scrollTop} flexGrow={flexGrow} scrollbarThumbColor={scrollbarColor} + overflowToBackbuffer={overflowToBackbuffer} + scrollbar={scrollbar} + stableScrollback={stableScrollback} > {/* This inner box is necessary to prevent the parent from shrinking diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index fd7eaeb8e3..326005726f 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -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,14 @@ import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; const ANIMATION_FRAME_DURATION_MS = 33; -type VirtualizedListProps = { - 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 extends VirtualizedListProps { hasFocus: boolean; width?: string | number; + scrollbar?: boolean; + stableScrollback?: boolean; + copyModeEnabled?: boolean; + isStatic?: boolean; + fixedItemHeight?: boolean; } export type ScrollableListRef = VirtualizedListRef; @@ -48,7 +45,7 @@ function ScrollableList( ref: React.Ref>, ) { const keyMatchers = useKeyMatchers(); - const { hasFocus, width } = props; + const { hasFocus, width, scrollbar = true, stableScrollback } = props; const virtualizedListRef = useRef>(null); const containerRef = useRef(null); @@ -258,17 +255,13 @@ function ScrollableList( useScrollable(scrollableEntry, true); return ( - + ); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx index 75fcbd4633..98e7790538 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx @@ -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('', () => { const keyExtractor = (item: string) => item; @@ -324,11 +317,6 @@ describe('', () => { }); it('renders correctly in copyModeEnabled when scrolled', async () => { - const { useUIState } = await import('../../contexts/UIStateContext.js'); - vi.mocked(useUIState).mockReturnValue({ - copyModeEnabled: true, - } as Partial as UIState); - const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); // Use copy mode const { lastFrame, unmount } = await render( @@ -343,6 +331,7 @@ describe('', () => { keyExtractor={(item) => item} estimatedItemHeight={() => 1} initialScrollIndex={50} + copyModeEnabled={true} /> , ); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 669b1bc035..e7b756b649 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -12,17 +12,17 @@ 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 { 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 = { +export type VirtualizedListProps = { data: T[]; renderItem: (info: { item: T; index: number }) => React.ReactElement; estimatedItemHeight: (index: number) => number; @@ -30,6 +30,15 @@ type VirtualizedListProps = { 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; + fixedItemHeight?: boolean; }; export type VirtualizedListRef = { @@ -66,6 +75,43 @@ function findLastIndex( 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; + }) => ( + + {shouldBeStatic ? ( + + {content} + + ) : ( + content + )} + + ), +); + +VirtualizedListItem.displayName = 'VirtualizedListItem'; + function VirtualizedList( props: VirtualizedListProps, ref: React.Ref>, @@ -77,8 +123,16 @@ function VirtualizedList( keyExtractor, initialScrollIndex, initialScrollOffsetInIndex, + renderStatic, + isStatic, + isStaticItem, + width, + overflowToBackbuffer, + scrollbar = true, + stableScrollback, + copyModeEnabled = false, + fixedItemHeight = false, } = props; - const { copyModeEnabled } = useUIState(); const dataRef = useRef(data); useLayoutEffect(() => { dataRef.current = data; @@ -119,6 +173,7 @@ function VirtualizedList( const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); const itemRefs = useRef>([]); const [heights, setHeights] = useState>({}); const isInitialScrollSet = useRef(false); @@ -133,7 +188,10 @@ function VirtualizedList( 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 +300,9 @@ function VirtualizedList( const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels; if (wasAtBottom && actualScrollTop >= prevScrollTop.current) { - setIsStickingToBottom(true); + if (!isStickingToBottom) { + setIsStickingToBottom(true); + } } const listGrew = data.length > prevDataLength.current; @@ -253,10 +313,16 @@ function VirtualizedList( (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 +332,17 @@ function VirtualizedList( 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 +355,7 @@ function VirtualizedList( actualScrollTop, scrollableContainerHeight, scrollAnchor.index, + scrollAnchor.offset, getAnchorForScrollTop, offsets, isStickingToBottom, @@ -348,15 +423,22 @@ function VirtualizedList( ? data.length - 1 : Math.min(data.length - 1, endIndexOffset); - const topSpacerHeight = offsets[startIndex] ?? 0; - const bottomSpacerHeight = - totalHeight - (offsets[endIndex + 1] ?? totalHeight); + const topSpacerHeight = + renderStatic === true || overflowToBackbuffer === true + ? 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>(new Set()); useLayoutEffect(() => { const currentNodes = new Set(); - for (let i = startIndex; i <= endIndex; i++) { + const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex; + const observeEnd = renderStatic ? data.length - 1 : endIndex; + + for (let i = observeStart; i <= observeEnd; i++) { const node = itemRefs.current[i]; const item = data[i]; if (node && item) { @@ -364,14 +446,16 @@ function VirtualizedList( 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 && !fixedItemHeight && !observedNodes.current.has(node)) { itemsObserver.observe(node); } } } for (const node of observedNodes.current) { if (!currentNodes.has(node)) { - itemsObserver.unobserve(node); + if (!isStatic && !fixedItemHeight) { + itemsObserver.unobserve(node); + } nodeToKeyRef.current.delete(node); } } @@ -379,22 +463,49 @@ function VirtualizedList( }); const renderedItems = []; - for (let i = startIndex; i <= endIndex; i++) { - const item = data[i]; - if (item) { - renderedItems.push( - { - itemRefs.current[i] = el; - }} - > - {renderItem({ item, index: i })} - , - ); + const renderRangeStart = + renderStatic || overflowToBackbuffer ? 0 : startIndex; + const renderRangeEnd = renderStatic ? data.length - 1 : endIndex; + + // 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 || + process.env['NODE_ENV'] === 'test' || + (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 === true && isOutsideViewport) || + isStaticItem?.(item, i) === true; + + const content = renderItem({ item, index: i }); + const key = keyExtractor(item, i); + + renderedItems.push( + { + if (i >= renderRangeStart && i <= renderRangeEnd) { + itemRefs.current[i] = el; + } + }} + />, + ); + } } } @@ -539,6 +650,9 @@ function VirtualizedList( height="100%" flexDirection="column" paddingRight={copyModeEnabled ? 0 : 1} + overflowToBackbuffer={overflowToBackbuffer} + scrollbar={scrollbar} + stableScrollback={stableScrollback} > 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... +" `; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a5d10820b2..99d5874aba 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -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; + mainControlsRef: (node: DOMElement | null) => void; // NOTE: This is for performance profiling only. rootUiRef: React.MutableRefObject; currentIDE: IdeInfo | null; diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts index 23e5a8b444..937a87195d 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts @@ -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); 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); 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; 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); diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.ts index 8300df70de..8b41d59f3f 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.ts @@ -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 => { diff --git a/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts b/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts index 46f6bb5c68..f45a7054d7 100644 --- a/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts +++ b/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts @@ -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) { diff --git a/packages/cli/src/ui/hooks/useBatchedScroll.ts b/packages/cli/src/ui/hooks/useBatchedScroll.ts index 05b73a9068..c294fb0cca 100644 --- a/packages/cli/src/ui/hooks/useBatchedScroll.ts +++ b/packages/cli/src/ui/hooks/useBatchedScroll.ts @@ -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; }); diff --git a/packages/cli/src/ui/hooks/useExecutionLifecycle.ts b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts index e0b5c3ffaa..06df7e8d3f 100644 --- a/packages/cli/src/ui/hooks/useExecutionLifecycle.ts +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts @@ -525,47 +525,81 @@ export const useExecutionLifecycle = ( dispatch({ type: 'SET_ACTIVE_PTY', pid: null }); } - let mainContent: string; + let finalDisplayOutput: string | AnsiOutput; + let finalTextOutput: string; + if (isBinaryStream || isBinary(result.rawOutput)) { - mainContent = + finalTextOutput = '[Command produced binary output, which is not shown.]'; + finalDisplayOutput = finalTextOutput; } else { - mainContent = + finalTextOutput = result.output.trim() || '(Command produced no output)'; + finalDisplayOutput = Array.isArray(cumulativeStdout) + ? cumulativeStdout + : finalTextOutput; } - let finalOutput = mainContent; let finalStatus = CoreToolCallStatus.Success; + const prefixWarnings: string[] = []; if (result.error) { finalStatus = CoreToolCallStatus.Error; - finalOutput = `${result.error.message}\n${finalOutput}`; + prefixWarnings.push(result.error.message); } else if (result.aborted) { finalStatus = CoreToolCallStatus.Cancelled; - finalOutput = `Command was cancelled.\n${finalOutput}`; + prefixWarnings.push('Command was cancelled.'); } else if (result.backgrounded) { finalStatus = CoreToolCallStatus.Success; - finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + finalDisplayOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + finalTextOutput = finalDisplayOutput; + prefixWarnings.length = 0; } else if (result.signal) { finalStatus = CoreToolCallStatus.Error; - finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`; - } else if (result.exitCode !== 0) { + prefixWarnings.push( + `Command terminated by signal: ${result.signal}.`, + ); + } else if (result.exitCode !== null && result.exitCode !== 0) { finalStatus = CoreToolCallStatus.Error; - finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`; + prefixWarnings.push(`Command exited with code ${result.exitCode}.`); } if (pwdFilePath && fs.existsSync(pwdFilePath)) { const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim(); if (finalPwd && finalPwd !== targetDir) { - const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`; - finalOutput = `${warning}\n\n${finalOutput}`; + prefixWarnings.push( + `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.\n`, + ); + } + } + + if (prefixWarnings.length > 0) { + const prefixString = prefixWarnings.join('\n') + '\n'; + finalTextOutput = prefixString + finalTextOutput; + + if (typeof finalDisplayOutput === 'string') { + finalDisplayOutput = prefixString + finalDisplayOutput; + } else { + const ansiPrefix = prefixString.split('\n').map((line) => [ + { + text: line, + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + ]); + finalDisplayOutput = [...ansiPrefix, ...finalDisplayOutput]; } } const finalToolDisplay: IndividualToolCallDisplay = { ...initialToolDisplay, status: finalStatus, - resultDisplay: finalOutput, + resultDisplay: finalDisplayOutput, }; if (finalStatus !== CoreToolCallStatus.Cancelled) { @@ -578,7 +612,11 @@ export const useExecutionLifecycle = ( ); } - addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput); + addShellCommandToGeminiHistory( + geminiClient, + rawQuery, + finalTextOutput, + ); } catch (err) { setPendingHistoryItem(null); const errorMessage = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 04c5b64dd2..c988fe711a 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -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 () => { diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts index 991a52a1c8..b2cd40df9a 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts @@ -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()); diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index bef10f8522..c23596dc0f 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -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> = { [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> = { // 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( diff --git a/packages/cli/src/ui/key/keyMatchers.test.ts b/packages/cli/src/ui/key/keyMatchers.test.ts index ab12ca1ddf..2a3709350f 100644 --- a/packages/cli/src/ui/key/keyMatchers.test.ts +++ b/packages/cli/src/ui/key/keyMatchers.test.ts @@ -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 })], }, diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index aaa9e04632..964fb5ec55 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -34,7 +34,6 @@ export const DefaultAppLayout: React.FC = () => { paddingBottom={isAlternateBuffer ? 1 : undefined} flexShrink={0} flexGrow={0} - overflow="hidden" ref={uiState.rootUiRef} > diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index d88f3f1fb2..f6eb87b102 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -29,7 +29,7 @@ export const ScreenReaderAppLayout: React.FC = () => { >