mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Initial commit of Gemini Code CLI
This commit introduces the initial codebase for the Gemini Code CLI, a command-line interface designed to facilitate interaction with the Gemini API for software engineering tasks.
The code was migrated from a previous git repository as a single squashed commit.
Core Features & Components:
* **Gemini Integration:** Leverages the `@google/genai` SDK to interact with the Gemini models, supporting chat history, streaming responses, and function calling (tools).
* **Terminal UI:** Built with Ink (React for CLIs) providing an interactive chat interface within the terminal, including input prompts, message display, loading indicators, and tool interaction elements.
* **Tooling Framework:** Implements a robust tool system allowing Gemini to interact with the local environment. Includes tools for:
* File system listing (`ls`)
* File reading (`read-file`)
* Content searching (`grep`)
* File globbing (`glob`)
* File editing (`edit`)
* File writing (`write-file`)
* Executing bash commands (`terminal`)
* **State Management:** Handles the streaming state of Gemini responses and manages the conversation history.
* **Configuration:** Parses command-line arguments (`yargs`) and loads environment variables (`dotenv`) for setup.
* **Project Structure:** Organized into `core`, `ui`, `tools`, `config`, and `utils` directories using TypeScript. Includes basic build (`tsc`) and start scripts.
This initial version establishes the foundation for a powerful CLI tool enabling developers to use Gemini for coding assistance directly in their terminal environment.
---
Created by yours truly: __Gemini Code__
This commit is contained in:
21
packages/cli/src/ui/components/Footer.tsx
Normal file
21
packages/cli/src/ui/components/Footer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface FooterProps {
|
||||
queryLength: number;
|
||||
}
|
||||
|
||||
const Footer: React.FC<FooterProps> = ({ queryLength }) => {
|
||||
return (
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Box minWidth={15}>
|
||||
<Text color="gray">
|
||||
{queryLength === 0 ? "? for shortcuts" : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="blue">Gemini</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
38
packages/cli/src/ui/components/Header.tsx
Normal file
38
packages/cli/src/ui/components/Header.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { UI_WIDTH, BOX_PADDING_X } from '../constants.js';
|
||||
import { shortenPath } from '../../utils/paths.js';
|
||||
|
||||
interface HeaderProps {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ cwd }) => {
|
||||
return (
|
||||
<>
|
||||
{/* Static Header Art */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color="blue">{`
|
||||
______ ________ ____ ____ _____ ____ _____ _____
|
||||
.' ___ ||_ __ ||_ \\ / _||_ _||_ \\|_ _||_ _|
|
||||
/ .' \\_| | |_ \\_| | \\/ | | | | \\ | | | |
|
||||
| | ____ | _| _ | |\\ /| | | | | |\\ \\| | | |
|
||||
\\ \`.___] |_| |__/ | _| |_\\/_| |_ _| |_ _| |_\\ |_ _| |_
|
||||
\`._____.'|________||_____||_____||_____||_____|\\____||_____|`}</Text>
|
||||
</Box>
|
||||
{/* CWD Display */}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="blue"
|
||||
paddingX={BOX_PADDING_X}
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
width={UI_WIDTH}
|
||||
>
|
||||
<Box paddingLeft={2}><Text color="gray">cwd: {shortenPath(cwd, /*maxLength*/ 70)}</Text></Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
39
packages/cli/src/ui/components/HistoryDisplay.tsx
Normal file
39
packages/cli/src/ui/components/HistoryDisplay.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { UI_WIDTH } from '../constants.js';
|
||||
import UserMessage from './messages/UserMessage.js';
|
||||
import GeminiMessage from './messages/GeminiMessage.js';
|
||||
import InfoMessage from './messages/InfoMessage.js';
|
||||
import ErrorMessage from './messages/ErrorMessage.js';
|
||||
import ToolGroupMessage from './messages/ToolGroupMessage.js';
|
||||
import { PartListUnion } from '@google/genai';
|
||||
|
||||
interface HistoryDisplayProps {
|
||||
history: HistoryItem[];
|
||||
onSubmit: (value: PartListUnion) => void;
|
||||
}
|
||||
|
||||
const HistoryDisplay: React.FC<HistoryDisplayProps> = ({ history, onSubmit }) => {
|
||||
// No grouping logic needed here anymore
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{history.map((item) => (
|
||||
<Box key={item.id} marginBottom={1}>
|
||||
{/* Render standard message types */}
|
||||
{item.type === 'user' && <UserMessage text={item.text} />}
|
||||
{item.type === 'gemini' && <GeminiMessage text={item.text} />}
|
||||
{item.type === 'info' && <InfoMessage text={item.text} />}
|
||||
{item.type === 'error' && <ErrorMessage text={item.text} />}
|
||||
|
||||
{/* Render the tool group component */}
|
||||
{item.type === 'tool_group' && (
|
||||
<ToolGroupMessage toolCalls={item.tools} onSubmit={onSubmit} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryDisplay;
|
||||
39
packages/cli/src/ui/components/InputPrompt.tsx
Normal file
39
packages/cli/src/ui/components/InputPrompt.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
interface InputPromptProps {
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
query,
|
||||
setQuery,
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
marginTop={1}
|
||||
borderStyle="round"
|
||||
borderColor={'white'}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={'white'}>> </Text>
|
||||
<Box flexGrow={1}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onSubmit={onSubmit}
|
||||
showCursor={true}
|
||||
focus={true}
|
||||
placeholder={'Ask Gemini... (try "/init" or "/help")'}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputPrompt;
|
||||
32
packages/cli/src/ui/components/LoadingIndicator.tsx
Normal file
32
packages/cli/src/ui/components/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
isLoading: boolean;
|
||||
currentLoadingPhrase: string;
|
||||
elapsedTime: number;
|
||||
}
|
||||
|
||||
const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
isLoading,
|
||||
currentLoadingPhrase,
|
||||
elapsedTime,
|
||||
}) => {
|
||||
if (!isLoading) {
|
||||
return null; // Don't render anything if not loading
|
||||
}
|
||||
|
||||
return (
|
||||
<Box marginTop={1} paddingLeft={0}>
|
||||
<Box marginRight={1}>
|
||||
<Spinner type="dots" />
|
||||
</Box>
|
||||
<Text color="cyan">{currentLoadingPhrase} ({elapsedTime}s)</Text>
|
||||
<Box flexGrow={1}>{/* Spacer */}</Box>
|
||||
<Text color="gray">(ESC to cancel)</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
||||
17
packages/cli/src/ui/components/Tips.tsx
Normal file
17
packages/cli/src/ui/components/Tips.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { UI_WIDTH } from '../constants.js';
|
||||
|
||||
const Tips: React.FC = () => {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1} width={UI_WIDTH}>
|
||||
<Text>Tips for getting started:</Text>
|
||||
<Text>1. <Text bold>/help</Text> for more information.</Text>
|
||||
<Text>2. <Text bold>/init</Text> to create a GEMINI.md for instructions & context.</Text>
|
||||
<Text>3. Ask coding questions, edit code or run commands.</Text>
|
||||
<Text>4. Be specific for the best results.</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tips;
|
||||
152
packages/cli/src/ui/components/messages/DiffRenderer.tsx
Normal file
152
packages/cli/src/ui/components/messages/DiffRenderer.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink'
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||
oldLine?: number;
|
||||
newLine?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
|
||||
const lines = diffContent.split('\n');
|
||||
const result: DiffLine[] = [];
|
||||
let currentOldLine = 0;
|
||||
let currentNewLine = 0;
|
||||
let inHunk = false;
|
||||
const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/;
|
||||
|
||||
for (const line of lines) {
|
||||
const hunkMatch = line.match(hunkHeaderRegex);
|
||||
if (hunkMatch) {
|
||||
currentOldLine = parseInt(hunkMatch[1], 10);
|
||||
currentNewLine = parseInt(hunkMatch[2], 10);
|
||||
inHunk = true;
|
||||
result.push({ type: 'hunk', content: line });
|
||||
// We need to adjust the starting point because the first line number applies to the *first* actual line change/context,
|
||||
// but we increment *before* pushing that line. So decrement here.
|
||||
currentOldLine--;
|
||||
currentNewLine--;
|
||||
continue;
|
||||
}
|
||||
if (!inHunk) {
|
||||
// Skip standard Git header lines more robustly
|
||||
if (line.startsWith('--- ') || line.startsWith('+++ ') || line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('similarity index') || line.startsWith('rename from') || line.startsWith('rename to') || line.startsWith('new file mode') || line.startsWith('deleted file mode')) continue;
|
||||
// If it's not a hunk or header, skip (or handle as 'other' if needed)
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
currentNewLine++; // Increment before pushing
|
||||
result.push({ type: 'add', newLine: currentNewLine, content: line.substring(1) });
|
||||
} else if (line.startsWith('-')) {
|
||||
currentOldLine++; // Increment before pushing
|
||||
result.push({ type: 'del', oldLine: currentOldLine, content: line.substring(1) });
|
||||
} else if (line.startsWith(' ')) {
|
||||
currentOldLine++; // Increment before pushing
|
||||
currentNewLine++;
|
||||
result.push({ type: 'context', oldLine: currentOldLine, newLine: currentNewLine, content: line.substring(1) });
|
||||
} else if (line.startsWith('\\')) { // Handle "\ No newline at end of file"
|
||||
result.push({ type: 'other', content: line });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
interface DiffRendererProps {
|
||||
diffContent: string;
|
||||
filename?: string;
|
||||
tabWidth?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||
|
||||
const DiffRenderer: React.FC<DiffRendererProps> = ({ diffContent, tabWidth = DEFAULT_TAB_WIDTH }) => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return <Text color="yellow">No diff content.</Text>;
|
||||
}
|
||||
|
||||
const parsedLines = parseDiffWithLineNumbers(diffContent);
|
||||
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map(line => ({
|
||||
...line,
|
||||
content: line.content.replace(/\t/g, ' '.repeat(tabWidth))
|
||||
}));
|
||||
|
||||
// Filter out non-displayable lines (hunks, potentially 'other') using the normalized list
|
||||
const displayableLines = normalizedLines.filter(l => l.type !== 'hunk' && l.type !== 'other');
|
||||
|
||||
|
||||
if (displayableLines.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor="gray" padding={1}>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate the minimum indentation across all displayable lines
|
||||
let baseIndentation = Infinity; // Start high to find the minimum
|
||||
for (const line of displayableLines) {
|
||||
// Only consider lines with actual content for indentation calculation
|
||||
if (line.content.trim() === '') continue;
|
||||
|
||||
const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char
|
||||
const currentIndent = (firstCharIndex === -1) ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found
|
||||
baseIndentation = Math.min(baseIndentation, currentIndent);
|
||||
}
|
||||
// If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0
|
||||
if (!isFinite(baseIndentation)) {
|
||||
baseIndentation = 0;
|
||||
}
|
||||
// --- End Modification ---
|
||||
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor="gray" flexDirection="column">
|
||||
{/* Iterate over the lines that should be displayed (already normalized) */}
|
||||
{displayableLines.map((line, index) => {
|
||||
const key = `diff-line-${index}`;
|
||||
let gutterNumStr = '';
|
||||
let color: string | undefined = undefined;
|
||||
let prefixSymbol = ' ';
|
||||
let dim = false;
|
||||
|
||||
switch (line.type) {
|
||||
case 'add':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
color = 'green';
|
||||
prefixSymbol = '+';
|
||||
break;
|
||||
case 'del':
|
||||
gutterNumStr = (line.oldLine ?? '').toString();
|
||||
color = 'red';
|
||||
prefixSymbol = '-';
|
||||
break;
|
||||
case 'context':
|
||||
// Show new line number for context lines in gutter
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
dim = true;
|
||||
prefixSymbol = ' ';
|
||||
break;
|
||||
}
|
||||
|
||||
// Render the line content *after* stripping the calculated *minimum* baseIndentation.
|
||||
// The line.content here is already the tab-normalized version.
|
||||
const displayContent = line.content.substring(baseIndentation);
|
||||
|
||||
return (
|
||||
// Using your original rendering structure
|
||||
<Box key={key} flexDirection="row">
|
||||
<Text color="gray">{gutterNumStr} </Text>
|
||||
<Text color={color} dimColor={dim}>{prefixSymbol} </Text>
|
||||
<Text color={color} dimColor={dim} wrap="wrap">{displayContent}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffRenderer;
|
||||
24
packages/cli/src/ui/components/messages/ErrorMessage.tsx
Normal file
24
packages/cli/src/ui/components/messages/ErrorMessage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
|
||||
interface ErrorMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
|
||||
const prefix = '✕ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color="red">{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color="red">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
||||
44
packages/cli/src/ui/components/messages/GeminiMessage.tsx
Normal file
44
packages/cli/src/ui/components/messages/GeminiMessage.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const GeminiMessage: React.FC<GeminiMessageProps> = ({ text }) => {
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
// Handle potentially null or undefined text gracefully
|
||||
const safeText = text || '';
|
||||
|
||||
// Use the static render method from the MarkdownRenderer class
|
||||
// Pass safeText which is guaranteed to be a string
|
||||
const renderedBlocks = MarkdownRenderer.render(safeText);
|
||||
|
||||
// If the original text was actually empty/null, render the minimal state
|
||||
if (!safeText && renderedBlocks.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color="blue">{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}></Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color="blue">{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{renderedBlocks}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeminiMessage;
|
||||
24
packages/cli/src/ui/components/messages/InfoMessage.tsx
Normal file
24
packages/cli/src/ui/components/messages/InfoMessage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
|
||||
interface InfoMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
|
||||
const prefix = 'ℹ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color="yellow">{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color="yellow">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoMessage;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import { ToolCallConfirmationDetails, ToolEditConfirmationDetails, ToolConfirmationOutcome, ToolExecuteConfirmationDetails } from '../../types.js'; // Adjust path as needed
|
||||
import { PartListUnion } from '@google/genai';
|
||||
import DiffRenderer from './DiffRenderer.js';
|
||||
import { UI_WIDTH } from '../../constants.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
confirmationDetails: ToolCallConfirmationDetails;
|
||||
onSubmit: (value: PartListUnion) => void;
|
||||
}
|
||||
|
||||
function isEditDetails(props: ToolCallConfirmationDetails): props is ToolEditConfirmationDetails {
|
||||
return (props as ToolEditConfirmationDetails).fileName !== undefined;
|
||||
}
|
||||
|
||||
interface InternalOption {
|
||||
label: string;
|
||||
value: ToolConfirmationOutcome;
|
||||
}
|
||||
|
||||
const ToolConfirmationMessage: React.FC<ToolConfirmationMessageProps> = ({ confirmationDetails }) => {
|
||||
const { onConfirm } = confirmationDetails;
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelect = (item: InternalOption) => {
|
||||
onConfirm(item.value);
|
||||
};
|
||||
|
||||
let title: string;
|
||||
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
|
||||
let question: string;
|
||||
const options: InternalOption[] = [];
|
||||
|
||||
if (isEditDetails(confirmationDetails)) {
|
||||
title = "Edit"; // Title for the outer box
|
||||
|
||||
// Body content is now the DiffRenderer, passing filename to it
|
||||
// The bordered box is removed from here and handled within DiffRenderer
|
||||
bodyContent = (
|
||||
<DiffRenderer diffContent={confirmationDetails.fileDiff} />
|
||||
);
|
||||
|
||||
question = `Apply this change?`;
|
||||
options.push(
|
||||
{ label: '1. Yes, apply change', value: ToolConfirmationOutcome.ProceedOnce },
|
||||
{ label: "2. Yes, always apply file edits", value: ToolConfirmationOutcome.ProceedAlways },
|
||||
{ label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel }
|
||||
);
|
||||
|
||||
} else {
|
||||
const executionProps = confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
title = "Execute Command"; // Title for the outer box
|
||||
|
||||
// For execution, we still need context display and description
|
||||
const commandDisplay = <Text color="cyan">{executionProps.command}</Text>;
|
||||
|
||||
// Combine command and description into bodyContent for layout consistency
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1} marginLeft={1}>{commandDisplay}</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
question = `Allow execution?`;
|
||||
const alwaysLabel = `2. Yes, always allow '${executionProps.rootCommand}' commands`;
|
||||
options.push(
|
||||
{ label: '1. Yes, allow once', value: ToolConfirmationOutcome.ProceedOnce },
|
||||
{ label: alwaysLabel, value: ToolConfirmationOutcome.ProceedAlways },
|
||||
{ label: '3. No (esc)', value: ToolConfirmationOutcome.Cancel }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1} minWidth={UI_WIDTH}>
|
||||
{/* Body Content (Diff Renderer or Command Info) */}
|
||||
{/* No separate context display here anymore for edits */}
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||
{bodyContent}
|
||||
</Box>
|
||||
|
||||
{/* Confirmation Question */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text>{question}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Select Input for Options */}
|
||||
<Box flexShrink={0}>
|
||||
<SelectInput items={options} onSelect={handleSelect} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolConfirmationMessage;
|
||||
47
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Normal file
47
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
|
||||
import ToolMessage from './ToolMessage.js';
|
||||
import { PartListUnion } from '@google/genai';
|
||||
import ToolConfirmationMessage from './ToolConfirmationMessage.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
onSubmit: (value: PartListUnion) => void;
|
||||
}
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ toolCalls, onSubmit }) => {
|
||||
const hasPending = toolCalls.some(t => t.status === ToolCallStatus.Pending);
|
||||
const borderColor = hasPending ? "yellow" : "blue";
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
{toolCalls.map((tool) => {
|
||||
return (
|
||||
<React.Fragment key={tool.callId}>
|
||||
<ToolMessage
|
||||
key={tool.callId} // Use callId as the key
|
||||
name={tool.name}
|
||||
description={tool.description}
|
||||
resultDisplay={tool.resultDisplay}
|
||||
status={tool.status}
|
||||
/>
|
||||
{tool.status === ToolCallStatus.Confirming && tool.confirmationDetails && (
|
||||
<ToolConfirmationMessage confirmationDetails={tool.confirmationDetails} onSubmit={onSubmit}></ToolConfirmationMessage>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{/* Optional: Add padding below the last item if needed,
|
||||
though ToolMessage already has some vertical space implicitly */}
|
||||
{/* {tools.length > 0 && <Box height={1} />} */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolGroupMessage;
|
||||
53
packages/cli/src/ui/components/messages/ToolMessage.tsx
Normal file
53
packages/cli/src/ui/components/messages/ToolMessage.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import { ToolResultDisplay } from '../../../tools/ToolResult.js';
|
||||
import DiffRenderer from './DiffRenderer.js';
|
||||
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js';
|
||||
|
||||
interface ToolMessageProps {
|
||||
name: string;
|
||||
description: string;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
status: ToolCallStatus;
|
||||
}
|
||||
|
||||
const ToolMessage: React.FC<ToolMessageProps> = ({ name, description, resultDisplay, status }) => {
|
||||
const statusIndicatorWidth = 3;
|
||||
const hasResult = (status === ToolCallStatus.Invoked || status === ToolCallStatus.Canceled) && resultDisplay && resultDisplay.toString().trim().length > 0;
|
||||
|
||||
return (
|
||||
<Box paddingX={1} paddingY={0} flexDirection="column">
|
||||
{/* Row for Status Indicator and Tool Info */}
|
||||
<Box minHeight={1}>
|
||||
{/* Status Indicator */}
|
||||
<Box minWidth={statusIndicatorWidth}>
|
||||
{status === ToolCallStatus.Pending && <Spinner type="dots" />}
|
||||
{status === ToolCallStatus.Invoked && <Text color="green">✔</Text>}
|
||||
{status === ToolCallStatus.Confirming && <Text color="blue">?</Text>}
|
||||
{status === ToolCallStatus.Canceled && <Text color="red" bold>-</Text>}
|
||||
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="blue" wrap="truncate-end" strikethrough={status === ToolCallStatus.Canceled}>
|
||||
<Text bold>{name}</Text> <Text color="gray">{description}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{hasResult && (
|
||||
<Box paddingLeft={statusIndicatorWidth}>
|
||||
<Box flexShrink={1} flexDirection="row">
|
||||
<Text color="gray">↳ </Text>
|
||||
{/* Use default text color (white) or gray instead of dimColor */}
|
||||
{typeof resultDisplay === 'string' && <Box flexDirection='column'>{MarkdownRenderer.render(resultDisplay)}</Box>}
|
||||
{typeof resultDisplay === 'object' && <DiffRenderer diffContent={resultDisplay.fileDiff} />}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolMessage;
|
||||
24
packages/cli/src/ui/components/messages/UserMessage.tsx
Normal file
24
packages/cli/src/ui/components/messages/UserMessage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
|
||||
interface UserMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
|
||||
const prefix = '> ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color="gray">{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap">{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMessage;
|
||||
Reference in New Issue
Block a user