Files
gemini-cli/packages/cli/src/ui/App.tsx
T

244 lines
7.6 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2025-04-22 18:57:47 -07:00
import React, { useState, useMemo, useCallback } from 'react';
import { Box, Static, Text } from 'ink';
2025-04-21 11:49:46 -07:00
import { StreamingState, type HistoryItem } from './types.js';
2025-04-15 21:41:08 -07:00
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
2025-04-19 14:31:59 +01:00
import { useInputHistory } from './hooks/useInputHistory.js';
2025-04-22 18:57:47 -07:00
import { useThemeCommand } from './hooks/useThemeCommand.js';
2025-04-18 19:09:41 -04:00
import { Header } from './components/Header.js';
import { LoadingIndicator } from './components/LoadingIndicator.js';
import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
2025-04-22 18:57:47 -07:00
import { ThemeDialog } from './components/ThemeDialog.js';
2025-04-25 20:15:05 +00:00
import { useStartupWarnings } from './hooks/useAppEffects.js';
2025-04-19 12:38:09 -04:00
import { shortenPath, type Config } from '@gemini-code/server';
import { Colors } from './colors.js';
2025-04-29 23:17:36 +00:00
import { Intro } from './components/Intro.js';
2025-04-22 18:57:47 -07:00
import { Tips } from './components/Tips.js';
import { ConsoleOutput } from './components/ConsolePatcher.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
2025-04-18 21:55:02 +01:00
2025-04-15 21:41:08 -07:00
interface AppProps {
config: Config;
cliVersion: string;
2025-04-15 21:41:08 -07:00
}
export const App = ({ config, cliVersion }: AppProps) => {
2025-04-17 18:06:21 -04:00
const [history, setHistory] = useState<HistoryItem[]>([]);
2025-04-18 21:55:02 +01:00
const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
2025-04-29 23:38:26 +00:00
const {
streamingState,
submitQuery,
initError,
debugMessage,
slashCommands,
} = useGeminiStream(setHistory, config);
2025-04-17 18:06:21 -04:00
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
2025-04-15 21:41:08 -07:00
const {
isThemeDialogOpen,
openThemeDialog,
handleThemeSelect,
handleThemeHighlight,
} = useThemeCommand();
2025-04-22 18:57:47 -07:00
2025-04-18 21:55:02 +01:00
useStartupWarnings(setStartupWarnings);
2025-04-22 18:57:47 -07:00
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
const trimmedValue = submittedValue.trim();
if (trimmedValue === '/theme') {
openThemeDialog();
} else if (trimmedValue.length > 0) {
submitQuery(submittedValue);
}
},
[openThemeDialog, submitQuery],
);
2025-04-19 14:31:59 +01:00
const userMessages = useMemo(
() =>
history
.filter(
(item): item is HistoryItem & { type: 'user'; text: string } =>
item.type === 'user' &&
typeof item.text === 'string' &&
item.text.trim() !== '',
)
.map((item) => item.text),
[history],
);
2025-04-15 21:41:08 -07:00
2025-04-21 14:32:18 -04:00
const isInputActive = streamingState === StreamingState.Idle && !initError;
2025-04-19 14:31:59 +01:00
const { query, handleSubmit: handleHistorySubmit } = useInputHistory({
2025-04-19 14:31:59 +01:00
userMessages,
2025-04-22 18:57:47 -07:00
onSubmit: handleFinalSubmit,
2025-04-19 14:31:59 +01:00
isActive: isInputActive,
});
2025-04-22 18:57:47 -07:00
// --- Render Logic ---
const { staticallyRenderedHistoryItems, updatableHistoryItems } =
getHistoryRenderSlices(history);
2025-04-17 18:06:21 -04:00
return (
<Box flexDirection="column" marginBottom={1} width="90%">
{/*
* The Static component is an Ink intrinsic in which there can only be 1 per application.
* Because of this restriction we're hacking it slightly by having a 'header' item here to
* ensure that it's statically rendered.
*
* Background on the Static Item: Anything in the Static component is written a single time
* to the console. Think of it like doing a console.log and then never using ANSI codes to
* clear that content ever again. Effectively it has a moving frame that every time new static
* content is set it'll flush content to the terminal and move the area which it's "clearing"
* down a notch. Without Static the area which gets erased and redrawn continuously grows.
*/}
<Static items={['header', ...staticallyRenderedHistoryItems]}>
{(item, index) => {
if (item === 'header') {
return (
<Box flexDirection="column" key={'header-' + index}>
<Header />
<Tips />
2025-04-29 23:38:26 +00:00
<Intro commands={slashCommands} />
</Box>
);
}
const historyItem = item as HistoryItem;
return (
<HistoryItemDisplay
key={'history-' + historyItem.id}
item={historyItem}
onSubmit={submitQuery}
/>
);
}}
</Static>
{updatableHistoryItems.length > 0 && (
<Box flexDirection="column" alignItems="flex-start">
{updatableHistoryItems.map((historyItem) => (
<HistoryItemDisplay
key={'history-' + historyItem.id}
item={historyItem}
onSubmit={submitQuery}
/>
))}
</Box>
)}
2025-04-15 21:41:08 -07:00
2025-04-18 21:55:02 +01:00
{startupWarnings.length > 0 && (
<Box
borderStyle="round"
2025-04-19 12:38:09 -04:00
borderColor={Colors.AccentYellow}
2025-04-18 21:55:02 +01:00
paddingX={1}
marginY={1}
flexDirection="column"
>
{startupWarnings.map((warning, index) => (
2025-04-19 12:38:09 -04:00
<Text key={index} color={Colors.AccentYellow}>
2025-04-18 21:55:02 +01:00
{warning}
</Text>
))}
</Box>
)}
2025-04-25 20:15:05 +00:00
{isThemeDialogOpen ? (
<ThemeDialog
onSelect={handleThemeSelect}
onHighlight={handleThemeHighlight}
/>
) : (
<>
<LoadingIndicator
isLoading={streamingState === StreamingState.Responding}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
/>
2025-04-25 20:15:05 +00:00
{isInputActive && (
<>
<Box marginTop={1}>
2025-04-25 20:15:05 +00:00
<Text color={Colors.SubtleComment}>cwd: </Text>
<Text color={Colors.LightBlue}>
{shortenPath(config.getTargetDir(), 70)}
2025-04-25 20:15:05 +00:00
</Text>
</Box>
<InputPrompt onSubmit={handleHistorySubmit} />
</>
)}
</>
)}
2025-04-21 14:32:18 -04:00
{initError && streamingState !== StreamingState.Responding && (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
paddingX={1}
marginBottom={1}
>
{history.find(
(item) => item.type === 'error' && item.text?.includes(initError),
)?.text ? (
<Text color={Colors.AccentRed}>
{
history.find(
(item) =>
item.type === 'error' && item.text?.includes(initError),
)?.text
}
</Text>
) : (
<>
2025-04-19 12:38:09 -04:00
<Text color={Colors.AccentRed}>
2025-04-21 14:32:18 -04:00
Initialization Error: {initError}
2025-04-17 18:06:21 -04:00
</Text>
2025-04-21 14:32:18 -04:00
<Text color={Colors.AccentRed}>
{' '}
Please check API key and configuration.
</Text>
</>
)}
</Box>
)}
2025-04-15 21:41:08 -07:00
2025-04-20 20:20:40 +01:00
<Footer
config={config}
2025-04-20 20:20:40 +01:00
queryLength={query.length}
debugMode={config.getDebugMode()}
debugMessage={debugMessage}
cliVersion={cliVersion}
2025-04-20 20:20:40 +01:00
/>
<ConsoleOutput />
2025-04-17 18:06:21 -04:00
</Box>
);
2025-04-15 21:41:08 -07:00
};
function getHistoryRenderSlices(history: HistoryItem[]) {
let staticallyRenderedHistoryItems: HistoryItem[] = [];
let updatableHistoryItems: HistoryItem[] = [];
if (
history.length > 1 &&
history[history.length - 2]?.type === 'tool_group'
) {
// If the second-to-last item is a tool_group, it and the last item are updateable
staticallyRenderedHistoryItems = history.slice(0, -2);
updatableHistoryItems = history.slice(-2);
} else {
// Otherwise, only the last item is updateable
staticallyRenderedHistoryItems = history.slice(0, -1);
updatableHistoryItems = history.slice(-1);
}
return { staticallyRenderedHistoryItems, updatableHistoryItems };
}