summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui')
-rw-r--r--packages/cli/src/ui/App.tsx153
-rw-r--r--packages/cli/src/ui/components/Footer.tsx22
-rw-r--r--packages/cli/src/ui/components/Header.tsx44
-rw-r--r--packages/cli/src/ui/components/HistoryDisplay.tsx45
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx51
-rw-r--r--packages/cli/src/ui/components/LoadingIndicator.tsx42
-rw-r--r--packages/cli/src/ui/components/Tips.tsx25
-rw-r--r--packages/cli/src/ui/components/messages/DiffRenderer.tsx69
-rw-r--r--packages/cli/src/ui/components/messages/ErrorMessage.tsx30
-rw-r--r--packages/cli/src/ui/components/messages/GeminiMessage.tsx56
-rw-r--r--packages/cli/src/ui/components/messages/InfoMessage.tsx30
-rw-r--r--packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx61
-rw-r--r--packages/cli/src/ui/components/messages/ToolGroupMessage.tsx65
-rw-r--r--packages/cli/src/ui/components/messages/ToolMessage.tsx91
-rw-r--r--packages/cli/src/ui/components/messages/UserMessage.tsx28
-rw-r--r--packages/cli/src/ui/constants.ts35
-rw-r--r--packages/cli/src/ui/hooks/useGeminiStream.ts243
-rw-r--r--packages/cli/src/ui/hooks/useLoadingIndicator.ts100
-rw-r--r--packages/cli/src/ui/types.ts75
-rw-r--r--packages/cli/src/ui/utils/MarkdownRenderer.tsx547
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'}>&gt; </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'}>&gt; </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;
+ }
+}