Run npm run format

- Also updated README.md accordingly.

Part of https://b.corp.google.com/issues/411384603
This commit is contained in:
Taylor Mullen
2025-04-17 18:06:21 -04:00
committed by N. Taylor Mullen
parent 7928c1727f
commit cfc697a96d
45 changed files with 4373 additions and 3332 deletions

View File

@@ -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;
export default App;

View File

@@ -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;
export default Footer;

View File

@@ -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;
export default Header;

View File

@@ -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;
export default HistoryDisplay;

View File

@@ -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;
export default InputPrompt;

View File

@@ -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;
export default LoadingIndicator;

View File

@@ -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;
export default Tips;

View File

@@ -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;
export default DiffRenderer;

View File

@@ -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;
export default ErrorMessage;

View File

@@ -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;
export default GeminiMessage;

View File

@@ -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;
export default InfoMessage;

View File

@@ -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;
export default ToolConfirmationMessage;

View File

@@ -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;

View File

@@ -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>
{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>
)}
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;

View File

@@ -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;
export default UserMessage;

View File

@@ -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;
export const STREAM_DEBOUNCE_MS = 100;

View File

@@ -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;
}
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;
}
if (typeof query === 'string' && query.toString().trim().length === 0) {
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],
);
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 };
return { streamingState, submitQuery, initError };
};

View File

@@ -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 };
};
return { elapsedTime, currentLoadingPhrase };
};

View File

@@ -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,
}
ProceedOnce,
ProceedAlways,
Cancel,
}

View File

@@ -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
}
// 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>
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>,
);
}
}
/**
* 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;
const fullMatch = match[0];
let renderedNode: React.ReactNode = null;
const key = `m-${match.index}`; // Base key for matched part
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));
// 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
}
return contentBlocks;
// 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;
}
}