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
This commit is contained in:
parent
601a61ed31
commit
33743d347b
|
@ -4,8 +4,8 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { Box, Static, Text, useStdout } from 'ink';
|
import { Box, DOMElement, measureElement, Static, Text, useStdout } from 'ink';
|
||||||
import { StreamingState, type HistoryItem } from './types.js';
|
import { StreamingState, type HistoryItem } from './types.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||||
|
@ -46,6 +46,7 @@ export const App = ({
|
||||||
startupWarnings = [],
|
startupWarnings = [],
|
||||||
}: AppProps) => {
|
}: AppProps) => {
|
||||||
const { history, addItem, clearItems } = useHistory();
|
const { history, addItem, clearItems } = useHistory();
|
||||||
|
const [staticNeedsRefresh, setStaticNeedsRefresh] = useState(false);
|
||||||
const [staticKey, setStaticKey] = useState(0);
|
const [staticKey, setStaticKey] = useState(0);
|
||||||
const refreshStatic = useCallback(() => {
|
const refreshStatic = useCallback(() => {
|
||||||
setStaticKey((prev) => prev + 1);
|
setStaticKey((prev) => prev + 1);
|
||||||
|
@ -55,7 +56,8 @@ export const App = ({
|
||||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||||
const [themeError, setThemeError] = useState<string | null>(null);
|
const [themeError, setThemeError] = useState<string | null>(null);
|
||||||
|
const [availableTerminalHeight, setAvailableTerminalHeight] =
|
||||||
|
useState<number>(0);
|
||||||
const {
|
const {
|
||||||
isThemeDialogOpen,
|
isThemeDialogOpen,
|
||||||
openThemeDialog,
|
openThemeDialog,
|
||||||
|
@ -193,12 +195,51 @@ export const App = ({
|
||||||
|
|
||||||
// --- Render Logic ---
|
// --- Render Logic ---
|
||||||
|
|
||||||
// Get terminal width
|
// Get terminal dimensions
|
||||||
|
|
||||||
const { stdout } = useStdout();
|
const { stdout } = useStdout();
|
||||||
const terminalWidth = stdout?.columns ?? 80;
|
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
|
// Calculate width for suggestions, leave some padding
|
||||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
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 (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1} width="90%">
|
<Box flexDirection="column" marginBottom={1} width="90%">
|
||||||
{/*
|
{/*
|
||||||
|
@ -219,20 +260,24 @@ export const App = ({
|
||||||
<Header />
|
<Header />
|
||||||
<Tips />
|
<Tips />
|
||||||
</Box>,
|
</Box>,
|
||||||
...history.map((h) => <HistoryItemDisplay key={h.id} item={h} />),
|
...history.map((h) => <HistoryItemDisplay availableTerminalHeight={availableTerminalHeight} key={h.id} item={h} />),
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{(item) => item}
|
{(item) => item}
|
||||||
</Static>
|
</Static>
|
||||||
{pendingHistoryItem && (
|
{pendingHistoryItem && (
|
||||||
|
<Box ref={pendingHistoryItemRef}>
|
||||||
<HistoryItemDisplay
|
<HistoryItemDisplay
|
||||||
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
// TODO(taehykim): It seems like references to ids aren't necessary in
|
// TODO(taehykim): It seems like references to ids aren't necessary in
|
||||||
// HistoryItemDisplay. Refactor later. Use a fake id for now.
|
// HistoryItemDisplay. Refactor later. Use a fake id for now.
|
||||||
item={{ ...pendingHistoryItem, id: 0 }}
|
item={{ ...pendingHistoryItem, id: 0 }}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
{showHelp && <Help commands={slashCommands} />}
|
{showHelp && <Help commands={slashCommands} />}
|
||||||
|
|
||||||
|
<Box flexDirection="column" ref={footerRef}>
|
||||||
{startupWarnings.length > 0 && (
|
{startupWarnings.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
|
@ -360,5 +405,6 @@ export const App = ({
|
||||||
/>
|
/>
|
||||||
<ConsoleOutput />
|
<ConsoleOutput />
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -16,10 +16,12 @@ import { Box } from 'ink';
|
||||||
|
|
||||||
interface HistoryItemDisplayProps {
|
interface HistoryItemDisplayProps {
|
||||||
item: HistoryItem;
|
item: HistoryItem;
|
||||||
|
availableTerminalHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
item,
|
item,
|
||||||
|
availableTerminalHeight,
|
||||||
}) => (
|
}) => (
|
||||||
<Box flexDirection="column" key={item.id}>
|
<Box flexDirection="column" key={item.id}>
|
||||||
{/* Render standard message types */}
|
{/* Render standard message types */}
|
||||||
|
@ -31,7 +33,11 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
{item.type === 'info' && <InfoMessage text={item.text} />}
|
{item.type === 'info' && <InfoMessage text={item.text} />}
|
||||||
{item.type === 'error' && <ErrorMessage text={item.text} />}
|
{item.type === 'error' && <ErrorMessage text={item.text} />}
|
||||||
{item.type === 'tool_group' && (
|
{item.type === 'tool_group' && (
|
||||||
<ToolGroupMessage toolCalls={item.tools} groupId={item.id} />
|
<ToolGroupMessage
|
||||||
|
toolCalls={item.tools}
|
||||||
|
groupId={item.id}
|
||||||
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
@ -14,18 +14,23 @@ import { Colors } from '../../colors.js';
|
||||||
interface ToolGroupMessageProps {
|
interface ToolGroupMessageProps {
|
||||||
groupId: number;
|
groupId: number;
|
||||||
toolCalls: IndividualToolCallDisplay[];
|
toolCalls: IndividualToolCallDisplay[];
|
||||||
|
availableTerminalHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main component renders the border and maps the tools using ToolMessage
|
// Main component renders the border and maps the tools using ToolMessage
|
||||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
groupId,
|
groupId,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
|
availableTerminalHeight,
|
||||||
}) => {
|
}) => {
|
||||||
const hasPending = !toolCalls.every(
|
const hasPending = !toolCalls.every(
|
||||||
(t) => t.status === ToolCallStatus.Success,
|
(t) => t.status === ToolCallStatus.Success,
|
||||||
);
|
);
|
||||||
const borderColor = hasPending ? Colors.AccentYellow : Colors.SubtleComment;
|
const borderColor = hasPending ? Colors.AccentYellow : Colors.SubtleComment;
|
||||||
|
|
||||||
|
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
|
||||||
|
availableTerminalHeight -= staticHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={groupId}
|
key={groupId}
|
||||||
|
@ -46,13 +51,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
{toolCalls.map((tool) => (
|
{toolCalls.map((tool) => (
|
||||||
<Box key={groupId + '-' + tool.callId} flexDirection="column">
|
<Box key={groupId + '-' + tool.callId} flexDirection="column">
|
||||||
<ToolMessage
|
<ToolMessage
|
||||||
key={tool.callId} // Use callId as the key
|
key={tool.callId}
|
||||||
callId={tool.callId} // Pass callId
|
callId={tool.callId}
|
||||||
name={tool.name}
|
name={tool.name}
|
||||||
description={tool.description}
|
description={tool.description}
|
||||||
resultDisplay={tool.resultDisplay}
|
resultDisplay={tool.resultDisplay}
|
||||||
status={tool.status}
|
status={tool.status}
|
||||||
confirmationDetails={tool.confirmationDetails} // Pass confirmationDetails
|
confirmationDetails={tool.confirmationDetails}
|
||||||
|
availableTerminalHeight={availableTerminalHeight}
|
||||||
/>
|
/>
|
||||||
{tool.status === ToolCallStatus.Confirming &&
|
{tool.status === ToolCallStatus.Confirming &&
|
||||||
tool.confirmationDetails && (
|
tool.confirmationDetails && (
|
||||||
|
|
|
@ -12,14 +12,38 @@ import { DiffRenderer } from './DiffRenderer.js';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
|
|
||||||
export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
|
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||||
|
availableTerminalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
resultDisplay,
|
resultDisplay,
|
||||||
status,
|
status,
|
||||||
|
availableTerminalHeight,
|
||||||
}) => {
|
}) => {
|
||||||
const statusIndicatorWidth = 3;
|
const statusIndicatorWidth = 3;
|
||||||
const hasResult = resultDisplay && resultDisplay.toString().trim().length > 0;
|
const hasResult = resultDisplay && resultDisplay.toString().trim().length > 0;
|
||||||
|
const staticHeight = /* Header */ 1;
|
||||||
|
availableTerminalHeight -= staticHeight;
|
||||||
|
|
||||||
|
let displayableResult = resultDisplay;
|
||||||
|
let hiddenLines = 0;
|
||||||
|
|
||||||
|
// Truncate the overall string content if it's too long.
|
||||||
|
// MarkdownRenderer will handle specific truncation for code blocks within this content.
|
||||||
|
if (typeof resultDisplay === 'string' && resultDisplay.length > 0) {
|
||||||
|
const lines = resultDisplay.split('\n');
|
||||||
|
// Estimate available height for this specific tool message content area
|
||||||
|
// This is a rough estimate; ideally, we'd have a more precise measurement.
|
||||||
|
const contentHeightEstimate = availableTerminalHeight - 5; // Subtracting lines for tool name, status, padding etc.
|
||||||
|
if (lines.length > contentHeightEstimate && contentHeightEstimate > 0) {
|
||||||
|
displayableResult = lines.slice(0, contentHeightEstimate).join('\n');
|
||||||
|
hiddenLines = lines.length - contentHeightEstimate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box paddingX={1} paddingY={0} flexDirection="column">
|
<Box paddingX={1} paddingY={0} flexDirection="column">
|
||||||
<Box minHeight={1}>
|
<Box minHeight={1}>
|
||||||
|
@ -56,15 +80,22 @@ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
|
||||||
</Box>
|
</Box>
|
||||||
{hasResult && (
|
{hasResult && (
|
||||||
<Box paddingLeft={statusIndicatorWidth} width="100%">
|
<Box paddingLeft={statusIndicatorWidth} width="100%">
|
||||||
<Box flexDirection="row">
|
|
||||||
{/* Use default text color (white) or gray instead of dimColor */}
|
|
||||||
{typeof resultDisplay === 'string' && (
|
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<MarkdownDisplay text={resultDisplay} />
|
{typeof displayableResult === 'string' && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<MarkdownDisplay text={displayableResult} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{typeof resultDisplay === 'object' && (
|
{typeof displayableResult === 'object' && (
|
||||||
<DiffRenderer diffContent={resultDisplay.fileDiff} />
|
<DiffRenderer diffContent={displayableResult.fileDiff} />
|
||||||
|
)}
|
||||||
|
{hiddenLines > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text color={Colors.SubtleComment}>
|
||||||
|
... {hiddenLines} more line{hiddenLines === 1 ? '' : 's'}{' '}
|
||||||
|
hidden ...
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
Loading…
Reference in New Issue