qwen-code/packages/cli/src/ui/App.tsx
Taylor Mullen 5be89befef feat: Fix flickering in iTerm + scrolling + performance issues.
- Refactors history display using Ink's <Static> component to prevent flickering and improve performance by rendering completed items statically.
- Introduces ConsolePatcher component to capture and display console.log, console.warn, and console.error output within the Ink UI, addressing native handling issues.
- Introduce a new content splitting mechanism to work better for static items. Basically when content gets too long we will now split content into multiple blocks for Gemini messages to ensure that we can statically cache larger pieces of history.

Fixes:
- https://b.corp.google.com/issues/411450097
- https://b.corp.google.com/issues/412716309
2025-04-26 16:08:05 -07:00

216 lines
6.8 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo, useCallback } from 'react';
import { Box, Static, Text } from 'ink';
import { StreamingState, type HistoryItem } from './types.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistory } from './hooks/useInputHistory.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { Header } from './components/Header.js';
import { LoadingIndicator } from './components/LoadingIndicator.js';
import { InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
import { useStartupWarnings } from './hooks/useAppEffects.js';
import { shortenPath, type Config } from '@gemini-code/server';
import { Colors } from './colors.js';
import { Tips } from './components/Tips.js';
import { ConsoleOutput } from './components/ConsolePatcher.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
interface AppProps {
config: Config;
cliVersion: string;
}
export const App = ({ config, cliVersion }: AppProps) => {
const [history, setHistory] = useState<HistoryItem[]>([]);
const [startupWarnings, setStartupWarnings] = useState<string[]>([]);
const { streamingState, submitQuery, initError, debugMessage } =
useGeminiStream(setHistory, config);
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
const {
isThemeDialogOpen,
openThemeDialog,
handleThemeSelect,
handleThemeHighlight,
} = useThemeCommand();
useStartupWarnings(setStartupWarnings);
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
const trimmedValue = submittedValue.trim();
if (trimmedValue === '/theme') {
openThemeDialog();
} else if (trimmedValue.length > 0) {
submitQuery(submittedValue);
}
},
[openThemeDialog, submitQuery],
);
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],
);
const isInputActive = streamingState === StreamingState.Idle && !initError;
const { query, handleSubmit: handleHistorySubmit } = useInputHistory({
userMessages,
onSubmit: handleFinalSubmit,
isActive: isInputActive,
});
// --- Render Logic ---
const staticallyRenderedHistoryItems = history.slice(0, -1);
const updatableHistoryItem = history[history.length - 1];
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 />
</Box>
);
}
const historyItem = item as HistoryItem;
return (
<HistoryItemDisplay
key={'history-' + historyItem.id}
item={historyItem}
onSubmit={submitQuery}
/>
);
}}
</Static>
{updatableHistoryItem && (
<Box flexDirection="column" alignItems="flex-start">
<HistoryItemDisplay
key={'history-' + updatableHistoryItem.id}
item={updatableHistoryItem}
onSubmit={submitQuery}
/>
</Box>
)}
{startupWarnings.length > 0 && (
<Box
borderStyle="round"
borderColor={Colors.AccentYellow}
paddingX={1}
marginY={1}
flexDirection="column"
>
{startupWarnings.map((warning, index) => (
<Text key={index} color={Colors.AccentYellow}>
{warning}
</Text>
))}
</Box>
)}
{isThemeDialogOpen ? (
<ThemeDialog
onSelect={handleThemeSelect}
onHighlight={handleThemeHighlight}
/>
) : (
<>
<LoadingIndicator
isLoading={streamingState === StreamingState.Responding}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
/>
{isInputActive && (
<>
<Box marginTop={1}>
<Text color={Colors.SubtleComment}>cwd: </Text>
<Text color={Colors.LightBlue}>
{shortenPath(config.getTargetDir(), 70)}
</Text>
</Box>
<InputPrompt onSubmit={handleHistorySubmit} />
</>
)}
</>
)}
{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>
) : (
<>
<Text color={Colors.AccentRed}>
Initialization Error: {initError}
</Text>
<Text color={Colors.AccentRed}>
{' '}
Please check API key and configuration.
</Text>
</>
)}
</Box>
)}
<Footer
config={config}
queryLength={query.length}
debugMode={config.getDebugMode()}
debugMessage={debugMessage}
cliVersion={cliVersion}
/>
<ConsoleOutput />
</Box>
);
};