diff options
Diffstat (limited to 'packages/cli/src/ui')
20 files changed, 1038 insertions, 774 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 9a2ee49d..1bc0f6c6 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -13,78 +13,111 @@ import { StreamingState } from '../core/gemini-stream.js'; import { PartListUnion } from '@google/genai'; interface AppProps { - directory: string; + directory: string; } const App = ({ directory }: AppProps) => { - const [query, setQuery] = useState(''); - const [history, setHistory] = useState<HistoryItem[]>([]); - const { streamingState, submitQuery, initError } = useGeminiStream(setHistory); - const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(streamingState); + const [query, setQuery] = useState(''); + const [history, setHistory] = useState<HistoryItem[]>([]); + const { streamingState, submitQuery, initError } = + useGeminiStream(setHistory); + const { elapsedTime, currentLoadingPhrase } = + useLoadingIndicator(streamingState); - const handleInputSubmit = (value: PartListUnion) => { - submitQuery(value).then(() => { - setQuery(''); - }).catch(() => { - setQuery(''); - }); - }; + const handleInputSubmit = (value: PartListUnion) => { + submitQuery(value) + .then(() => { + setQuery(''); + }) + .catch(() => { + setQuery(''); + }); + }; - useEffect(() => { - if (initError && !history.some(item => item.type === 'error' && item.text?.includes(initError))) { - setHistory(prev => [ - ...prev, - { id: Date.now(), type: 'error', text: `Initialization Error: ${initError}. Please check API key and configuration.` } as HistoryItem - ]); - } - }, [initError, history]); + useEffect(() => { + if ( + initError && + !history.some( + (item) => item.type === 'error' && item.text?.includes(initError), + ) + ) { + setHistory((prev) => [ + ...prev, + { + id: Date.now(), + type: 'error', + text: `Initialization Error: ${initError}. Please check API key and configuration.`, + } as HistoryItem, + ]); + } + }, [initError, history]); - const isWaitingForToolConfirmation = history.some(item => - item.type === 'tool_group' && item.tools.some(tool => tool.confirmationDetails !== undefined) - ); - const isInputActive = streamingState === StreamingState.Idle && !initError; + const isWaitingForToolConfirmation = history.some( + (item) => + item.type === 'tool_group' && + item.tools.some((tool) => tool.confirmationDetails !== undefined), + ); + const isInputActive = streamingState === StreamingState.Idle && !initError; + return ( + <Box flexDirection="column" padding={1} marginBottom={1} width="100%"> + <Header cwd={directory} /> - return ( - <Box flexDirection="column" padding={1} marginBottom={1} width="100%"> - <Header cwd={directory} /> + <Tips /> - <Tips /> - - {initError && streamingState !== StreamingState.Responding && !isWaitingForToolConfirmation && ( - <Box borderStyle="round" borderColor="red" paddingX={1} marginBottom={1}> - {history.find(item => item.type === 'error' && item.text?.includes(initError))?.text ? ( - <Text color="red">{history.find(item => item.type === 'error' && item.text?.includes(initError))?.text}</Text> - ) : ( - <> - <Text color="red">Initialization Error: {initError}</Text> - <Text color="red"> Please check API key and configuration.</Text> - </> - )} - </Box> + {initError && + streamingState !== StreamingState.Responding && + !isWaitingForToolConfirmation && ( + <Box + borderStyle="round" + borderColor="red" + paddingX={1} + marginBottom={1} + > + {history.find( + (item) => item.type === 'error' && item.text?.includes(initError), + )?.text ? ( + <Text color="red"> + { + history.find( + (item) => + item.type === 'error' && item.text?.includes(initError), + )?.text + } + </Text> + ) : ( + <> + <Text color="red">Initialization Error: {initError}</Text> + <Text color="red"> + {' '} + Please check API key and configuration. + </Text> + </> )} + </Box> + )} - <Box flexDirection="column"> - <HistoryDisplay history={history} onSubmit={handleInputSubmit} /> - <LoadingIndicator - isLoading={streamingState === StreamingState.Responding} - currentLoadingPhrase={currentLoadingPhrase} - elapsedTime={elapsedTime} - /> - </Box> + <Box flexDirection="column"> + <HistoryDisplay history={history} onSubmit={handleInputSubmit} /> + <LoadingIndicator + isLoading={streamingState === StreamingState.Responding} + currentLoadingPhrase={currentLoadingPhrase} + elapsedTime={elapsedTime} + /> + </Box> - {!isWaitingForToolConfirmation && isInputActive && ( - <InputPrompt - query={query} - setQuery={setQuery} - onSubmit={handleInputSubmit} - isActive={isInputActive} - /> - )} + {!isWaitingForToolConfirmation && isInputActive && ( + <InputPrompt + query={query} + setQuery={setQuery} + onSubmit={handleInputSubmit} + isActive={isInputActive} + /> + )} - <Footer queryLength={query.length} /> - </Box> - ); + <Footer queryLength={query.length} /> + </Box> + ); }; -export default App;
\ No newline at end of file +export default App; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 06e6c681..215a4868 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -2,20 +2,18 @@ import React from 'react'; import { Box, Text } from 'ink'; interface FooterProps { - queryLength: number; + queryLength: number; } const Footer: React.FC<FooterProps> = ({ queryLength }) => { - return ( - <Box marginTop={1} justifyContent="space-between"> - <Box minWidth={15}> - <Text color="gray"> - {queryLength === 0 ? "? for shortcuts" : ""} - </Text> - </Box> - <Text color="blue">Gemini</Text> - </Box> - ); + return ( + <Box marginTop={1} justifyContent="space-between"> + <Box minWidth={15}> + <Text color="gray">{queryLength === 0 ? '? for shortcuts' : ''}</Text> + </Box> + <Text color="blue">Gemini</Text> + </Box> + ); }; -export default Footer;
\ No newline at end of file +export default Footer; diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index c5a99a30..37d42b57 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -4,35 +4,37 @@ import { UI_WIDTH, BOX_PADDING_X } from '../constants.js'; import { shortenPath } from '../../utils/paths.js'; interface HeaderProps { - cwd: string; + cwd: string; } const Header: React.FC<HeaderProps> = ({ cwd }) => { - return ( - <> - {/* Static Header Art */} - <Box marginBottom={1}> - <Text color="blue">{` + return ( + <> + {/* Static Header Art */} + <Box marginBottom={1}> + <Text color="blue">{` ______ ________ ____ ____ _____ ____ _____ _____ .' ___ ||_ __ ||_ \\ / _||_ _||_ \\|_ _||_ _| / .' \\_| | |_ \\_| | \\/ | | | | \\ | | | | | | ____ | _| _ | |\\ /| | | | | |\\ \\| | | | \\ \`.___] |_| |__/ | _| |_\\/_| |_ _| |_ _| |_\\ |_ _| |_ \`._____.'|________||_____||_____||_____||_____|\\____||_____|`}</Text> - </Box> - {/* CWD Display */} - <Box - borderStyle="round" - borderColor="blue" - paddingX={BOX_PADDING_X} - flexDirection="column" - marginBottom={1} - width={UI_WIDTH} - > - <Box paddingLeft={2}><Text color="gray">cwd: {shortenPath(cwd, /*maxLength*/ 70)}</Text></Box> - </Box> - </> - ); + </Box> + {/* CWD Display */} + <Box + borderStyle="round" + borderColor="blue" + paddingX={BOX_PADDING_X} + flexDirection="column" + marginBottom={1} + width={UI_WIDTH} + > + <Box paddingLeft={2}> + <Text color="gray">cwd: {shortenPath(cwd, /*maxLength*/ 70)}</Text> + </Box> + </Box> + </> + ); }; -export default Header;
\ No newline at end of file +export default Header; diff --git a/packages/cli/src/ui/components/HistoryDisplay.tsx b/packages/cli/src/ui/components/HistoryDisplay.tsx index bacbb258..285a6e30 100644 --- a/packages/cli/src/ui/components/HistoryDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryDisplay.tsx @@ -10,30 +10,33 @@ import ToolGroupMessage from './messages/ToolGroupMessage.js'; import { PartListUnion } from '@google/genai'; interface HistoryDisplayProps { - history: HistoryItem[]; - onSubmit: (value: PartListUnion) => void; + history: HistoryItem[]; + onSubmit: (value: PartListUnion) => void; } -const HistoryDisplay: React.FC<HistoryDisplayProps> = ({ history, onSubmit }) => { - // No grouping logic needed here anymore - return ( - <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} />} +const HistoryDisplay: React.FC<HistoryDisplayProps> = ({ + history, + onSubmit, +}) => { + // No grouping logic needed here anymore + return ( + <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> - ))} + {/* Render the tool group component */} + {item.type === 'tool_group' && ( + <ToolGroupMessage toolCalls={item.tools} onSubmit={onSubmit} /> + )} </Box> - ); + ))} + </Box> + ); }; -export default HistoryDisplay;
\ No newline at end of file +export default HistoryDisplay; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 92be10a4..b1832c04 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -3,37 +3,32 @@ import { Box, Text } from 'ink'; import TextInput from 'ink-text-input'; interface InputPromptProps { - query: string; - setQuery: (value: string) => void; - onSubmit: (value: string) => void; - isActive: boolean; + query: string; + setQuery: (value: string) => void; + onSubmit: (value: string) => void; + isActive: boolean; } const InputPrompt: React.FC<InputPromptProps> = ({ - query, - setQuery, - onSubmit, + query, + setQuery, + onSubmit, }) => { - return ( - <Box - marginTop={1} - borderStyle="round" - borderColor={'white'} - paddingX={1} - > - <Text color={'white'}>> </Text> - <Box flexGrow={1}> - <TextInput - value={query} - onChange={setQuery} - onSubmit={onSubmit} - showCursor={true} - focus={true} - placeholder={'Ask Gemini... (try "/init" or "/help")'} - /> - </Box> - </Box> - ); + return ( + <Box marginTop={1} borderStyle="round" borderColor={'white'} paddingX={1}> + <Text color={'white'}>> </Text> + <Box flexGrow={1}> + <TextInput + value={query} + onChange={setQuery} + onSubmit={onSubmit} + showCursor={true} + focus={true} + placeholder={'Ask Gemini... (try "/init" or "/help")'} + /> + </Box> + </Box> + ); }; -export default InputPrompt;
\ No newline at end of file +export default InputPrompt; diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 8a3f9b5e..442ddf26 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -3,30 +3,32 @@ import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; interface LoadingIndicatorProps { - isLoading: boolean; - currentLoadingPhrase: string; - elapsedTime: number; + isLoading: boolean; + currentLoadingPhrase: string; + elapsedTime: number; } const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ - isLoading, - currentLoadingPhrase, - elapsedTime, + isLoading, + currentLoadingPhrase, + elapsedTime, }) => { - if (!isLoading) { - return null; // Don't render anything if not loading - } + if (!isLoading) { + return null; // Don't render anything if not loading + } - return ( - <Box marginTop={1} paddingLeft={0}> - <Box marginRight={1}> - <Spinner type="dots" /> - </Box> - <Text color="cyan">{currentLoadingPhrase} ({elapsedTime}s)</Text> - <Box flexGrow={1}>{/* Spacer */}</Box> - <Text color="gray">(ESC to cancel)</Text> - </Box> - ); + return ( + <Box marginTop={1} paddingLeft={0}> + <Box marginRight={1}> + <Spinner type="dots" /> + </Box> + <Text color="cyan"> + {currentLoadingPhrase} ({elapsedTime}s) + </Text> + <Box flexGrow={1}>{/* Spacer */}</Box> + <Text color="gray">(ESC to cancel)</Text> + </Box> + ); }; -export default LoadingIndicator;
\ No newline at end of file +export default LoadingIndicator; diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx index 5dbe60b2..88a14407 100644 --- a/packages/cli/src/ui/components/Tips.tsx +++ b/packages/cli/src/ui/components/Tips.tsx @@ -3,15 +3,20 @@ import { Box, Text } from 'ink'; import { UI_WIDTH } from '../constants.js'; const Tips: React.FC = () => { - return ( - <Box flexDirection="column" marginBottom={1} width={UI_WIDTH}> - <Text>Tips for getting started:</Text> - <Text>1. <Text bold>/help</Text> for more information.</Text> - <Text>2. <Text bold>/init</Text> to create a GEMINI.md for instructions & context.</Text> - <Text>3. Ask coding questions, edit code or run commands.</Text> - <Text>4. Be specific for the best results.</Text> - </Box> - ); + return ( + <Box flexDirection="column" marginBottom={1} width={UI_WIDTH}> + <Text>Tips for getting started:</Text> + <Text> + 1. <Text bold>/help</Text> for more information. + </Text> + <Text> + 2. <Text bold>/init</Text> to create a GEMINI.md for instructions & + context. + </Text> + <Text>3. Ask coding questions, edit code or run commands.</Text> + <Text>4. Be specific for the best results.</Text> + </Box> + ); }; -export default Tips;
\ No newline at end of file +export default Tips; diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index 5cae9004..a45efe2a 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Text } from 'ink' +import { Box, Text } from 'ink'; interface DiffLine { type: 'add' | 'del' | 'context' | 'hunk' | 'other'; @@ -30,29 +30,53 @@ function parseDiffWithLineNumbers(diffContent: string): DiffLine[] { continue; } if (!inHunk) { - // Skip standard Git header lines more robustly - if (line.startsWith('--- ') || line.startsWith('+++ ') || line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('similarity index') || line.startsWith('rename from') || line.startsWith('rename to') || line.startsWith('new file mode') || line.startsWith('deleted file mode')) continue; + // Skip standard Git header lines more robustly + if ( + line.startsWith('--- ') || + line.startsWith('+++ ') || + line.startsWith('diff --git') || + line.startsWith('index ') || + line.startsWith('similarity index') || + line.startsWith('rename from') || + line.startsWith('rename to') || + line.startsWith('new file mode') || + line.startsWith('deleted file mode') + ) + continue; // If it's not a hunk or header, skip (or handle as 'other' if needed) continue; } if (line.startsWith('+')) { currentNewLine++; // Increment before pushing - result.push({ type: 'add', newLine: currentNewLine, content: line.substring(1) }); + result.push({ + type: 'add', + newLine: currentNewLine, + content: line.substring(1), + }); } else if (line.startsWith('-')) { currentOldLine++; // Increment before pushing - result.push({ type: 'del', oldLine: currentOldLine, content: line.substring(1) }); + result.push({ + type: 'del', + oldLine: currentOldLine, + content: line.substring(1), + }); } else if (line.startsWith(' ')) { currentOldLine++; // Increment before pushing currentNewLine++; - result.push({ type: 'context', oldLine: currentOldLine, newLine: currentNewLine, content: line.substring(1) }); - } else if (line.startsWith('\\')) { // Handle "\ No newline at end of file" + result.push({ + type: 'context', + oldLine: currentOldLine, + newLine: currentNewLine, + content: line.substring(1), + }); + } else if (line.startsWith('\\')) { + // Handle "\ No newline at end of file" result.push({ type: 'other', content: line }); } } return result; } - interface DiffRendererProps { diffContent: string; filename?: string; @@ -61,7 +85,10 @@ interface DiffRendererProps { const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization -const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEFAULT_TAB_WIDTH }) => { +const DiffRenderer: React.FC<DiffRendererProps> = ({ + diffContent, + tabWidth = DEFAULT_TAB_WIDTH, +}) => { if (!diffContent || typeof diffContent !== 'string') { return <Text color="yellow">No diff content.</Text>; } @@ -69,14 +96,15 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF const parsedLines = parseDiffWithLineNumbers(diffContent); // 1. Normalize whitespace (replace tabs with spaces) *before* further processing - const normalizedLines = parsedLines.map(line => ({ + const normalizedLines = parsedLines.map((line) => ({ ...line, - content: line.content.replace(/\t/g, ' '.repeat(tabWidth)) + content: line.content.replace(/\t/g, ' '.repeat(tabWidth)), })); // Filter out non-displayable lines (hunks, potentially 'other') using the normalized list - const displayableLines = normalizedLines.filter(l => l.type !== 'hunk' && l.type !== 'other'); - + const displayableLines = normalizedLines.filter( + (l) => l.type !== 'hunk' && l.type !== 'other', + ); if (displayableLines.length === 0) { return ( @@ -93,7 +121,7 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF if (line.content.trim() === '') continue; const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char - const currentIndent = (firstCharIndex === -1) ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found + const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found baseIndentation = Math.min(baseIndentation, currentIndent); } // If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0 @@ -102,7 +130,6 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF } // --- End Modification --- - return ( <Box borderStyle="round" borderColor="gray" flexDirection="column"> {/* Iterate over the lines that should be displayed (already normalized) */} @@ -139,9 +166,13 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF return ( // Using your original rendering structure <Box key={key} flexDirection="row"> - <Text color="gray">{gutterNumStr} </Text> - <Text color={color} dimColor={dim}>{prefixSymbol} </Text> - <Text color={color} dimColor={dim} wrap="wrap">{displayContent}</Text> + <Text color="gray">{gutterNumStr} </Text> + <Text color={color} dimColor={dim}> + {prefixSymbol}{' '} + </Text> + <Text color={color} dimColor={dim} wrap="wrap"> + {displayContent} + </Text> </Box> ); })} @@ -149,4 +180,4 @@ const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEF ); }; -export default DiffRenderer;
\ No newline at end of file +export default DiffRenderer; diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.tsx index 7ed8f4f2..fb7f9fa5 100644 --- a/packages/cli/src/ui/components/messages/ErrorMessage.tsx +++ b/packages/cli/src/ui/components/messages/ErrorMessage.tsx @@ -2,23 +2,25 @@ import React from 'react'; import { Text, Box } from 'ink'; interface ErrorMessageProps { - text: string; + text: string; } const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => { - const prefix = '✕ '; - const prefixWidth = prefix.length; + const prefix = '✕ '; + const prefixWidth = prefix.length; - return ( - <Box flexDirection="row"> - <Box width={prefixWidth}> - <Text color="red">{prefix}</Text> - </Box> - <Box flexGrow={1}> - <Text wrap="wrap" color="red">{text}</Text> - </Box> - </Box> - ); + return ( + <Box flexDirection="row"> + <Box width={prefixWidth}> + <Text color="red">{prefix}</Text> + </Box> + <Box flexGrow={1}> + <Text wrap="wrap" color="red"> + {text} + </Text> + </Box> + </Box> + ); }; -export default ErrorMessage;
\ No newline at end of file +export default ErrorMessage; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index fe09eb33..ccccbfc6 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -3,42 +3,42 @@ import { Text, Box } from 'ink'; import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; interface GeminiMessageProps { - text: string; + text: string; } const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => { - const prefix = '✦ '; - const prefixWidth = prefix.length; + const prefix = '✦ '; + const prefixWidth = prefix.length; - // Handle potentially null or undefined text gracefully - const safeText = text || ''; + // Handle potentially null or undefined text gracefully + const safeText = text || ''; - // Use the static render method from the MarkdownRenderer class - // Pass safeText which is guaranteed to be a string - const renderedBlocks = MarkdownRenderer.render(safeText); - - // If the original text was actually empty/null, render the minimal state - if (!safeText && renderedBlocks.length === 0) { - return ( - <Box flexDirection="row"> - <Box width={prefixWidth}> - <Text color="blue">{prefix}</Text> - </Box> - <Box flexGrow={1}></Box> - </Box> - ); - } + // Use the static render method from the MarkdownRenderer class + // Pass safeText which is guaranteed to be a string + const renderedBlocks = MarkdownRenderer.render(safeText); + // If the original text was actually empty/null, render the minimal state + if (!safeText && renderedBlocks.length === 0) { return ( - <Box flexDirection="row"> - <Box width={prefixWidth}> - <Text color="blue">{prefix}</Text> - </Box> - <Box flexGrow={1} flexDirection="column"> - {renderedBlocks} - </Box> + <Box flexDirection="row"> + <Box width={prefixWidth}> + <Text color="blue">{prefix}</Text> </Box> + <Box flexGrow={1}></Box> + </Box> ); + } + + return ( + <Box flexDirection="row"> + <Box width={prefixWidth}> + <Text color="blue">{prefix}</Text> + </Box> + <Box flexGrow={1} flexDirection="column"> + {renderedBlocks} + </Box> + </Box> + ); }; -export default GeminiMessage;
\ No newline at end of file +export default GeminiMessage; diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx index 8f5841b2..e6f24859 100644 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx @@ -2,23 +2,25 @@ import React from 'react'; import { Text, Box } from 'ink'; interface InfoMessageProps { - text: string; + text: string; } const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => { - const prefix = 'ℹ '; - const prefixWidth = prefix.length; + const prefix = 'ℹ '; + const prefixWidth = prefix.length; - return ( - <Box flexDirection="row"> - <Box width={prefixWidth}> - <Text color="yellow">{prefix}</Text> - </Box> - <Box flexGrow={1}> - <Text wrap="wrap" color="yellow">{text}</Text> - </Box> - </Box> - ); + return ( + <Box flexDirection="row"> + <Box width={prefixWidth}> + <Text color="yellow">{prefix}</Text> + </Box> + <Box flexGrow={1}> + <Text wrap="wrap" color="yellow"> + {text} + </Text> + </Box> + </Box> + ); }; -export default InfoMessage;
\ No newline at end of file +export default InfoMessage; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index a37d2f94..59c2cc42 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -1,7 +1,12 @@ import React from 'react'; import { Box, Text, useInput } from 'ink'; import SelectInput from 'ink-select-input'; -import { ToolCallConfirmationDetails, ToolEditConfirmationDetails, ToolConfirmationOutcome, ToolExecuteConfirmationDetails } from '../../types.js'; // Adjust path as needed +import { + ToolCallConfirmationDetails, + ToolEditConfirmationDetails, + ToolConfirmationOutcome, + ToolExecuteConfirmationDetails, +} from '../../types.js'; // Adjust path as needed import { PartListUnion } from '@google/genai'; import DiffRenderer from './DiffRenderer.js'; import { UI_WIDTH } from '../../constants.js'; @@ -11,7 +16,9 @@ export interface ToolConfirmationMessageProps { onSubmit: (value: PartListUnion) => void; } -function isEditDetails(props: ToolCallConfirmationDetails): props is ToolEditConfirmationDetails { +function isEditDetails( + props: ToolCallConfirmationDetails, +): props is ToolEditConfirmationDetails { return (props as ToolEditConfirmationDetails).fileName !== undefined; } @@ -20,7 +27,9 @@ interface InternalOption { value: ToolConfirmationOutcome; } -const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confirmationDetails }) => { +const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ + confirmationDetails, +}) => { const { onConfirm } = confirmationDetails; useInput((_, key) => { @@ -39,41 +48,53 @@ const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confi const options: InternalOption[] = []; if (isEditDetails(confirmationDetails)) { - title = "Edit"; // Title for the outer box + title = 'Edit'; // Title for the outer box // Body content is now the DiffRenderer, passing filename to it // The bordered box is removed from here and handled within DiffRenderer - bodyContent = ( - <DiffRenderer diffContent={confirmationDetails.fileDiff} /> - ); + bodyContent = <DiffRenderer diffContent={confirmationDetails.fileDiff} />; question = `Apply this change?`; options.push( - { label: '1. Yes, apply change', value: ToolConfirmationOutcome.ProceedOnce }, - { label: "2. Yes, always apply file edits", value: ToolConfirmationOutcome.ProceedAlways }, - { label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel } + { + label: '1. Yes, apply change', + value: ToolConfirmationOutcome.ProceedOnce, + }, + { + label: '2. Yes, always apply file edits', + value: ToolConfirmationOutcome.ProceedAlways, + }, + { label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel }, ); - } else { - const executionProps = confirmationDetails as ToolExecuteConfirmationDetails; - title = "Execute Command"; // Title for the outer box + const executionProps = + confirmationDetails as ToolExecuteConfirmationDetails; + title = 'Execute Command'; // Title for the outer box // For execution, we still need context display and description const commandDisplay = <Text color="cyan">{executionProps.command}</Text>; // Combine command and description into bodyContent for layout consistency bodyContent = ( - <Box flexDirection="column"> - <Box paddingX={1} marginLeft={1}>{commandDisplay}</Box> + <Box flexDirection="column"> + <Box paddingX={1} marginLeft={1}> + {commandDisplay} </Box> + </Box> ); question = `Allow execution?`; const alwaysLabel = `2. Yes, always allow '${executionProps.rootCommand}' commands`; options.push( - { label: '1. Yes, allow once', value: ToolConfirmationOutcome.ProceedOnce }, - { label: alwaysLabel, value: ToolConfirmationOutcome.ProceedAlways }, - { label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel } + { + label: '1. Yes, allow once', + value: ToolConfirmationOutcome.ProceedOnce, + }, + { + label: alwaysLabel, + value: ToolConfirmationOutcome.ProceedAlways, + }, + { label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel }, ); } @@ -82,7 +103,7 @@ const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confi {/* Body Content (Diff Renderer or Command Info) */} {/* No separate context display here anymore for edits */} <Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}> - {bodyContent} + {bodyContent} </Box> {/* Confirmation Question */} @@ -98,4 +119,4 @@ const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confi ); }; -export default ToolConfirmationMessage;
\ No newline at end of file +export default ToolConfirmationMessage; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 6ef3c5fc..7317345b 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -6,42 +6,45 @@ import { PartListUnion } from '@google/genai'; import ToolConfirmationMessage from './ToolConfirmationMessage.js'; interface ToolGroupMessageProps { - toolCalls: IndividualToolCallDisplay[]; - onSubmit: (value: PartListUnion) => void; + toolCalls: IndividualToolCallDisplay[]; + onSubmit: (value: PartListUnion) => void; } // Main component renders the border and maps the tools using ToolMessage -const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ toolCalls, onSubmit }) => { - const hasPending = toolCalls.some(t => t.status === ToolCallStatus.Pending); - const borderColor = hasPending ? "yellow" : "blue"; +const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ + toolCalls, + onSubmit, +}) => { + const hasPending = toolCalls.some((t) => t.status === ToolCallStatus.Pending); + const borderColor = hasPending ? 'yellow' : 'blue'; - return ( - <Box - flexDirection="column" - borderStyle="round" - borderColor={borderColor} - > - {toolCalls.map((tool) => { - return ( - <React.Fragment key={tool.callId}> - <ToolMessage - key={tool.callId} // Use callId as the key - name={tool.name} - description={tool.description} - resultDisplay={tool.resultDisplay} - status={tool.status} - /> - {tool.status === ToolCallStatus.Confirming && tool.confirmationDetails && ( - <ToolConfirmationMessage confirmationDetails={tool.confirmationDetails} onSubmit={onSubmit}></ToolConfirmationMessage> - )} - </React.Fragment> - ); - })} - {/* Optional: Add padding below the last item if needed, + return ( + <Box flexDirection="column" borderStyle="round" borderColor={borderColor}> + {toolCalls.map((tool) => { + return ( + <React.Fragment key={tool.callId}> + <ToolMessage + key={tool.callId} // Use callId as the key + name={tool.name} + description={tool.description} + resultDisplay={tool.resultDisplay} + status={tool.status} + /> + {tool.status === ToolCallStatus.Confirming && + tool.confirmationDetails && ( + <ToolConfirmationMessage + confirmationDetails={tool.confirmationDetails} + onSubmit={onSubmit} + ></ToolConfirmationMessage> + )} + </React.Fragment> + ); + })} + {/* Optional: Add padding below the last item if needed, though ToolMessage already has some vertical space implicitly */} - {/* {tools.length > 0 && <Box height={1} />} */} - </Box> - ); + {/* {tools.length > 0 && <Box height={1} />} */} + </Box> + ); }; export default ToolGroupMessage; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index f8db54c4..cd18dae2 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -7,47 +7,68 @@ import DiffRenderer from './DiffRenderer.js'; import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; interface ToolMessageProps { - name: string; - description: string; - resultDisplay: ToolResultDisplay | undefined; - status: ToolCallStatus; + name: string; + description: string; + resultDisplay: ToolResultDisplay | undefined; + status: ToolCallStatus; } -const ToolMessage: React.FC<ToolMessageProps> = ({ name, description, resultDisplay, status }) => { - const statusIndicatorWidth = 3; - const hasResult = (status === ToolCallStatus.Invoked || status === ToolCallStatus.Canceled) && resultDisplay && resultDisplay.toString().trim().length > 0; +const ToolMessage: React.FC<ToolMessageProps> = ({ + name, + description, + resultDisplay, + status, +}) => { + const statusIndicatorWidth = 3; + const hasResult = + (status === ToolCallStatus.Invoked || status === ToolCallStatus.Canceled) && + resultDisplay && + resultDisplay.toString().trim().length > 0; - return ( - <Box paddingX={1} paddingY={0} flexDirection="column"> - {/* Row for Status Indicator and Tool Info */} - <Box minHeight={1}> - {/* Status Indicator */} - <Box minWidth={statusIndicatorWidth}> - {status === ToolCallStatus.Pending && <Spinner type="dots" />} - {status === ToolCallStatus.Invoked && <Text color="green">✔</Text>} - {status === ToolCallStatus.Confirming && <Text color="blue">?</Text>} - {status === ToolCallStatus.Canceled && <Text color="red" bold>-</Text>} - - </Box> - <Box> - <Text color="blue" wrap="truncate-end" strikethrough={status === ToolCallStatus.Canceled}> - <Text bold>{name}</Text> <Text color="gray">{description}</Text> - </Text> - </Box> - </Box> + return ( + <Box paddingX={1} paddingY={0} flexDirection="column"> + {/* Row for Status Indicator and Tool Info */} + <Box minHeight={1}> + {/* Status Indicator */} + <Box minWidth={statusIndicatorWidth}> + {status === ToolCallStatus.Pending && <Spinner type="dots" />} + {status === ToolCallStatus.Invoked && <Text color="green">✔</Text>} + {status === ToolCallStatus.Confirming && <Text color="blue">?</Text>} + {status === ToolCallStatus.Canceled && ( + <Text color="red" bold> + - + </Text> + )} + </Box> + <Box> + <Text + color="blue" + wrap="truncate-end" + strikethrough={status === ToolCallStatus.Canceled} + > + <Text bold>{name}</Text> <Text color="gray">{description}</Text> + </Text> + </Box> + </Box> - {hasResult && ( - <Box paddingLeft={statusIndicatorWidth}> - <Box flexShrink={1} flexDirection="row"> - <Text color="gray">↳ </Text> - {/* Use default text color (white) or gray instead of dimColor */} - {typeof resultDisplay === 'string' && <Box flexDirection='column'>{MarkdownRenderer.render(resultDisplay)}</Box>} - {typeof resultDisplay === 'object' && <DiffRenderer diffContent={resultDisplay.fileDiff} />} - </Box> - </Box> + {hasResult && ( + <Box paddingLeft={statusIndicatorWidth}> + <Box flexShrink={1} flexDirection="row"> + <Text color="gray">↳ </Text> + {/* Use default text color (white) or gray instead of dimColor */} + {typeof resultDisplay === 'string' && ( + <Box flexDirection="column"> + {MarkdownRenderer.render(resultDisplay)} + </Box> + )} + {typeof resultDisplay === 'object' && ( + <DiffRenderer diffContent={resultDisplay.fileDiff} /> )} + </Box> </Box> - ); + )} + </Box> + ); }; export default ToolMessage; diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx index 0dd451f6..08c0070f 100644 --- a/packages/cli/src/ui/components/messages/UserMessage.tsx +++ b/packages/cli/src/ui/components/messages/UserMessage.tsx @@ -2,23 +2,23 @@ import React from 'react'; import { Text, Box } from 'ink'; interface UserMessageProps { - text: string; + text: string; } const UserMessage: React.FC<UserMessageProps> = ({ text }) => { - const prefix = '> '; - const prefixWidth = prefix.length; + const prefix = '> '; + const prefixWidth = prefix.length; - return ( - <Box flexDirection="row"> - <Box width={prefixWidth}> - <Text color="gray">{prefix}</Text> - </Box> - <Box flexGrow={1}> - <Text wrap="wrap">{text}</Text> - </Box> - </Box> - ); + return ( + <Box flexDirection="row"> + <Box width={prefixWidth}> + <Text color="gray">{prefix}</Text> + </Box> + <Box flexGrow={1}> + <Text wrap="wrap">{text}</Text> + </Box> + </Box> + ); }; -export default UserMessage;
\ No newline at end of file +export default UserMessage; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 4104dcc2..23e29417 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -3,24 +3,25 @@ const BoxBorderWidth = 1; export const BOX_PADDING_X = 1; // Calculate width based on art, padding, and border -export const UI_WIDTH = EstimatedArtWidth + (BOX_PADDING_X * 2) + (BoxBorderWidth * 2); // ~63 +export const UI_WIDTH = + EstimatedArtWidth + BOX_PADDING_X * 2 + BoxBorderWidth * 2; // ~63 export const WITTY_LOADING_PHRASES = [ - 'Consulting the digital spirits...', - 'Reticulating splines...', - 'Warming up the AI hamsters...', - 'Asking the magic conch shell...', - 'Generating witty retort...', - 'Polishing the algorithms...', - 'Don\'t rush perfection (or my code)...', - 'Brewing fresh bytes...', - 'Counting electrons...', - 'Engaging cognitive processors...', - 'Checking for syntax errors in the universe...', - 'One moment, optimizing humor...', - 'Shuffling punchlines...', - 'Untangling neural nets...', - 'Compiling brilliance...', + 'Consulting the digital spirits...', + 'Reticulating splines...', + 'Warming up the AI hamsters...', + 'Asking the magic conch shell...', + 'Generating witty retort...', + 'Polishing the algorithms...', + "Don't rush perfection (or my code)...", + 'Brewing fresh bytes...', + 'Counting electrons...', + 'Engaging cognitive processors...', + 'Checking for syntax errors in the universe...', + 'One moment, optimizing humor...', + 'Shuffling punchlines...', + 'Untangling neural nets...', + 'Compiling brilliance...', ]; export const PHRASE_CHANGE_INTERVAL_MS = 15000; -export const STREAM_DEBOUNCE_MS = 100;
\ No newline at end of file +export const STREAM_DEBOUNCE_MS = 100; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index c65422ca..4144d96a 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -7,136 +7,157 @@ import { processGeminiStream } from '../../core/gemini-stream.js'; import { StreamingState } from '../../core/gemini-stream.js'; const addHistoryItem = ( - setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, - itemData: Omit<HistoryItem, 'id'>, - id: number + setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, + itemData: Omit<HistoryItem, 'id'>, + id: number, ) => { - setHistory((prevHistory) => [ - ...prevHistory, - { ...itemData, id } as HistoryItem, - ]); + setHistory((prevHistory) => [ + ...prevHistory, + { ...itemData, id } as HistoryItem, + ]); }; export const useGeminiStream = ( - setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, + setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, ) => { - const [streamingState, setStreamingState] = useState<StreamingState>(StreamingState.Idle); - const [initError, setInitError] = useState<string | null>(null); - const abortControllerRef = useRef<AbortController | null>(null); - const currentToolGroupIdRef = useRef<number | null>(null); - const chatSessionRef = useRef<Chat | null>(null); - const geminiClientRef = useRef<GeminiClient | null>(null); - const messageIdCounterRef = useRef(0); + const [streamingState, setStreamingState] = useState<StreamingState>( + StreamingState.Idle, + ); + const [initError, setInitError] = useState<string | null>(null); + const abortControllerRef = useRef<AbortController | null>(null); + const currentToolGroupIdRef = useRef<number | null>(null); + const chatSessionRef = useRef<Chat | null>(null); + const geminiClientRef = useRef<GeminiClient | null>(null); + const messageIdCounterRef = useRef(0); - // Initialize Client Effect (remains the same) - useEffect(() => { - setInitError(null); - if (!geminiClientRef.current) { - try { - geminiClientRef.current = new GeminiClient(); - } catch (error: any) { - setInitError(`Failed to initialize client: ${error.message || 'Unknown error'}`); - } - } - }, []); + // Initialize Client Effect (remains the same) + useEffect(() => { + setInitError(null); + if (!geminiClientRef.current) { + try { + geminiClientRef.current = new GeminiClient(); + } catch (error: any) { + setInitError( + `Failed to initialize client: ${error.message || 'Unknown error'}`, + ); + } + } + }, []); - // Input Handling Effect (remains the same) - useInput((input, key) => { - if (streamingState === StreamingState.Responding && key.escape) { - abortControllerRef.current?.abort(); - } - }); + // Input Handling Effect (remains the same) + useInput((input, key) => { + if (streamingState === StreamingState.Responding && key.escape) { + abortControllerRef.current?.abort(); + } + }); - // ID Generation Callback (remains the same) - const getNextMessageId = useCallback((baseTimestamp: number): number => { - messageIdCounterRef.current += 1; - return baseTimestamp + messageIdCounterRef.current; - }, []); + // ID Generation Callback (remains the same) + const getNextMessageId = useCallback((baseTimestamp: number): number => { + messageIdCounterRef.current += 1; + return baseTimestamp + messageIdCounterRef.current; + }, []); - // Submit Query Callback (updated to call processGeminiStream) - const submitQuery = useCallback(async (query: PartListUnion) => { - if (streamingState === StreamingState.Responding) { - // No-op if already going. - return; - } + // Submit Query Callback (updated to call processGeminiStream) + const submitQuery = useCallback( + async (query: PartListUnion) => { + if (streamingState === StreamingState.Responding) { + // No-op if already going. + return; + } - if (typeof query === 'string' && query.toString().trim().length === 0) { - return; - } + if (typeof query === 'string' && query.toString().trim().length === 0) { + return; + } - const userMessageTimestamp = Date.now(); - const client = geminiClientRef.current; - if (!client) { - setInitError("Gemini client is not available."); - return; - } + const userMessageTimestamp = Date.now(); + const client = geminiClientRef.current; + if (!client) { + setInitError('Gemini client is not available.'); + return; + } - if (!chatSessionRef.current) { - chatSessionRef.current = await client.startChat(); - } + if (!chatSessionRef.current) { + chatSessionRef.current = await client.startChat(); + } - // Reset state - setStreamingState(StreamingState.Responding); - setInitError(null); - currentToolGroupIdRef.current = null; - messageIdCounterRef.current = 0; - const chat = chatSessionRef.current; + // Reset state + setStreamingState(StreamingState.Responding); + setInitError(null); + currentToolGroupIdRef.current = null; + messageIdCounterRef.current = 0; + const chat = chatSessionRef.current; - try { - // Add user message - if (typeof query === 'string') { - const trimmedQuery = query.toString(); - addHistoryItem(setHistory, { type: 'user', text: trimmedQuery }, userMessageTimestamp); - } else if ( - // HACK to detect errored function responses. - typeof query === 'object' && - query !== null && - !Array.isArray(query) && // Ensure it's a single Part object - 'functionResponse' in query && // Check if it's a function response Part - query.functionResponse?.response && // Check if response object exists - 'error' in query.functionResponse.response // Check specifically for the 'error' key - ) { - const history = chat.getHistory(); - history.push({ role: 'user', parts: [query] }); - return; - } + try { + // Add user message + if (typeof query === 'string') { + const trimmedQuery = query.toString(); + addHistoryItem( + setHistory, + { type: 'user', text: trimmedQuery }, + userMessageTimestamp, + ); + } else if ( + // HACK to detect errored function responses. + typeof query === 'object' && + query !== null && + !Array.isArray(query) && // Ensure it's a single Part object + 'functionResponse' in query && // Check if it's a function response Part + query.functionResponse?.response && // Check if response object exists + 'error' in query.functionResponse.response // Check specifically for the 'error' key + ) { + const history = chat.getHistory(); + history.push({ role: 'user', parts: [query] }); + return; + } - // Prepare for streaming - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; + // Prepare for streaming + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; - // --- Delegate to Stream Processor --- + // --- Delegate to Stream Processor --- - const stream = client.sendMessageStream(chat, query, signal); + const stream = client.sendMessageStream(chat, query, signal); - const addHistoryItemFromStream = (itemData: Omit<HistoryItem, 'id'>, id: number) => { - addHistoryItem(setHistory, itemData, id); - }; - const getStreamMessageId = () => getNextMessageId(userMessageTimestamp); + const addHistoryItemFromStream = ( + itemData: Omit<HistoryItem, 'id'>, + id: number, + ) => { + addHistoryItem(setHistory, itemData, id); + }; + const getStreamMessageId = () => getNextMessageId(userMessageTimestamp); - // Call the renamed processor function - await processGeminiStream({ - stream, - signal, - setHistory, - submitQuery, - getNextMessageId: getStreamMessageId, - addHistoryItem: addHistoryItemFromStream, - currentToolGroupIdRef, - }); - } catch (error: any) { - // (Error handling for stream initiation remains the same) - console.error("Error initiating stream:", error); - if (error.name !== 'AbortError') { - // Use historyUpdater's function potentially? Or keep addHistoryItem here? - // Keeping addHistoryItem here for direct errors from this scope. - addHistoryItem(setHistory, { type: 'error', text: `[Error starting stream: ${error.message}]` }, getNextMessageId(userMessageTimestamp)); - } - } finally { - abortControllerRef.current = null; - setStreamingState(StreamingState.Idle); + // Call the renamed processor function + await processGeminiStream({ + stream, + signal, + setHistory, + submitQuery, + getNextMessageId: getStreamMessageId, + addHistoryItem: addHistoryItemFromStream, + currentToolGroupIdRef, + }); + } catch (error: any) { + // (Error handling for stream initiation remains the same) + console.error('Error initiating stream:', error); + if (error.name !== 'AbortError') { + // Use historyUpdater's function potentially? Or keep addHistoryItem here? + // Keeping addHistoryItem here for direct errors from this scope. + addHistoryItem( + setHistory, + { + type: 'error', + text: `[Error starting stream: ${error.message}]`, + }, + getNextMessageId(userMessageTimestamp), + ); } - }, [setStreamingState, setHistory, initError, getNextMessageId]); + } finally { + abortControllerRef.current = null; + setStreamingState(StreamingState.Idle); + } + }, + [setStreamingState, setHistory, initError, getNextMessageId], + ); - return { streamingState, submitQuery, initError }; + return { streamingState, submitQuery, initError }; }; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index 8f440327..7641cd01 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -1,53 +1,61 @@ import { useState, useEffect, useRef } from 'react'; -import { WITTY_LOADING_PHRASES, PHRASE_CHANGE_INTERVAL_MS } from '../constants.js'; +import { + WITTY_LOADING_PHRASES, + PHRASE_CHANGE_INTERVAL_MS, +} from '../constants.js'; import { StreamingState } from '../../core/gemini-stream.js'; export const useLoadingIndicator = (streamingState: StreamingState) => { - const [elapsedTime, setElapsedTime] = useState(0); - const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(WITTY_LOADING_PHRASES[0]); - const timerRef = useRef<NodeJS.Timeout | null>(null); - const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); - const currentPhraseIndexRef = useRef<number>(0); + const [elapsedTime, setElapsedTime] = useState(0); + const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( + WITTY_LOADING_PHRASES[0], + ); + const timerRef = useRef<NodeJS.Timeout | null>(null); + const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); + const currentPhraseIndexRef = useRef<number>(0); - // Timer effect for elapsed time during loading - useEffect(() => { - if (streamingState === StreamingState.Responding) { - setElapsedTime(0); // Reset timer on new loading start - timerRef.current = setInterval(() => { - setElapsedTime((prevTime) => prevTime + 1); - }, 1000); - } else if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - // Cleanup on unmount or when isLoading changes - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - } - }; - }, [streamingState]); + // Timer effect for elapsed time during loading + useEffect(() => { + if (streamingState === StreamingState.Responding) { + setElapsedTime(0); // Reset timer on new loading start + timerRef.current = setInterval(() => { + setElapsedTime((prevTime) => prevTime + 1); + }, 1000); + } else if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + // Cleanup on unmount or when isLoading changes + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, [streamingState]); - // Effect for cycling through witty loading phrases - useEffect(() => { - if (streamingState === StreamingState.Responding) { - currentPhraseIndexRef.current = 0; - setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); - phraseIntervalRef.current = setInterval(() => { - currentPhraseIndexRef.current = (currentPhraseIndexRef.current + 1) % WITTY_LOADING_PHRASES.length; - setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[currentPhraseIndexRef.current]); - }, PHRASE_CHANGE_INTERVAL_MS); - } else if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } - // Cleanup on unmount or when isLoading changes - return () => { - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - } - }; - }, [streamingState]); + // Effect for cycling through witty loading phrases + useEffect(() => { + if (streamingState === StreamingState.Responding) { + currentPhraseIndexRef.current = 0; + setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); + phraseIntervalRef.current = setInterval(() => { + currentPhraseIndexRef.current = + (currentPhraseIndexRef.current + 1) % WITTY_LOADING_PHRASES.length; + setCurrentLoadingPhrase( + WITTY_LOADING_PHRASES[currentPhraseIndexRef.current], + ); + }, PHRASE_CHANGE_INTERVAL_MS); + } else if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + // Cleanup on unmount or when isLoading changes + return () => { + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + } + }; + }, [streamingState]); - return { elapsedTime, currentLoadingPhrase }; -};
\ No newline at end of file + return { elapsedTime, currentLoadingPhrase }; +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index f7dcb9d3..320f355b 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -1,62 +1,65 @@ -import { ToolResultDisplay } from "../tools/tools.js"; +import { ToolResultDisplay } from '../tools/tools.js'; export enum ToolCallStatus { - Pending, - Invoked, - Confirming, - Canceled, + Pending, + Invoked, + Confirming, + Canceled, } export interface ToolCallEvent { - type: 'tool_call'; - status: ToolCallStatus; - callId: string; - name: string; - args: Record<string, any>; - resultDisplay: ToolResultDisplay | undefined; - confirmationDetails: ToolCallConfirmationDetails | undefined; + type: 'tool_call'; + status: ToolCallStatus; + callId: string; + name: string; + args: Record<string, any>; + resultDisplay: ToolResultDisplay | undefined; + confirmationDetails: ToolCallConfirmationDetails | undefined; } export interface IndividualToolCallDisplay { - callId: string; - name: string; - description: string; - resultDisplay: ToolResultDisplay | undefined; - status: ToolCallStatus; - confirmationDetails: ToolCallConfirmationDetails | undefined; + callId: string; + name: string; + description: string; + resultDisplay: ToolResultDisplay | undefined; + status: ToolCallStatus; + confirmationDetails: ToolCallConfirmationDetails | undefined; } export interface HistoryItemBase { - id: number; - text?: string; // Text content for user/gemini/info/error messages + id: number; + text?: string; // Text content for user/gemini/info/error messages } -export type HistoryItem = HistoryItemBase & ( +export type HistoryItem = HistoryItemBase & + ( | { type: 'user'; text: string } | { type: 'gemini'; text: string } | { type: 'info'; text: string } | { type: 'error'; text: string } - | { type: 'tool_group'; tools: IndividualToolCallDisplay[]; } -); + | { type: 'tool_group'; tools: IndividualToolCallDisplay[] } + ); export interface ToolCallConfirmationDetails { - title: string; - onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>; + title: string; + onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>; } -export interface ToolEditConfirmationDetails extends ToolCallConfirmationDetails { - fileName: string; - fileDiff: string; +export interface ToolEditConfirmationDetails + extends ToolCallConfirmationDetails { + fileName: string; + fileDiff: string; } -export interface ToolExecuteConfirmationDetails extends ToolCallConfirmationDetails { - command: string; - rootCommand: string; - description: string; +export interface ToolExecuteConfirmationDetails + extends ToolCallConfirmationDetails { + command: string; + rootCommand: string; + description: string; } export enum ToolConfirmationOutcome { - ProceedOnce, - ProceedAlways, - Cancel, -}
\ No newline at end of file + ProceedOnce, + ProceedAlways, + Cancel, +} diff --git a/packages/cli/src/ui/utils/MarkdownRenderer.tsx b/packages/cli/src/ui/utils/MarkdownRenderer.tsx index fc8c2b0c..20b50939 100644 --- a/packages/cli/src/ui/utils/MarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/MarkdownRenderer.tsx @@ -7,243 +7,356 @@ import { Text, Box } from 'ink'; * and inline styles (bold, italic, strikethrough, code, links). */ export class MarkdownRenderer { + /** + * Renders INLINE markdown elements using an iterative approach. + * Supports: **bold**, *italic*, _italic_, ~~strike~~, [link](url), `code`, ``code``, <u>underline</u> + * @param text The string segment to parse for inline styles. + * @returns An array of React nodes (Text components or strings). + */ + private static _renderInline(text: string): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + // UPDATED Regex: Added <u>.*?<\/u> pattern + const inlineRegex = + /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>)/g; + let match; - /** - * Renders INLINE markdown elements using an iterative approach. - * Supports: **bold**, *italic*, _italic_, ~~strike~~, [link](url), `code`, ``code``, <u>underline</u> - * @param text The string segment to parse for inline styles. - * @returns An array of React nodes (Text components or strings). - */ - private static _renderInline(text: string): React.ReactNode[] { - const nodes: React.ReactNode[] = []; - let lastIndex = 0; - // UPDATED Regex: Added <u>.*?<\/u> pattern - const inlineRegex = /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>)/g; - let match; - - while ((match = inlineRegex.exec(text)) !== null) { - // 1. Add plain text before the match - if (match.index > lastIndex) { - nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex, match.index)}</Text>); - } - - const fullMatch = match[0]; - let renderedNode: React.ReactNode = null; - const key = `m-${match.index}`; // Base key for matched part - - // 2. Determine type of match and render accordingly - try { - if (fullMatch.startsWith('**') && fullMatch.endsWith('**') && fullMatch.length > 4) { - renderedNode = <Text key={key} bold>{fullMatch.slice(2, -2)}</Text>; - } else if (((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && fullMatch.length > 2) { - renderedNode = <Text key={key} italic>{fullMatch.slice(1, -1)}</Text>; - } else if (fullMatch.startsWith('~~') && fullMatch.endsWith('~~') && fullMatch.length > 4) { - // Strikethrough as gray text - renderedNode = <Text key={key} strikethrough>{fullMatch.slice(2, -2)}</Text>; - } else if (fullMatch.startsWith('`') && fullMatch.endsWith('`') && fullMatch.length > 1) { - // Code: Try to match varying numbers of backticks - const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); - if (codeMatch && codeMatch[2]) { - renderedNode = <Text key={key} color="yellow">{codeMatch[2]}</Text>; - } else { // Fallback for simple or non-matching cases - renderedNode = <Text key={key} color="yellow">{fullMatch.slice(1, -1)}</Text>; - } - } else if (fullMatch.startsWith('[') && fullMatch.includes('](') && fullMatch.endsWith(')')) { - // Link: Extract text and URL - const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); - if (linkMatch) { - const linkText = linkMatch[1]; - const url = linkMatch[2]; - // Render link text then URL slightly dimmed/colored - renderedNode = ( - <Text key={key}> - {linkText} - <Text color="blue"> ({url})</Text> - </Text> - ); - } - } else if (fullMatch.startsWith('<u>') && fullMatch.endsWith('</u>') && fullMatch.length > 6) { - // ***** NEW: Handle underline tag ***** - // Use slice(3, -4) to remove <u> and </u> - renderedNode = <Text key={key} underline>{fullMatch.slice(3, -4)}</Text>; - } - } catch (e) { - // In case of regex or slicing errors, fallback to literal rendering - console.error("Error parsing inline markdown part:", fullMatch, e); - renderedNode = null; // Ensure fallback below is used - } - + while ((match = inlineRegex.exec(text)) !== null) { + // 1. Add plain text before the match + if (match.index > lastIndex) { + nodes.push( + <Text key={`t-${lastIndex}`}> + {text.slice(lastIndex, match.index)} + </Text>, + ); + } - // 3. Add the rendered node or the literal text if parsing failed - nodes.push(renderedNode ?? <Text key={key}>{fullMatch}</Text>); - lastIndex = inlineRegex.lastIndex; // Move index past the current match - } + const fullMatch = match[0]; + let renderedNode: React.ReactNode = null; + const key = `m-${match.index}`; // Base key for matched part - // 4. Add any remaining plain text after the last match - if (lastIndex < text.length) { - nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>); + // 2. Determine type of match and render accordingly + try { + if ( + fullMatch.startsWith('**') && + fullMatch.endsWith('**') && + fullMatch.length > 4 + ) { + renderedNode = ( + <Text key={key} bold> + {fullMatch.slice(2, -2)} + </Text> + ); + } else if ( + ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || + (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && + fullMatch.length > 2 + ) { + renderedNode = ( + <Text key={key} italic> + {fullMatch.slice(1, -1)} + </Text> + ); + } else if ( + fullMatch.startsWith('~~') && + fullMatch.endsWith('~~') && + fullMatch.length > 4 + ) { + // Strikethrough as gray text + renderedNode = ( + <Text key={key} strikethrough> + {fullMatch.slice(2, -2)} + </Text> + ); + } else if ( + fullMatch.startsWith('`') && + fullMatch.endsWith('`') && + fullMatch.length > 1 + ) { + // Code: Try to match varying numbers of backticks + const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); + if (codeMatch && codeMatch[2]) { + renderedNode = ( + <Text key={key} color="yellow"> + {codeMatch[2]} + </Text> + ); + } else { + // Fallback for simple or non-matching cases + renderedNode = ( + <Text key={key} color="yellow"> + {fullMatch.slice(1, -1)} + </Text> + ); + } + } else if ( + fullMatch.startsWith('[') && + fullMatch.includes('](') && + fullMatch.endsWith(')') + ) { + // Link: Extract text and URL + const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/); + if (linkMatch) { + const linkText = linkMatch[1]; + const url = linkMatch[2]; + // Render link text then URL slightly dimmed/colored + renderedNode = ( + <Text key={key}> + {linkText} + <Text color="blue"> ({url})</Text> + </Text> + ); + } + } else if ( + fullMatch.startsWith('<u>') && + fullMatch.endsWith('</u>') && + fullMatch.length > 6 + ) { + // ***** NEW: Handle underline tag ***** + // Use slice(3, -4) to remove <u> and </u> + renderedNode = ( + <Text key={key} underline> + {fullMatch.slice(3, -4)} + </Text> + ); } + } catch (e) { + // In case of regex or slicing errors, fallback to literal rendering + console.error('Error parsing inline markdown part:', fullMatch, e); + renderedNode = null; // Ensure fallback below is used + } - // Filter out potential nulls if any error occurred without fallback - return nodes.filter(node => node !== null); + // 3. Add the rendered node or the literal text if parsing failed + nodes.push(renderedNode ?? <Text key={key}>{fullMatch}</Text>); + lastIndex = inlineRegex.lastIndex; // Move index past the current match } - /** - * Helper to render a code block. - */ - private static _renderCodeBlock(key: string, content: string[], lang: string | null): React.ReactNode { - // Basic styling for code block - return ( - <Box key={key} borderStyle="round" paddingX={1} borderColor="gray" flexDirection="column"> - {lang && <Text dimColor> {lang}</Text>} - {/* Render each line preserving whitespace (within Text component) */} - {content.map((line, idx) => ( - <Text key={idx}>{line}</Text> - ))} - </Box> - ); + // 4. Add any remaining plain text after the last match + if (lastIndex < text.length) { + nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>); } - /** - * Helper to render a list item (ordered or unordered). - */ - private static _renderListItem(key: string, text: string, type: 'ul' | 'ol', marker: string): React.ReactNode { - const renderedText = MarkdownRenderer._renderInline(text); // Allow inline styles in list items - const prefix = type === 'ol' ? `${marker} ` : `${marker} `; // e.g., "1. " or "* " - const prefixWidth = prefix.length; + // Filter out potential nulls if any error occurred without fallback + return nodes.filter((node) => node !== null); + } - return ( - <Box key={key} paddingLeft={1} flexDirection="row"> - <Box width={prefixWidth}> - <Text>{prefix}</Text> - </Box> - <Box flexGrow={1}> - <Text wrap="wrap">{renderedText}</Text> - </Box> - </Box> - ); - } + /** + * Helper to render a code block. + */ + private static _renderCodeBlock( + key: string, + content: string[], + lang: string | null, + ): React.ReactNode { + // Basic styling for code block + return ( + <Box + key={key} + borderStyle="round" + paddingX={1} + borderColor="gray" + flexDirection="column" + > + {lang && <Text dimColor> {lang}</Text>} + {/* Render each line preserving whitespace (within Text component) */} + {content.map((line, idx) => ( + <Text key={idx}>{line}</Text> + ))} + </Box> + ); + } + /** + * Helper to render a list item (ordered or unordered). + */ + private static _renderListItem( + key: string, + text: string, + type: 'ul' | 'ol', + marker: string, + ): React.ReactNode { + const renderedText = MarkdownRenderer._renderInline(text); // Allow inline styles in list items + const prefix = type === 'ol' ? `${marker} ` : `${marker} `; // e.g., "1. " or "* " + const prefixWidth = prefix.length; - /** - * Renders a full markdown string, handling block elements (headers, lists, code blocks) - * and applying inline styles. This is the main public static method. - * @param text The full markdown string to render. - * @returns An array of React nodes representing markdown blocks. - */ - public static render(text: string): React.ReactNode[] { - if (!text) return []; + return ( + <Box key={key} paddingLeft={1} flexDirection="row"> + <Box width={prefixWidth}> + <Text>{prefix}</Text> + </Box> + <Box flexGrow={1}> + <Text wrap="wrap">{renderedText}</Text> + </Box> + </Box> + ); + } - const lines = text.split('\n'); - // Regexes for block elements - const headerRegex = /^ *(#{1,4}) +(.*)/; - const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; // ```lang or ``` or ~~~ - const ulItemRegex = /^ *([-*+]) +(.*)/; // Unordered list item, captures bullet and text - const olItemRegex = /^ *(\d+)\. +(.*)/; // Ordered list item, captures number and text - const hrRegex = /^ *([-*_] *){3,} *$/; // Horizontal rule + /** + * Renders a full markdown string, handling block elements (headers, lists, code blocks) + * and applying inline styles. This is the main public static method. + * @param text The full markdown string to render. + * @returns An array of React nodes representing markdown blocks. + */ + public static render(text: string): React.ReactNode[] { + if (!text) return []; - const contentBlocks: React.ReactNode[] = []; - // State for parsing across lines - let inCodeBlock = false; - let codeBlockContent: string[] = []; - let codeBlockLang: string | null = null; - let codeBlockFence = ''; // Store the type of fence used (``` or ~~~) - let inListType: 'ul' | 'ol' | null = null; // Track current list type to group items + const lines = text.split('\n'); + // Regexes for block elements + const headerRegex = /^ *(#{1,4}) +(.*)/; + const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\S*?) *$/; // ```lang or ``` or ~~~ + const ulItemRegex = /^ *([-*+]) +(.*)/; // Unordered list item, captures bullet and text + const olItemRegex = /^ *(\d+)\. +(.*)/; // Ordered list item, captures number and text + const hrRegex = /^ *([-*_] *){3,} *$/; // Horizontal rule - lines.forEach((line, index) => { - const key = `line-${index}`; + const contentBlocks: React.ReactNode[] = []; + // State for parsing across lines + let inCodeBlock = false; + let codeBlockContent: string[] = []; + let codeBlockLang: string | null = null; + let codeBlockFence = ''; // Store the type of fence used (``` or ~~~) + let inListType: 'ul' | 'ol' | null = null; // Track current list type to group items - // --- State 1: Inside a Code Block --- - if (inCodeBlock) { - const fenceMatch = line.match(codeFenceRegex); - // Check for closing fence, matching the opening one and length - if (fenceMatch && fenceMatch[1].startsWith(codeBlockFence[0]) && fenceMatch[1].length >= codeBlockFence.length) { - // End of code block - render it - contentBlocks.push(MarkdownRenderer._renderCodeBlock(key, codeBlockContent, codeBlockLang)); - // Reset state - inCodeBlock = false; - codeBlockContent = []; - codeBlockLang = null; - codeBlockFence = ''; - inListType = null; // Ensure list context is reset - } else { - // Add line to current code block content - codeBlockContent.push(line); - } - return; // Process next line - } + lines.forEach((line, index) => { + const key = `line-${index}`; - // --- State 2: Not Inside a Code Block --- - // Check for block element starts in rough order of precedence/commonness - const codeFenceMatch = line.match(codeFenceRegex); - const headerMatch = line.match(headerRegex); - const ulMatch = line.match(ulItemRegex); - const olMatch = line.match(olItemRegex); - const hrMatch = line.match(hrRegex); + // --- State 1: Inside a Code Block --- + if (inCodeBlock) { + const fenceMatch = line.match(codeFenceRegex); + // Check for closing fence, matching the opening one and length + if ( + fenceMatch && + fenceMatch[1].startsWith(codeBlockFence[0]) && + fenceMatch[1].length >= codeBlockFence.length + ) { + // End of code block - render it + contentBlocks.push( + MarkdownRenderer._renderCodeBlock( + key, + codeBlockContent, + codeBlockLang, + ), + ); + // Reset state + inCodeBlock = false; + codeBlockContent = []; + codeBlockLang = null; + codeBlockFence = ''; + inListType = null; // Ensure list context is reset + } else { + // Add line to current code block content + codeBlockContent.push(line); + } + return; // Process next line + } - if (codeFenceMatch) { - inCodeBlock = true; - codeBlockFence = codeFenceMatch[1]; - codeBlockLang = codeFenceMatch[2] || null; - inListType = null; // Starting code block breaks list - } else if (hrMatch) { - // Render Horizontal Rule (simple dashed line) - // Use box with height and border character, or just Text with dashes - contentBlocks.push(<Box key={key}><Text dimColor>---</Text></Box>); - inListType = null; // HR breaks list - } else if (headerMatch) { - const level = headerMatch[1].length; - const headerText = headerMatch[2]; - const renderedHeaderText = MarkdownRenderer._renderInline(headerText); - let headerNode: React.ReactNode = null; - switch (level) { /* ... (header styling as before) ... */ - case 1: headerNode = <Text bold color="cyan">{renderedHeaderText}</Text>; break; - case 2: headerNode = <Text bold color="blue">{renderedHeaderText}</Text>; break; - case 3: headerNode = <Text bold>{renderedHeaderText}</Text>; break; - case 4: headerNode = <Text italic color="gray">{renderedHeaderText}</Text>; break; - } - if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>); - inListType = null; // Header breaks list - } else if (ulMatch) { - const marker = ulMatch[1]; // *, -, or + - const itemText = ulMatch[2]; - // If previous line was not UL, maybe add spacing? For now, just render item. - contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ul', marker)); - inListType = 'ul'; // Set/maintain list context - } else if (olMatch) { - const marker = olMatch[1]; // The number - const itemText = olMatch[2]; - contentBlocks.push(MarkdownRenderer._renderListItem(key, itemText, 'ol', marker)); - inListType = 'ol'; // Set/maintain list context - } else { - // --- Regular line (Paragraph or Empty line) --- - inListType = null; // Any non-list line breaks the list sequence + // --- State 2: Not Inside a Code Block --- + // Check for block element starts in rough order of precedence/commonness + const codeFenceMatch = line.match(codeFenceRegex); + const headerMatch = line.match(headerRegex); + const ulMatch = line.match(ulItemRegex); + const olMatch = line.match(olItemRegex); + const hrMatch = line.match(hrRegex); - // Render line content if it's not blank, applying inline styles - const renderedLine = MarkdownRenderer._renderInline(line); - if (renderedLine.length > 0 || line.length > 0) { // Render lines with content or only whitespace - contentBlocks.push( - <Box key={key}> - <Text wrap="wrap">{renderedLine}</Text> - </Box> - ); - } else if (line.trim().length === 0) { // Handle specifically empty lines - // Add minimal space for blank lines between paragraphs/blocks - if (contentBlocks.length > 0 && !inCodeBlock) { // Avoid adding space inside code block state (handled above) - const previousBlock = contentBlocks[contentBlocks.length - 1]; - // Avoid adding multiple blank lines consecutively easily - check if previous was also blank? - // For now, add a minimal spacer for any blank line outside code blocks. - contentBlocks.push(<Box key={key} height={1} />); - } - } - } - }); + if (codeFenceMatch) { + inCodeBlock = true; + codeBlockFence = codeFenceMatch[1]; + codeBlockLang = codeFenceMatch[2] || null; + inListType = null; // Starting code block breaks list + } else if (hrMatch) { + // Render Horizontal Rule (simple dashed line) + // Use box with height and border character, or just Text with dashes + contentBlocks.push( + <Box key={key}> + <Text dimColor>---</Text> + </Box>, + ); + inListType = null; // HR breaks list + } else if (headerMatch) { + const level = headerMatch[1].length; + const headerText = headerMatch[2]; + const renderedHeaderText = MarkdownRenderer._renderInline(headerText); + let headerNode: React.ReactNode = null; + switch (level /* ... (header styling as before) ... */) { + case 1: + headerNode = ( + <Text bold color="cyan"> + {renderedHeaderText} + </Text> + ); + break; + case 2: + headerNode = ( + <Text bold color="blue"> + {renderedHeaderText} + </Text> + ); + break; + case 3: + headerNode = <Text bold>{renderedHeaderText}</Text>; + break; + case 4: + headerNode = ( + <Text italic color="gray"> + {renderedHeaderText} + </Text> + ); + break; + } + if (headerNode) contentBlocks.push(<Box key={key}>{headerNode}</Box>); + inListType = null; // Header breaks list + } else if (ulMatch) { + const marker = ulMatch[1]; // *, -, or + + const itemText = ulMatch[2]; + // If previous line was not UL, maybe add spacing? For now, just render item. + contentBlocks.push( + MarkdownRenderer._renderListItem(key, itemText, 'ul', marker), + ); + inListType = 'ul'; // Set/maintain list context + } else if (olMatch) { + const marker = olMatch[1]; // The number + const itemText = olMatch[2]; + contentBlocks.push( + MarkdownRenderer._renderListItem(key, itemText, 'ol', marker), + ); + inListType = 'ol'; // Set/maintain list context + } else { + // --- Regular line (Paragraph or Empty line) --- + inListType = null; // Any non-list line breaks the list sequence - // Handle unclosed code block at the end of the input - if (inCodeBlock) { - contentBlocks.push(MarkdownRenderer._renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang)); + // Render line content if it's not blank, applying inline styles + const renderedLine = MarkdownRenderer._renderInline(line); + if (renderedLine.length > 0 || line.length > 0) { + // Render lines with content or only whitespace + contentBlocks.push( + <Box key={key}> + <Text wrap="wrap">{renderedLine}</Text> + </Box>, + ); + } else if (line.trim().length === 0) { + // Handle specifically empty lines + // Add minimal space for blank lines between paragraphs/blocks + if (contentBlocks.length > 0 && !inCodeBlock) { + // Avoid adding space inside code block state (handled above) + const previousBlock = contentBlocks[contentBlocks.length - 1]; + // Avoid adding multiple blank lines consecutively easily - check if previous was also blank? + // For now, add a minimal spacer for any blank line outside code blocks. + contentBlocks.push(<Box key={key} height={1} />); + } } + } + }); - return contentBlocks; + // Handle unclosed code block at the end of the input + if (inCodeBlock) { + contentBlocks.push( + MarkdownRenderer._renderCodeBlock( + `line-eof`, + codeBlockContent, + codeBlockLang, + ), + ); } -}
\ No newline at end of file + + return contentBlocks; + } +} |
