diff options
| -rw-r--r-- | packages/cli/src/ui/App.tsx | 304 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/HistoryItemDisplay.tsx | 8 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/messages/ToolGroupMessage.tsx | 12 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/messages/ToolMessage.tsx | 45 |
4 files changed, 229 insertions, 140 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> ); }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 0c724feb..bd35d335 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -16,10 +16,12 @@ import { Box } from 'ink'; interface HistoryItemDisplayProps { item: HistoryItem; + availableTerminalHeight: number; } export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ item, + availableTerminalHeight, }) => ( <Box flexDirection="column" key={item.id}> {/* Render standard message types */} @@ -31,7 +33,11 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ {item.type === 'info' && <InfoMessage text={item.text} />} {item.type === 'error' && <ErrorMessage text={item.text} />} {item.type === 'tool_group' && ( - <ToolGroupMessage toolCalls={item.tools} groupId={item.id} /> + <ToolGroupMessage + toolCalls={item.tools} + groupId={item.id} + availableTerminalHeight={availableTerminalHeight} + /> )} </Box> ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 35408114..33460405 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -14,18 +14,23 @@ import { Colors } from '../../colors.js'; interface ToolGroupMessageProps { groupId: number; toolCalls: IndividualToolCallDisplay[]; + availableTerminalHeight: number; } // Main component renders the border and maps the tools using ToolMessage export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ groupId, toolCalls, + availableTerminalHeight, }) => { const hasPending = !toolCalls.every( (t) => t.status === ToolCallStatus.Success, ); const borderColor = hasPending ? Colors.AccentYellow : Colors.SubtleComment; + const staticHeight = /* border */ 2 + /* marginBottom */ 1; + availableTerminalHeight -= staticHeight; + return ( <Box key={groupId} @@ -46,13 +51,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ {toolCalls.map((tool) => ( <Box key={groupId + '-' + tool.callId} flexDirection="column"> <ToolMessage - key={tool.callId} // Use callId as the key - callId={tool.callId} // Pass callId + key={tool.callId} + callId={tool.callId} name={tool.name} description={tool.description} resultDisplay={tool.resultDisplay} status={tool.status} - confirmationDetails={tool.confirmationDetails} // Pass confirmationDetails + confirmationDetails={tool.confirmationDetails} + availableTerminalHeight={availableTerminalHeight} /> {tool.status === ToolCallStatus.Confirming && tool.confirmationDetails && ( diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 3b58c052..220578de 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -12,14 +12,38 @@ import { DiffRenderer } from './DiffRenderer.js'; import { Colors } from '../../colors.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, description, resultDisplay, status, + availableTerminalHeight, }) => { const statusIndicatorWidth = 3; 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 ( <Box paddingX={1} paddingY={0} flexDirection="column"> <Box minHeight={1}> @@ -56,15 +80,22 @@ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({ </Box> {hasResult && ( <Box paddingLeft={statusIndicatorWidth} width="100%"> - <Box flexDirection="row"> - {/* Use default text color (white) or gray instead of dimColor */} - {typeof resultDisplay === 'string' && ( + <Box flexDirection="column"> + {typeof displayableResult === 'string' && ( <Box flexDirection="column"> - <MarkdownDisplay text={resultDisplay} /> + <MarkdownDisplay text={displayableResult} /> </Box> )} - {typeof resultDisplay === 'object' && ( - <DiffRenderer diffContent={resultDisplay.fileDiff} /> + {typeof displayableResult === 'object' && ( + <DiffRenderer diffContent={displayableResult.fileDiff} /> + )} + {hiddenLines > 0 && ( + <Box> + <Text color={Colors.SubtleComment}> + ... {hiddenLines} more line{hiddenLines === 1 ? '' : 's'}{' '} + hidden ... + </Text> + </Box> )} </Box> </Box> |
