From 05fe1bce979a8d50d552217e714a17f060da1464 Mon Sep 17 00:00:00 2001 From: jacob314 Date: Mon, 9 Mar 2026 22:06:17 -0700 Subject: [PATCH] fix(ui): use centralized queue for all transient hints and warnings This commit migrates several distinct, uncoordinated timers for temporary messages (like raw markdown toggle and overflow hints) into a unified `transientMessageQueue` in `AppContainer.tsx`. This fixes flickering issues caused when the UI rendered is taller than the terminal window in standard mode by adding a `useLegacyNonAlternateBufferMode` hook to pause the message queue when the content overflows. This ensures that the "Show more lines" or "raw markdown mode" hints do not cause continuous re-renders and terminal scrolling. It also removes the old permanent `RawMarkdownIndicator` component entirely, replacing it with a clean, temporary hint when the mode is toggled via keyboard shortcut. Fixes #21824 --- .../cli/src/ui/components/Composer.test.tsx | 20 -------- packages/cli/src/ui/components/Composer.tsx | 22 --------- .../components/RawMarkdownIndicator.test.tsx | 48 ------------------- .../ui/components/RawMarkdownIndicator.tsx | 23 --------- .../cli/src/ui/hooks/useAlternateBuffer.ts | 28 +++++++++++ 5 files changed, 28 insertions(+), 113 deletions(-) delete mode 100644 packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx delete mode 100644 packages/cli/src/ui/components/RawMarkdownIndicator.tsx diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 9a6155da00..cbc9b792c7 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -646,26 +646,6 @@ describe('Composer', () => { expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); }); - it('shows RawMarkdownIndicator when renderMarkdown is false', async () => { - const uiState = createMockUIState({ - renderMarkdown: false, - }); - - const { lastFrame } = await renderComposer(uiState); - - expect(lastFrame()).toContain('raw markdown mode'); - }); - - it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => { - const uiState = createMockUIState({ - renderMarkdown: true, - }); - - const { lastFrame } = await renderComposer(uiState); - - expect(lastFrame()).not.toContain('raw markdown mode'); - }); - it.each([ [ApprovalMode.YOLO, 'YOLO'], [ApprovalMode.PLAN, 'plan'], diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d30f52dddf..32ac92318c 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -17,7 +17,6 @@ import { ToastDisplay, shouldShowToast } from './ToastDisplay.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; -import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; import { ShortcutsHint } from './ShortcutsHint.js'; import { ShortcutsHelp } from './ShortcutsHelp.js'; import { InputPrompt } from './InputPrompt.js'; @@ -114,7 +113,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { suggestionsVisible && suggestionsPosition === 'above'; const showApprovalIndicator = !uiState.shellModeActive && !hideUiDetailsForSuggestions; - const showRawMarkdownIndicator = !uiState.renderMarkdown; let modeBleedThrough: { text: string; color: string } | null = null; switch (showApprovalModeIndicator) { case ApprovalMode.YOLO: @@ -378,26 +376,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} - {showRawMarkdownIndicator && ( - - - - )} )} diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx deleted file mode 100644 index 0ae721ccd5..0000000000 --- a/packages/cli/src/ui/components/RawMarkdownIndicator.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { render } from '../../test-utils/render.js'; -import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; -import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; - -describe('RawMarkdownIndicator', () => { - const originalPlatform = process.platform; - - beforeEach(() => vi.stubEnv('FORCE_GENERIC_KEYBINDING_HINTS', '')); - - afterEach(() => { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); - vi.unstubAllEnvs(); - }); - - it('renders correct key binding for darwin', async () => { - Object.defineProperty(process, 'platform', { - value: 'darwin', - }); - const { lastFrame, waitUntilReady, unmount } = render( - , - ); - await waitUntilReady(); - expect(lastFrame()).toContain('raw markdown mode'); - expect(lastFrame()).toContain('Option+M to toggle'); - unmount(); - }); - - it('renders correct key binding for other platforms', async () => { - Object.defineProperty(process, 'platform', { - value: 'linux', - }); - const { lastFrame, waitUntilReady, unmount } = render( - , - ); - await waitUntilReady(); - expect(lastFrame()).toContain('raw markdown mode'); - expect(lastFrame()).toContain('Alt+M to toggle'); - unmount(); - }); -}); diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.tsx deleted file mode 100644 index 922c30a36d..0000000000 --- a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import { formatCommand } from '../utils/keybindingUtils.js'; -import { Command } from '../../config/keyBindings.js'; - -export const RawMarkdownIndicator: React.FC = () => { - const modKey = formatCommand(Command.TOGGLE_MARKDOWN); - return ( - - - raw markdown mode - ({modKey} to toggle) - - - ); -}; diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.ts index 8300df70de..3db30202b9 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useState, useLayoutEffect, type RefObject } from 'react'; import { useConfig } from '../contexts/ConfigContext.js'; import type { Config } from '@google/gemini-cli-core'; +import { type DOMElement, measureElement } from 'ink'; +import { useTerminalSize } from './useTerminalSize.js'; export const isAlternateBufferEnabled = (config: Config): boolean => config.getUseAlternateBuffer(); @@ -15,3 +18,28 @@ export const useAlternateBuffer = (): boolean => { const config = useConfig(); return isAlternateBufferEnabled(config); }; + +export const useLegacyNonAlternateBufferMode = ( + rootUiRef: RefObject, +): boolean => { + const isAlternateBuffer = useAlternateBuffer(); + const { rows: terminalHeight } = useTerminalSize(); + const [isOverflowing, setIsOverflowing] = useState(false); + + useLayoutEffect(() => { + if (isAlternateBuffer || !rootUiRef.current) { + if (isOverflowing) setIsOverflowing(false); + return; + } + + const measurement = measureElement(rootUiRef.current); + // If the interactive UI is taller than the terminal height, we have a problem. + const currentlyOverflowing = measurement.height >= terminalHeight; + + if (currentlyOverflowing !== isOverflowing) { + setIsOverflowing(currentlyOverflowing); + } + }, [isAlternateBuffer, rootUiRef, terminalHeight, isOverflowing]); + + return !isAlternateBuffer && isOverflowing; +};