Skip to content

Commit 001089b

Browse files
committed
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 vadimdemedes/ink#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
1 parent 25adc15 commit 001089b

File tree

4 files changed

+229
-140
lines changed

4 files changed

+229
-140
lines changed

packages/cli/src/ui/App.tsx

Lines changed: 175 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { useCallback, useEffect, useMemo, useState } from 'react';
8-
import { Box, Static, Text, useStdout } from 'ink';
7+
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
8+
import { Box, DOMElement, measureElement, Static, Text, useStdout } from 'ink';
99
import { StreamingState, type HistoryItem } from './types.js';
1010
import { useGeminiStream } from './hooks/useGeminiStream.js';
1111
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
@@ -46,6 +46,7 @@ export const App = ({
4646
startupWarnings = [],
4747
}: AppProps) => {
4848
const { history, addItem, clearItems } = useHistory();
49+
const [staticNeedsRefresh, setStaticNeedsRefresh] = useState(false);
4950
const [staticKey, setStaticKey] = useState(0);
5051
const refreshStatic = useCallback(() => {
5152
setStaticKey((prev) => prev + 1);
@@ -55,7 +56,8 @@ export const App = ({
5556
const [debugMessage, setDebugMessage] = useState<string>('');
5657
const [showHelp, setShowHelp] = useState<boolean>(false);
5758
const [themeError, setThemeError] = useState<string | null>(null);
58-
59+
const [availableTerminalHeight, setAvailableTerminalHeight] =
60+
useState<number>(0);
5961
const {
6062
isThemeDialogOpen,
6163
openThemeDialog,
@@ -193,12 +195,51 @@ export const App = ({
193195

194196
// --- Render Logic ---
195197

196-
// Get terminal width
198+
// Get terminal dimensions
199+
197200
const { stdout } = useStdout();
198201
const terminalWidth = stdout?.columns ?? 80;
202+
const terminalHeight = stdout?.rows ?? 24;
203+
const footerRef = useRef<DOMElement>(null);
204+
const pendingHistoryItemRef = useRef<DOMElement>(null);
205+
199206
// Calculate width for suggestions, leave some padding
200207
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
201208

209+
useEffect(() => {
210+
const staticExtraHeight = /* margins and padding */ 3;
211+
const fullFooterMeasurement = measureElement(footerRef.current!);
212+
const fullFooterHeight = fullFooterMeasurement.height;
213+
214+
setAvailableTerminalHeight(
215+
terminalHeight - fullFooterHeight - staticExtraHeight,
216+
);
217+
}, [terminalHeight]);
218+
219+
useEffect(() => {
220+
if (!pendingHistoryItem) {
221+
return;
222+
}
223+
224+
const pendingItemDimensions = measureElement(
225+
pendingHistoryItemRef.current!,
226+
);
227+
228+
// If our pending history item happens to exceed the terminal height we will most likely need to refresh
229+
// our static collection to ensure no duplication or tearing. This is currently working around a core bug
230+
// in Ink which we have a PR out to fix: https://github.com/vadimdemedes/ink/pull/717
231+
if (pendingItemDimensions.height > availableTerminalHeight) {
232+
setStaticNeedsRefresh(true);
233+
}
234+
}, [pendingHistoryItem, availableTerminalHeight, streamingState]);
235+
236+
useEffect(() => {
237+
if (streamingState === StreamingState.Idle && staticNeedsRefresh) {
238+
setStaticNeedsRefresh(false);
239+
refreshStatic();
240+
}
241+
}, [streamingState, refreshStatic, staticNeedsRefresh]);
242+
202243
return (
203244
<Box flexDirection="column" marginBottom={1} width="90%">
204245
{/*
@@ -219,146 +260,151 @@ export const App = ({
219260
<Header />
220261
<Tips />
221262
</Box>,
222-
...history.map((h) => <HistoryItemDisplay key={h.id} item={h} />),
263+
...history.map((h) => <HistoryItemDisplay availableTerminalHeight={availableTerminalHeight} key={h.id} item={h} />),
223264
]}
224265
>
225266
{(item) => item}
226267
</Static>
227268
{pendingHistoryItem && (
228-
<HistoryItemDisplay
229-
// TODO(taehykim): It seems like references to ids aren't necessary in
230-
// HistoryItemDisplay. Refactor later. Use a fake id for now.
231-
item={{ ...pendingHistoryItem, id: 0 }}
232-
/>
269+
<Box ref={pendingHistoryItemRef}>
270+
<HistoryItemDisplay
271+
availableTerminalHeight={availableTerminalHeight}
272+
// TODO(taehykim): It seems like references to ids aren't necessary in
273+
// HistoryItemDisplay. Refactor later. Use a fake id for now.
274+
item={{ ...pendingHistoryItem, id: 0 }}
275+
/>
276+
</Box>
233277
)}
234278
{showHelp && <Help commands={slashCommands} />}
235279

236-
{startupWarnings.length > 0 && (
237-
<Box
238-
borderStyle="round"
239-
borderColor={Colors.AccentYellow}
240-
paddingX={1}
241-
marginY={1}
242-
flexDirection="column"
243-
>
244-
{startupWarnings.map((warning, index) => (
245-
<Text key={index} color={Colors.AccentYellow}>
246-
{warning}
247-
</Text>
248-
))}
249-
</Box>
250-
)}
280+
<Box flexDirection="column" ref={footerRef}>
281+
{startupWarnings.length > 0 && (
282+
<Box
283+
borderStyle="round"
284+
borderColor={Colors.AccentYellow}
285+
paddingX={1}
286+
marginY={1}
287+
flexDirection="column"
288+
>
289+
{startupWarnings.map((warning, index) => (
290+
<Text key={index} color={Colors.AccentYellow}>
291+
{warning}
292+
</Text>
293+
))}
294+
</Box>
295+
)}
251296

252-
{isThemeDialogOpen ? (
253-
<Box flexDirection="column">
254-
{themeError && (
255-
<Box marginBottom={1}>
256-
<Text color={Colors.AccentRed}>{themeError}</Text>
257-
</Box>
258-
)}
259-
<ThemeDialog
260-
onSelect={handleThemeSelect}
261-
onHighlight={handleThemeHighlight}
262-
settings={settings}
263-
setQuery={setQuery}
264-
/>
265-
</Box>
266-
) : (
267-
<>
268-
<LoadingIndicator
269-
isLoading={streamingState === StreamingState.Responding}
270-
currentLoadingPhrase={currentLoadingPhrase}
271-
elapsedTime={elapsedTime}
272-
/>
273-
{isInputActive && (
274-
<>
275-
<Box
276-
marginTop={1}
277-
display="flex"
278-
justifyContent="space-between"
279-
width="100%"
280-
>
281-
<Box>
282-
<Text color={Colors.SubtleComment}>cwd: </Text>
283-
<Text color={Colors.LightBlue}>
284-
{shortenPath(config.getTargetDir(), 70)}
285-
</Text>
286-
</Box>
297+
{isThemeDialogOpen ? (
298+
<Box flexDirection="column">
299+
{themeError && (
300+
<Box marginBottom={1}>
301+
<Text color={Colors.AccentRed}>{themeError}</Text>
287302
</Box>
288-
289-
<InputPrompt
290-
query={query}
291-
onChange={setQuery}
292-
onChangeAndMoveCursor={onChangeAndMoveCursor}
293-
editorState={editorState}
294-
onSubmit={handleFinalSubmit} // Pass handleFinalSubmit directly
295-
showSuggestions={completion.showSuggestions}
296-
suggestions={completion.suggestions}
297-
activeSuggestionIndex={completion.activeSuggestionIndex}
298-
userMessages={userMessages} // Pass userMessages
299-
navigateSuggestionUp={completion.navigateUp}
300-
navigateSuggestionDown={completion.navigateDown}
301-
resetCompletion={completion.resetCompletionState}
302-
setEditorState={setEditorState}
303-
onClearScreen={handleClearScreen} // Added onClearScreen prop
304-
/>
305-
{completion.showSuggestions && (
306-
<Box>
307-
<SuggestionsDisplay
308-
suggestions={completion.suggestions}
309-
activeIndex={completion.activeSuggestionIndex}
310-
isLoading={completion.isLoadingSuggestions}
311-
width={suggestionsWidth}
312-
scrollOffset={completion.visibleStartIndex}
313-
userInput={query}
314-
/>
303+
)}
304+
<ThemeDialog
305+
onSelect={handleThemeSelect}
306+
onHighlight={handleThemeHighlight}
307+
settings={settings}
308+
setQuery={setQuery}
309+
/>
310+
</Box>
311+
) : (
312+
<>
313+
<LoadingIndicator
314+
isLoading={streamingState === StreamingState.Responding}
315+
currentLoadingPhrase={currentLoadingPhrase}
316+
elapsedTime={elapsedTime}
317+
/>
318+
{isInputActive && (
319+
<>
320+
<Box
321+
marginTop={1}
322+
display="flex"
323+
justifyContent="space-between"
324+
width="100%"
325+
>
326+
<Box>
327+
<Text color={Colors.SubtleComment}>cwd: </Text>
328+
<Text color={Colors.LightBlue}>
329+
{shortenPath(config.getTargetDir(), 70)}
330+
</Text>
331+
</Box>
315332
</Box>
316-
)}
317-
</>
318-
)}
319-
</>
320-
)}
321333

322-
{initError && streamingState !== StreamingState.Responding && (
323-
<Box
324-
borderStyle="round"
325-
borderColor={Colors.AccentRed}
326-
paddingX={1}
327-
marginBottom={1}
328-
>
329-
{history.find(
330-
(item) => item.type === 'error' && item.text?.includes(initError),
331-
)?.text ? (
332-
<Text color={Colors.AccentRed}>
333-
{
334-
history.find(
335-
(item) =>
336-
item.type === 'error' && item.text?.includes(initError),
337-
)?.text
338-
}
339-
</Text>
340-
) : (
341-
<>
342-
<Text color={Colors.AccentRed}>
343-
Initialization Error: {initError}
344-
</Text>
334+
<InputPrompt
335+
query={query}
336+
onChange={setQuery}
337+
onChangeAndMoveCursor={onChangeAndMoveCursor}
338+
editorState={editorState}
339+
onSubmit={handleFinalSubmit} // Pass handleFinalSubmit directly
340+
showSuggestions={completion.showSuggestions}
341+
suggestions={completion.suggestions}
342+
activeSuggestionIndex={completion.activeSuggestionIndex}
343+
userMessages={userMessages} // Pass userMessages
344+
navigateSuggestionUp={completion.navigateUp}
345+
navigateSuggestionDown={completion.navigateDown}
346+
resetCompletion={completion.resetCompletionState}
347+
setEditorState={setEditorState}
348+
onClearScreen={handleClearScreen} // Added onClearScreen prop
349+
/>
350+
{completion.showSuggestions && (
351+
<Box>
352+
<SuggestionsDisplay
353+
suggestions={completion.suggestions}
354+
activeIndex={completion.activeSuggestionIndex}
355+
isLoading={completion.isLoadingSuggestions}
356+
width={suggestionsWidth}
357+
scrollOffset={completion.visibleStartIndex}
358+
userInput={query}
359+
/>
360+
</Box>
361+
)}
362+
</>
363+
)}
364+
</>
365+
)}
366+
367+
{initError && streamingState !== StreamingState.Responding && (
368+
<Box
369+
borderStyle="round"
370+
borderColor={Colors.AccentRed}
371+
paddingX={1}
372+
marginBottom={1}
373+
>
374+
{history.find(
375+
(item) => item.type === 'error' && item.text?.includes(initError),
376+
)?.text ? (
345377
<Text color={Colors.AccentRed}>
346-
{' '}
347-
Please check API key and configuration.
378+
{
379+
history.find(
380+
(item) =>
381+
item.type === 'error' && item.text?.includes(initError),
382+
)?.text
383+
}
348384
</Text>
349-
</>
350-
)}
351-
</Box>
352-
)}
385+
) : (
386+
<>
387+
<Text color={Colors.AccentRed}>
388+
Initialization Error: {initError}
389+
</Text>
390+
<Text color={Colors.AccentRed}>
391+
{' '}
392+
Please check API key and configuration.
393+
</Text>
394+
</>
395+
)}
396+
</Box>
397+
)}
353398

354-
<Footer
355-
config={config}
356-
debugMode={config.getDebugMode()}
357-
debugMessage={debugMessage}
358-
cliVersion={cliVersion}
359-
geminiMdFileCount={geminiMdFileCount}
360-
/>
361-
<ConsoleOutput />
399+
<Footer
400+
config={config}
401+
debugMode={config.getDebugMode()}
402+
debugMessage={debugMessage}
403+
cliVersion={cliVersion}
404+
geminiMdFileCount={geminiMdFileCount}
405+
/>
406+
<ConsoleOutput />
407+
</Box>
362408
</Box>
363409
);
364410
};

packages/cli/src/ui/components/HistoryItemDisplay.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ import { Box } from 'ink';
1616

1717
interface HistoryItemDisplayProps {
1818
item: HistoryItem;
19+
availableTerminalHeight: number;
1920
}
2021

2122
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
2223
item,
24+
availableTerminalHeight,
2325
}) => (
2426
<Box flexDirection="column" key={item.id}>
2527
{/* Render standard message types */}
@@ -31,7 +33,11 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
3133
{item.type === 'info' && <InfoMessage text={item.text} />}
3234
{item.type === 'error' && <ErrorMessage text={item.text} />}
3335
{item.type === 'tool_group' && (
34-
<ToolGroupMessage toolCalls={item.tools} groupId={item.id} />
36+
<ToolGroupMessage
37+
toolCalls={item.tools}
38+
groupId={item.id}
39+
availableTerminalHeight={availableTerminalHeight}
40+
/>
3541
)}
3642
</Box>
3743
);

0 commit comments

Comments
 (0)