diff options
Diffstat (limited to 'packages/cli/src/ui/components')
10 files changed, 193 insertions, 53 deletions
diff --git a/packages/cli/src/ui/components/ConsolePatcher.tsx b/packages/cli/src/ui/components/ConsolePatcher.tsx new file mode 100644 index 00000000..7070fbe4 --- /dev/null +++ b/packages/cli/src/ui/components/ConsolePatcher.tsx @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, Key } from 'react'; +import { Box, Text } from 'ink'; +import util from 'util'; + +interface ConsoleMessage { + id: Key; + type: 'log' | 'warn' | 'error'; + content: string; +} + +// Using a module-level counter for unique IDs. +// This ensures IDs are unique across messages. +let messageIdCounter = 0; + +export const ConsoleOutput: React.FC = () => { + const [messages, setMessages] = useState<ConsoleMessage[]>([]); + + useEffect(() => { + const originalConsoleLog = console.log; + const originalConsoleWarn = console.warn; + const originalConsoleError = console.error; + + const formatArgs = (args: unknown[]): string => util.format(...args); + const addMessage = (type: 'log' | 'warn' | 'error', args: unknown[]) => { + setMessages((prevMessages) => [ + ...prevMessages, + { + id: `console-msg-${messageIdCounter++}`, + type, + content: formatArgs(args), + }, + ]); + }; + + // It's patching time + console.log = (...args: unknown[]) => addMessage('log', args); + console.warn = (...args: unknown[]) => addMessage('warn', args); + console.error = (...args: unknown[]) => addMessage('error', args); + + return () => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }; + }, []); + + return ( + <Box flexDirection="column"> + {messages.map((msg) => { + const textProps: { color?: string } = {}; + let prefix = ''; + + switch (msg.type) { + case 'warn': + textProps.color = 'yellow'; + prefix = 'WARN: '; + break; + case 'error': + textProps.color = 'red'; + prefix = 'ERROR: '; + break; + case 'log': + default: + prefix = 'LOG: '; + break; + } + + return ( + <Box key={msg.id}> + <Text {...textProps}> + {prefix} + {msg.content} + </Text> + </Box> + ); + })} + </Box> + ); +}; diff --git a/packages/cli/src/ui/components/HistoryDisplay.tsx b/packages/cli/src/ui/components/HistoryDisplay.tsx deleted file mode 100644 index 914b84c2..00000000 --- a/packages/cli/src/ui/components/HistoryDisplay.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { Box } from 'ink'; -import type { HistoryItem } from '../types.js'; -import { UserMessage } from './messages/UserMessage.js'; -import { GeminiMessage } from './messages/GeminiMessage.js'; -import { InfoMessage } from './messages/InfoMessage.js'; -import { ErrorMessage } from './messages/ErrorMessage.js'; -import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; -import { PartListUnion } from '@google/genai'; - -interface HistoryDisplayProps { - history: HistoryItem[]; - onSubmit: (value: PartListUnion) => void; -} - -export const HistoryDisplay: React.FC<HistoryDisplayProps> = ({ - history, - onSubmit, -}) => ( - // No grouping logic needed here anymore - <Box flexDirection="column"> - {history.map((item) => ( - <Box key={item.id} marginBottom={1}> - {/* Render standard message types */} - {item.type === 'user' && <UserMessage text={item.text} />} - {item.type === 'gemini' && <GeminiMessage text={item.text} />} - {item.type === 'info' && <InfoMessage text={item.text} />} - {item.type === 'error' && <ErrorMessage text={item.text} />} - - {/* Render the tool group component */} - {item.type === 'tool_group' && ( - <ToolGroupMessage toolCalls={item.tools} onSubmit={onSubmit} /> - )} - </Box> - ))} - </Box> -); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx new file mode 100644 index 00000000..5fb9b32f --- /dev/null +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import type { HistoryItem } from '../types.js'; +import { UserMessage } from './messages/UserMessage.js'; +import { GeminiMessage } from './messages/GeminiMessage.js'; +import { InfoMessage } from './messages/InfoMessage.js'; +import { ErrorMessage } from './messages/ErrorMessage.js'; +import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; +import { PartListUnion } from '@google/genai'; +import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; +import { Box } from 'ink'; + +interface HistoryItemDisplayProps { + item: HistoryItem; + onSubmit: (value: PartListUnion) => void; +} + +export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ + item, + onSubmit, +}) => ( + <Box flexDirection="column" key={item.id}> + {/* Render standard message types */} + {item.type === 'user' && <UserMessage text={item.text} />} + {item.type === 'gemini' && <GeminiMessage text={item.text} />} + {item.type === 'gemini_content' && ( + <GeminiMessageContent text={item.text} /> + )} + {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} + onSubmit={onSubmit} + /> + )} + </Box> +); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b5c3cc7d..153b4701 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -14,7 +14,12 @@ interface InputPromptProps { } export const InputPrompt: React.FC<InputPromptProps> = ({ onSubmit }) => { - const [value, setValue] = React.useState(''); + const [value, setValue] = React.useState( + "I'd like to update my web fetch tool to be a little smarter about the content it fetches from web pages. Instead of returning the entire HTML to the LLM I was extract the body text and other important information to reduce the amount of tokens we need to use.", + ); + // const [value, setValue] = React.useState('Add "Hello World" to the top of README.md'); + // const [value, setValue] = React.useState('show me "Hello World" in as many langauges as you can think of'); + const { isFocused } = useFocus({ autoFocus: true }); useInput( diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index eb3133c3..4d196e6d 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../../colors.js'; +import crypto from 'crypto'; interface DiffLine { type: 'add' | 'del' | 'context' | 'hunk' | 'other'; @@ -94,6 +95,7 @@ const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization export const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, + filename, tabWidth = DEFAULT_TAB_WIDTH, }) => { if (!diffContent || typeof diffContent !== 'string') { @@ -137,8 +139,11 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({ } // --- End Modification --- + const key = filename + ? `diff-box-${filename}` + : `diff-box-${crypto.createHash('sha1').update(diffContent).digest('hex')}`; return ( - <Box flexDirection="column"> + <Box flexDirection="column" key={key}> {/* Iterate over the lines that should be displayed (already normalized) */} {displayableLines.map((line, index) => { const key = `diff-line-${index}`; diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.tsx index 22d82465..edbea435 100644 --- a/packages/cli/src/ui/components/messages/ErrorMessage.tsx +++ b/packages/cli/src/ui/components/messages/ErrorMessage.tsx @@ -17,7 +17,7 @@ export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => { const prefixWidth = prefix.length; return ( - <Box flexDirection="row"> + <Box flexDirection="row" marginBottom={1}> <Box width={prefixWidth}> <Text color={Colors.AccentRed}>{prefix}</Text> </Box> diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx new file mode 100644 index 00000000..fb025231 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box } from 'ink'; +import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; + +interface GeminiMessageContentProps { + text: string; +} + +/* + * Gemini message content is a semi-hacked component. The intention is to represent a partial + * of GeminiMessage and is only used when a response gets too long. In that instance messages + * are split into multiple GeminiMessageContent's to enable the root <Static> component in + * App.tsx to be as performant as humanly possible. + */ +export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({ + text, +}) => { + const originalPrefix = '✦ '; + const prefixWidth = originalPrefix.length; + const renderedBlocks = MarkdownRenderer.render(text); + + return ( + <Box flexDirection="column" paddingLeft={prefixWidth}> + {renderedBlocks} + </Box> + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 748c7d1c..7099537e 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { Box, Text, useInput } from 'ink'; import { PartListUnion } from '@google/genai'; import { DiffRenderer } from './DiffRenderer.js'; -import { UI_WIDTH } from '../../constants.js'; import { Colors } from '../../colors.js'; import { ToolCallConfirmationDetails, @@ -88,7 +87,7 @@ export const ToolConfirmationMessage: React.FC< value: ToolConfirmationOutcome.ProceedOnce, }, { - label: `Yes, allow always for ${executionProps.rootCommand} ...`, + label: `Yes, allow always "${executionProps.rootCommand} ..."`, value: ToolConfirmationOutcome.ProceedAlways, }, { label: 'No (esc)', value: ToolConfirmationOutcome.Cancel }, @@ -96,7 +95,7 @@ export const ToolConfirmationMessage: React.FC< } return ( - <Box flexDirection="column" padding={1} minWidth={UI_WIDTH}> + <Box flexDirection="column" padding={1} minWidth="90%"> {/* Body Content (Diff Renderer or Command Info) */} {/* No separate context display here anymore for edits */} <Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}> diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 0675411f..2d4982c2 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -13,12 +13,14 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { Colors } from '../../colors.js'; interface ToolGroupMessageProps { + groupId: number; toolCalls: IndividualToolCallDisplay[]; onSubmit: (value: PartListUnion) => void; } // Main component renders the border and maps the tools using ToolMessage export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ + groupId, toolCalls, onSubmit, }) => { @@ -29,13 +31,23 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ return ( <Box + key={groupId} flexDirection="column" borderStyle="round" + /* + This width constraint is highly important and protects us from an Ink rendering bug. + Since the ToolGroup can typically change rendering states frequently, it can cause + Ink to render the border of the box incorrectly and span multiple lines and even + cause tearing. + */ + width="100%" + marginLeft={1} borderDimColor={hasPending} borderColor={borderColor} + marginBottom={1} > {toolCalls.map((tool) => ( - <React.Fragment key={tool.callId}> + <Box key={groupId + '-' + tool.callId} flexDirection="column"> <ToolMessage key={tool.callId} // Use callId as the key callId={tool.callId} // Pass callId @@ -52,7 +64,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ onSubmit={onSubmit} ></ToolConfirmationMessage> )} - </React.Fragment> + </Box> ))} {/* Optional: Add padding below the last item if needed, though ToolMessage already has some vertical space implicitly */} diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index ab590f53..4d5fca37 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -54,8 +54,8 @@ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({ </Box> </Box> {hasResult && ( - <Box paddingLeft={statusIndicatorWidth}> - <Box flexShrink={1} flexDirection="row"> + <Box paddingLeft={statusIndicatorWidth} width="100%"> + <Box flexDirection="row"> {/* Use default text color (white) or gray instead of dimColor */} {typeof resultDisplay === 'string' && ( <Box flexDirection="column"> |
