diff options
Diffstat (limited to 'packages/cli/src/ui')
20 files changed, 1277 insertions, 0 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx new file mode 100644 index 00000000..32dcaac0 --- /dev/null +++ b/packages/cli/src/ui/App.tsx @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Text } from 'ink'; +import type { HistoryItem } from './types.js'; +import { useGeminiStream } from './hooks/useGeminiStream.js'; +import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; +import Header from './components/Header.js'; +import Tips from './components/Tips.js'; +import HistoryDisplay from './components/HistoryDisplay.js'; +import LoadingIndicator from './components/LoadingIndicator.js'; +import InputPrompt from './components/InputPrompt.js'; +import Footer from './components/Footer.js'; +import { StreamingState } from '../core/StreamingState.js'; +import { PartListUnion } from '@google/genai'; + +interface AppProps { + 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 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]); + + 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} /> + + <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> + )} + + <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} + /> + )} + + <Footer queryLength={query.length} /> + </Box> + ); +}; + +export default App;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx new file mode 100644 index 00000000..06e6c681 --- /dev/null +++ b/packages/cli/src/ui/components/Footer.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Box, Text } from 'ink'; + +interface FooterProps { + 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> + ); +}; + +export default Footer;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx new file mode 100644 index 00000000..c5a99a30 --- /dev/null +++ b/packages/cli/src/ui/components/Header.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { UI_WIDTH, BOX_PADDING_X } from '../constants.js'; +import { shortenPath } from '../../utils/paths.js'; + +interface HeaderProps { + cwd: string; +} + +const Header: React.FC<HeaderProps> = ({ cwd }) => { + 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> + </> + ); +}; + +export default Header;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/HistoryDisplay.tsx b/packages/cli/src/ui/components/HistoryDisplay.tsx new file mode 100644 index 00000000..bacbb258 --- /dev/null +++ b/packages/cli/src/ui/components/HistoryDisplay.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Box } from 'ink'; +import type { HistoryItem } from '../types.js'; +import { UI_WIDTH } from '../constants.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; +} + +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> + ))} + </Box> + ); +}; + +export default HistoryDisplay;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx new file mode 100644 index 00000000..92be10a4 --- /dev/null +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +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; +} + +const InputPrompt: React.FC<InputPromptProps> = ({ + 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> + ); +}; + +export default InputPrompt;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx new file mode 100644 index 00000000..8a3f9b5e --- /dev/null +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; + +interface LoadingIndicatorProps { + isLoading: boolean; + currentLoadingPhrase: string; + elapsedTime: number; +} + +const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ + isLoading, + currentLoadingPhrase, + elapsedTime, +}) => { + 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> + ); +}; + +export default LoadingIndicator;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx new file mode 100644 index 00000000..5dbe60b2 --- /dev/null +++ b/packages/cli/src/ui/components/Tips.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +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> + ); +}; + +export default Tips;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx new file mode 100644 index 00000000..5cae9004 --- /dev/null +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Box, Text } from 'ink' + +interface DiffLine { + type: 'add' | 'del' | 'context' | 'hunk' | 'other'; + oldLine?: number; + newLine?: number; + content: string; +} + +function parseDiffWithLineNumbers(diffContent: string): DiffLine[] { + const lines = diffContent.split('\n'); + const result: DiffLine[] = []; + let currentOldLine = 0; + let currentNewLine = 0; + let inHunk = false; + const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/; + + for (const line of lines) { + const hunkMatch = line.match(hunkHeaderRegex); + if (hunkMatch) { + currentOldLine = parseInt(hunkMatch[1], 10); + currentNewLine = parseInt(hunkMatch[2], 10); + inHunk = true; + result.push({ type: 'hunk', content: line }); + // We need to adjust the starting point because the first line number applies to the *first* actual line change/context, + // but we increment *before* pushing that line. So decrement here. + currentOldLine--; + currentNewLine--; + 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; + // 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) }); + } else if (line.startsWith('-')) { + currentOldLine++; // Increment before pushing + 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: 'other', content: line }); + } + } + return result; +} + + +interface DiffRendererProps { + diffContent: string; + filename?: string; + tabWidth?: number; +} + +const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization + +const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEFAULT_TAB_WIDTH }) => { + if (!diffContent || typeof diffContent !== 'string') { + return <Text color="yellow">No diff content.</Text>; + } + + const parsedLines = parseDiffWithLineNumbers(diffContent); + + // 1. Normalize whitespace (replace tabs with spaces) *before* further processing + const normalizedLines = parsedLines.map(line => ({ + ...line, + 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'); + + + if (displayableLines.length === 0) { + return ( + <Box borderStyle="round" borderColor="gray" padding={1}> + <Text dimColor>No changes detected.</Text> + </Box> + ); + } + + // Calculate the minimum indentation across all displayable lines + let baseIndentation = Infinity; // Start high to find the minimum + for (const line of displayableLines) { + // Only consider lines with actual content for indentation calculation + 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 + baseIndentation = Math.min(baseIndentation, currentIndent); + } + // If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0 + if (!isFinite(baseIndentation)) { + baseIndentation = 0; + } + // --- End Modification --- + + + return ( + <Box borderStyle="round" borderColor="gray" flexDirection="column"> + {/* Iterate over the lines that should be displayed (already normalized) */} + {displayableLines.map((line, index) => { + const key = `diff-line-${index}`; + let gutterNumStr = ''; + let color: string | undefined = undefined; + let prefixSymbol = ' '; + let dim = false; + + switch (line.type) { + case 'add': + gutterNumStr = (line.newLine ?? '').toString(); + color = 'green'; + prefixSymbol = '+'; + break; + case 'del': + gutterNumStr = (line.oldLine ?? '').toString(); + color = 'red'; + prefixSymbol = '-'; + break; + case 'context': + // Show new line number for context lines in gutter + gutterNumStr = (line.newLine ?? '').toString(); + dim = true; + prefixSymbol = ' '; + break; + } + + // Render the line content *after* stripping the calculated *minimum* baseIndentation. + // The line.content here is already the tab-normalized version. + const displayContent = line.content.substring(baseIndentation); + + 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> + </Box> + ); + })} + </Box> + ); +}; + +export default DiffRenderer;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.tsx new file mode 100644 index 00000000..7ed8f4f2 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ErrorMessage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Text, Box } from 'ink'; + +interface ErrorMessageProps { + text: string; +} + +const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => { + 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> + ); +}; + +export default ErrorMessage;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx new file mode 100644 index 00000000..fe09eb33 --- /dev/null +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Text, Box } from 'ink'; +import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; + +interface GeminiMessageProps { + text: string; +} + +const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => { + const prefix = '✦ '; + const prefixWidth = prefix.length; + + // 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> + ); + } + + 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 diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx new file mode 100644 index 00000000..8f5841b2 --- /dev/null +++ b/packages/cli/src/ui/components/messages/InfoMessage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Text, Box } from 'ink'; + +interface InfoMessageProps { + text: string; +} + +const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => { + 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> + ); +}; + +export default InfoMessage;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx new file mode 100644 index 00000000..a37d2f94 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -0,0 +1,101 @@ +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 { PartListUnion } from '@google/genai'; +import DiffRenderer from './DiffRenderer.js'; +import { UI_WIDTH } from '../../constants.js'; + +export interface ToolConfirmationMessageProps { + confirmationDetails: ToolCallConfirmationDetails; + onSubmit: (value: PartListUnion) => void; +} + +function isEditDetails(props: ToolCallConfirmationDetails): props is ToolEditConfirmationDetails { + return (props as ToolEditConfirmationDetails).fileName !== undefined; +} + +interface InternalOption { + label: string; + value: ToolConfirmationOutcome; +} + +const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confirmationDetails }) => { + const { onConfirm } = confirmationDetails; + + useInput((_, key) => { + if (key.escape) { + onConfirm(ToolConfirmationOutcome.Cancel); + } + }); + + const handleSelect = (item: InternalOption) => { + onConfirm(item.value); + }; + + let title: string; + let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here + let question: string; + const options: InternalOption[] = []; + + if (isEditDetails(confirmationDetails)) { + 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} /> + ); + + 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 } + ); + + } else { + 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> + ); + + 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 } + ); + } + + return ( + <Box flexDirection="column" padding={1} minWidth={UI_WIDTH}> + {/* 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} + </Box> + + {/* Confirmation Question */} + <Box marginBottom={1} flexShrink={0}> + <Text>{question}</Text> + </Box> + + {/* Select Input for Options */} + <Box flexShrink={0}> + <SelectInput items={options} onSelect={handleSelect} /> + </Box> + </Box> + ); +}; + +export default ToolConfirmationMessage;
\ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx new file mode 100644 index 00000000..6ef3c5fc --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Box } from 'ink'; +import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js'; +import ToolMessage from './ToolMessage.js'; +import { PartListUnion } from '@google/genai'; +import ToolConfirmationMessage from './ToolConfirmationMessage.js'; + +interface ToolGroupMessageProps { + 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"; + + 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> + ); +}; + +export default ToolGroupMessage; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx new file mode 100644 index 00000000..38bc3de1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import { ToolCallStatus } from '../../types.js'; +import { ToolResultDisplay } from '../../../tools/ToolResult.js'; +import DiffRenderer from './DiffRenderer.js'; +import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js'; + +interface ToolMessageProps { + 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; + + 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> + )} + </Box> + ); +}; + +export default ToolMessage; diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx new file mode 100644 index 00000000..0dd451f6 --- /dev/null +++ b/packages/cli/src/ui/components/messages/UserMessage.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Text, Box } from 'ink'; + +interface UserMessageProps { + text: string; +} + +const UserMessage: React.FC<UserMessageProps> = ({ text }) => { + 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> + ); +}; + +export default UserMessage;
\ No newline at end of file diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts new file mode 100644 index 00000000..4104dcc2 --- /dev/null +++ b/packages/cli/src/ui/constants.ts @@ -0,0 +1,26 @@ +const EstimatedArtWidth = 59; +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 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...', +]; +export const PHRASE_CHANGE_INTERVAL_MS = 15000; +export const STREAM_DEBOUNCE_MS = 100;
\ No newline at end of file diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts new file mode 100644 index 00000000..71972fbe --- /dev/null +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -0,0 +1,142 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { useInput } from 'ink'; +import { GeminiClient } from '../../core/GeminiClient.js'; +import { type Chat, type PartListUnion } from '@google/genai'; +import { HistoryItem } from '../types.js'; +import { processGeminiStream } from '../../core/geminiStreamProcessor.js'; +import { StreamingState } from '../../core/StreamingState.js'; + +const addHistoryItem = ( + setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>, + itemData: Omit<HistoryItem, 'id'>, + id: number +) => { + setHistory((prevHistory) => [ + ...prevHistory, + { ...itemData, id } as HistoryItem, + ]); +}; + +export const useGeminiStream = ( + 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); + + // 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(); + } + }); + + // 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; + } + + 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; + } + + 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; + + 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; + + // --- Delegate to Stream Processor --- + + const stream = client.sendMessageStream(chat, query, signal); + + 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); + } + }, [setStreamingState, setHistory, initError, getNextMessageId]); + + return { streamingState, submitQuery, initError }; +}; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts new file mode 100644 index 00000000..f1ab4552 --- /dev/null +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useRef } from 'react'; +import { WITTY_LOADING_PHRASES, PHRASE_CHANGE_INTERVAL_MS } from '../constants.js'; +import { StreamingState } from '../../core/StreamingState.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); + + // 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]); + + return { elapsedTime, currentLoadingPhrase }; +};
\ No newline at end of file diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts new file mode 100644 index 00000000..0b5dd246 --- /dev/null +++ b/packages/cli/src/ui/types.ts @@ -0,0 +1,62 @@ +import { ToolResultDisplay } from "../tools/ToolResult.js"; + +export enum ToolCallStatus { + 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; +} + +export interface IndividualToolCallDisplay { + 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 +} + +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[]; } +); + +export interface ToolCallConfirmationDetails { + title: string; + onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>; +} + +export interface ToolEditConfirmationDetails extends ToolCallConfirmationDetails { + fileName: string; + fileDiff: string; +} + +export interface ToolExecuteConfirmationDetails extends ToolCallConfirmationDetails { + command: string; + rootCommand: string; + description: string; +} + +export enum ToolConfirmationOutcome { + ProceedOnce, + ProceedAlways, + Cancel, +}
\ No newline at end of file diff --git a/packages/cli/src/ui/utils/MarkdownRenderer.tsx b/packages/cli/src/ui/utils/MarkdownRenderer.tsx new file mode 100644 index 00000000..fc8c2b0c --- /dev/null +++ b/packages/cli/src/ui/utils/MarkdownRenderer.tsx @@ -0,0 +1,249 @@ +import React from 'react'; +import { Text, Box } from 'ink'; + +/** + * A utility class to render a subset of Markdown into Ink components. + * Handles H1-H4, Lists (ul/ol, no nesting), Code Blocks, + * 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; + + 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 + } + + + // 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 + } + + // 4. Add any remaining plain text after the last match + if (lastIndex < text.length) { + nodes.push(<Text key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Text>); + } + + // Filter out potential nulls if any error occurred without fallback + return nodes.filter(node => node !== null); + } + + /** + * 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; + + 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> + ); + } + + + /** + * 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 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 + + 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 + + lines.forEach((line, index) => { + const key = `line-${index}`; + + // --- 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 + } + + // --- 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); + + 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 + + // 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} />); + } + } + } + }); + + // Handle unclosed code block at the end of the input + if (inCodeBlock) { + contentBlocks.push(MarkdownRenderer._renderCodeBlock(`line-eof`, codeBlockContent, codeBlockLang)); + } + + return contentBlocks; + } +}
\ No newline at end of file |
