diff options
| author | Taylor Mullen <[email protected]> | 2025-05-15 00:19:41 -0700 |
|---|---|---|
| committer | N. Taylor Mullen <[email protected]> | 2025-05-15 22:57:28 -0700 |
| commit | 33743d347b6721f8eec537d01ad9f6a95b4c6683 (patch) | |
| tree | ab6630623da14e7893a2cfc54d4a5bb3ab815b2c /packages/cli/src/ui/App.tsx | |
| parent | 601a61ed31efb281e6bb396c5f15c491bfbca27d (diff) | |
Fix: Prevent UI tearing and improve display of long content
This commit introduces several changes to better manage terminal height and prevent UI tearing, especially when displaying long tool outputs or when the pending history item exceeds the available terminal height.
- Calculate and utilize available terminal height in `App.tsx`, `HistoryItemDisplay.tsx`, `ToolGroupMessage.tsx`, and `ToolMessage.tsx`.
- Refresh the static display area in `App.tsx` when a pending history item is too large, working around an Ink bug (see https://github.com/vadimdemedes/ink/pull/717).
- Truncate long tool output in `ToolMessage.tsx` and indicate the number of hidden lines.
- Refactor `App.tsx` to correctly measure and account for footer height.
Fixes https://b.corp.google.com/issues/414196943
Diffstat (limited to 'packages/cli/src/ui/App.tsx')
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 304 |
1 files changed, 175 insertions, 129 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 21d6a730..b6275491 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Box, Static, Text, useStdout } from 'ink'; +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { Box, DOMElement, measureElement, Static, Text, useStdout } from 'ink'; import { StreamingState, type HistoryItem } from './types.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; @@ -46,6 +46,7 @@ export const App = ({ startupWarnings = [], }: AppProps) => { const { history, addItem, clearItems } = useHistory(); + const [staticNeedsRefresh, setStaticNeedsRefresh] = useState(false); const [staticKey, setStaticKey] = useState(0); const refreshStatic = useCallback(() => { setStaticKey((prev) => prev + 1); @@ -55,7 +56,8 @@ export const App = ({ const [debugMessage, setDebugMessage] = useState<string>(''); const [showHelp, setShowHelp] = useState<boolean>(false); const [themeError, setThemeError] = useState<string | null>(null); - + const [availableTerminalHeight, setAvailableTerminalHeight] = + useState<number>(0); const { isThemeDialogOpen, openThemeDialog, @@ -193,12 +195,51 @@ export const App = ({ // --- Render Logic --- - // Get terminal width + // Get terminal dimensions + const { stdout } = useStdout(); const terminalWidth = stdout?.columns ?? 80; + const terminalHeight = stdout?.rows ?? 24; + const footerRef = useRef<DOMElement>(null); + const pendingHistoryItemRef = useRef<DOMElement>(null); + // Calculate width for suggestions, leave some padding const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); + useEffect(() => { + const staticExtraHeight = /* margins and padding */ 3; + const fullFooterMeasurement = measureElement(footerRef.current!); + const fullFooterHeight = fullFooterMeasurement.height; + + setAvailableTerminalHeight( + terminalHeight - fullFooterHeight - staticExtraHeight, + ); + }, [terminalHeight]); + + useEffect(() => { + if (!pendingHistoryItem) { + return; + } + + const pendingItemDimensions = measureElement( + pendingHistoryItemRef.current!, + ); + + // If our pending history item happens to exceed the terminal height we will most likely need to refresh + // our static collection to ensure no duplication or tearing. This is currently working around a core bug + // in Ink which we have a PR out to fix: https://github.com/vadimdemedes/ink/pull/717 + if (pendingItemDimensions.height > availableTerminalHeight) { + setStaticNeedsRefresh(true); + } + }, [pendingHistoryItem, availableTerminalHeight, streamingState]); + + useEffect(() => { + if (streamingState === StreamingState.Idle && staticNeedsRefresh) { + setStaticNeedsRefresh(false); + refreshStatic(); + } + }, [streamingState, refreshStatic, staticNeedsRefresh]); + return ( <Box flexDirection="column" marginBottom={1} width="90%"> {/* @@ -219,146 +260,151 @@ export const App = ({ <Header /> <Tips /> </Box>, - ...history.map((h) => <HistoryItemDisplay key={h.id} item={h} />), + ...history.map((h) => <HistoryItemDisplay availableTerminalHeight={availableTerminalHeight} key={h.id} item={h} />), ]} > {(item) => item} </Static> {pendingHistoryItem && ( - <HistoryItemDisplay - // TODO(taehykim): It seems like references to ids aren't necessary in - // HistoryItemDisplay. Refactor later. Use a fake id for now. - item={{ ...pendingHistoryItem, id: 0 }} - /> + <Box ref={pendingHistoryItemRef}> + <HistoryItemDisplay + availableTerminalHeight={availableTerminalHeight} + // TODO(taehykim): It seems like references to ids aren't necessary in + // HistoryItemDisplay. Refactor later. Use a fake id for now. + item={{ ...pendingHistoryItem, id: 0 }} + /> + </Box> )} {showHelp && <Help commands={slashCommands} />} - {startupWarnings.length > 0 && ( - <Box - borderStyle="round" - borderColor={Colors.AccentYellow} - paddingX={1} - marginY={1} - flexDirection="column" - > - {startupWarnings.map((warning, index) => ( - <Text key={index} color={Colors.AccentYellow}> - {warning} - </Text> - ))} - </Box> - )} + <Box flexDirection="column" ref={footerRef}> + {startupWarnings.length > 0 && ( + <Box + borderStyle="round" + borderColor={Colors.AccentYellow} + paddingX={1} + marginY={1} + flexDirection="column" + > + {startupWarnings.map((warning, index) => ( + <Text key={index} color={Colors.AccentYellow}> + {warning} + </Text> + ))} + </Box> + )} - {isThemeDialogOpen ? ( - <Box flexDirection="column"> - {themeError && ( - <Box marginBottom={1}> - <Text color={Colors.AccentRed}>{themeError}</Text> - </Box> - )} - <ThemeDialog - onSelect={handleThemeSelect} - onHighlight={handleThemeHighlight} - settings={settings} - setQuery={setQuery} - /> - </Box> - ) : ( - <> - <LoadingIndicator - isLoading={streamingState === StreamingState.Responding} - currentLoadingPhrase={currentLoadingPhrase} - elapsedTime={elapsedTime} - /> - {isInputActive && ( - <> - <Box - marginTop={1} - display="flex" - justifyContent="space-between" - width="100%" - > - <Box> - <Text color={Colors.SubtleComment}>cwd: </Text> - <Text color={Colors.LightBlue}> - {shortenPath(config.getTargetDir(), 70)} - </Text> - </Box> + {isThemeDialogOpen ? ( + <Box flexDirection="column"> + {themeError && ( + <Box marginBottom={1}> + <Text color={Colors.AccentRed}>{themeError}</Text> </Box> - - <InputPrompt - query={query} - onChange={setQuery} - onChangeAndMoveCursor={onChangeAndMoveCursor} - editorState={editorState} - onSubmit={handleFinalSubmit} // Pass handleFinalSubmit directly - showSuggestions={completion.showSuggestions} - suggestions={completion.suggestions} - activeSuggestionIndex={completion.activeSuggestionIndex} - userMessages={userMessages} // Pass userMessages - navigateSuggestionUp={completion.navigateUp} - navigateSuggestionDown={completion.navigateDown} - resetCompletion={completion.resetCompletionState} - setEditorState={setEditorState} - onClearScreen={handleClearScreen} // Added onClearScreen prop - /> - {completion.showSuggestions && ( - <Box> - <SuggestionsDisplay - suggestions={completion.suggestions} - activeIndex={completion.activeSuggestionIndex} - isLoading={completion.isLoadingSuggestions} - width={suggestionsWidth} - scrollOffset={completion.visibleStartIndex} - userInput={query} - /> + )} + <ThemeDialog + onSelect={handleThemeSelect} + onHighlight={handleThemeHighlight} + settings={settings} + setQuery={setQuery} + /> + </Box> + ) : ( + <> + <LoadingIndicator + isLoading={streamingState === StreamingState.Responding} + currentLoadingPhrase={currentLoadingPhrase} + elapsedTime={elapsedTime} + /> + {isInputActive && ( + <> + <Box + marginTop={1} + display="flex" + justifyContent="space-between" + width="100%" + > + <Box> + <Text color={Colors.SubtleComment}>cwd: </Text> + <Text color={Colors.LightBlue}> + {shortenPath(config.getTargetDir(), 70)} + </Text> + </Box> </Box> - )} - </> - )} - </> - )} - {initError && streamingState !== StreamingState.Responding && ( - <Box - borderStyle="round" - borderColor={Colors.AccentRed} - paddingX={1} - marginBottom={1} - > - {history.find( - (item) => item.type === 'error' && item.text?.includes(initError), - )?.text ? ( - <Text color={Colors.AccentRed}> - { - history.find( - (item) => - item.type === 'error' && item.text?.includes(initError), - )?.text - } - </Text> - ) : ( - <> - <Text color={Colors.AccentRed}> - Initialization Error: {initError} - </Text> + <InputPrompt + query={query} + onChange={setQuery} + onChangeAndMoveCursor={onChangeAndMoveCursor} + editorState={editorState} + onSubmit={handleFinalSubmit} // Pass handleFinalSubmit directly + showSuggestions={completion.showSuggestions} + suggestions={completion.suggestions} + activeSuggestionIndex={completion.activeSuggestionIndex} + userMessages={userMessages} // Pass userMessages + navigateSuggestionUp={completion.navigateUp} + navigateSuggestionDown={completion.navigateDown} + resetCompletion={completion.resetCompletionState} + setEditorState={setEditorState} + onClearScreen={handleClearScreen} // Added onClearScreen prop + /> + {completion.showSuggestions && ( + <Box> + <SuggestionsDisplay + suggestions={completion.suggestions} + activeIndex={completion.activeSuggestionIndex} + isLoading={completion.isLoadingSuggestions} + width={suggestionsWidth} + scrollOffset={completion.visibleStartIndex} + userInput={query} + /> + </Box> + )} + </> + )} + </> + )} + + {initError && streamingState !== StreamingState.Responding && ( + <Box + borderStyle="round" + borderColor={Colors.AccentRed} + paddingX={1} + marginBottom={1} + > + {history.find( + (item) => item.type === 'error' && item.text?.includes(initError), + )?.text ? ( <Text color={Colors.AccentRed}> - {' '} - Please check API key and configuration. + { + history.find( + (item) => + item.type === 'error' && item.text?.includes(initError), + )?.text + } </Text> - </> - )} - </Box> - )} + ) : ( + <> + <Text color={Colors.AccentRed}> + Initialization Error: {initError} + </Text> + <Text color={Colors.AccentRed}> + {' '} + Please check API key and configuration. + </Text> + </> + )} + </Box> + )} - <Footer - config={config} - debugMode={config.getDebugMode()} - debugMessage={debugMessage} - cliVersion={cliVersion} - geminiMdFileCount={geminiMdFileCount} - /> - <ConsoleOutput /> + <Footer + config={config} + debugMode={config.getDebugMode()} + debugMessage={debugMessage} + cliVersion={cliVersion} + geminiMdFileCount={geminiMdFileCount} + /> + <ConsoleOutput /> + </Box> </Box> ); }; |
