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
This commit is contained in:
parent
aa65a4a1fc
commit
5be89befef
|
@ -5,23 +5,23 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Static, Text } from 'ink';
|
||||||
import { StreamingState, type HistoryItem } from './types.js';
|
import { StreamingState, type HistoryItem } from './types.js';
|
||||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||||
import { useInputHistory } from './hooks/useInputHistory.js';
|
import { useInputHistory } from './hooks/useInputHistory.js';
|
||||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||||
import { Header } from './components/Header.js';
|
import { Header } from './components/Header.js';
|
||||||
import { HistoryDisplay } from './components/HistoryDisplay.js';
|
|
||||||
import { LoadingIndicator } from './components/LoadingIndicator.js';
|
import { LoadingIndicator } from './components/LoadingIndicator.js';
|
||||||
import { InputPrompt } from './components/InputPrompt.js';
|
import { InputPrompt } from './components/InputPrompt.js';
|
||||||
import { Footer } from './components/Footer.js';
|
import { Footer } from './components/Footer.js';
|
||||||
import { ThemeDialog } from './components/ThemeDialog.js';
|
import { ThemeDialog } from './components/ThemeDialog.js';
|
||||||
import { ITermDetectionWarning } from './utils/itermDetection.js';
|
|
||||||
import { useStartupWarnings } from './hooks/useAppEffects.js';
|
import { useStartupWarnings } from './hooks/useAppEffects.js';
|
||||||
import { shortenPath, type Config } from '@gemini-code/server';
|
import { shortenPath, type Config } from '@gemini-code/server';
|
||||||
import { Colors } from './colors.js';
|
import { Colors } from './colors.js';
|
||||||
import { Tips } from './components/Tips.js';
|
import { Tips } from './components/Tips.js';
|
||||||
|
import { ConsoleOutput } from './components/ConsolePatcher.js';
|
||||||
|
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
|
@ -80,10 +80,53 @@ export const App = ({ config, cliVersion }: AppProps) => {
|
||||||
|
|
||||||
// --- Render Logic ---
|
// --- Render Logic ---
|
||||||
|
|
||||||
|
const staticallyRenderedHistoryItems = history.slice(0, -1);
|
||||||
|
const updatableHistoryItem = history[history.length - 1];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1} width="100%">
|
<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 />
|
<Header />
|
||||||
<Tips />
|
<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 && (
|
{startupWarnings.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
|
@ -108,21 +151,17 @@ export const App = ({ config, cliVersion }: AppProps) => {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Box flexDirection="column">
|
|
||||||
<HistoryDisplay history={history} onSubmit={submitQuery} />
|
|
||||||
<LoadingIndicator
|
<LoadingIndicator
|
||||||
isLoading={streamingState === StreamingState.Responding}
|
isLoading={streamingState === StreamingState.Responding}
|
||||||
currentLoadingPhrase={currentLoadingPhrase}
|
currentLoadingPhrase={currentLoadingPhrase}
|
||||||
elapsedTime={elapsedTime}
|
elapsedTime={elapsedTime}
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{isInputActive && (
|
{isInputActive && (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box marginTop={1}>
|
||||||
<Text color={Colors.SubtleComment}>cwd: </Text>
|
<Text color={Colors.SubtleComment}>cwd: </Text>
|
||||||
<Text color={Colors.LightBlue}>
|
<Text color={Colors.LightBlue}>
|
||||||
{shortenPath(config.getTargetDir(), /*maxLength*/ 70)}
|
{shortenPath(config.getTargetDir(), 70)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
@ -171,7 +210,7 @@ export const App = ({ config, cliVersion }: AppProps) => {
|
||||||
debugMessage={debugMessage}
|
debugMessage={debugMessage}
|
||||||
cliVersion={cliVersion}
|
cliVersion={cliVersion}
|
||||||
/>
|
/>
|
||||||
<ITermDetectionWarning />
|
<ConsoleOutput />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, Key } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import util from 'util';
|
||||||
|
|
||||||
|
interface ConsoleMessage {
|
||||||
|
id: Key;
|
||||||
|
type: 'log' | 'warn' | 'error';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using a module-level counter for unique IDs.
|
||||||
|
// This ensures IDs are unique across messages.
|
||||||
|
let messageIdCounter = 0;
|
||||||
|
|
||||||
|
export const ConsoleOutput: React.FC = () => {
|
||||||
|
const [messages, setMessages] = useState<ConsoleMessage[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
const originalConsoleWarn = console.warn;
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
|
||||||
|
const formatArgs = (args: unknown[]): string => util.format(...args);
|
||||||
|
const addMessage = (type: 'log' | 'warn' | 'error', args: unknown[]) => {
|
||||||
|
setMessages((prevMessages) => [
|
||||||
|
...prevMessages,
|
||||||
|
{
|
||||||
|
id: `console-msg-${messageIdCounter++}`,
|
||||||
|
type,
|
||||||
|
content: formatArgs(args),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// It's patching time
|
||||||
|
console.log = (...args: unknown[]) => addMessage('log', args);
|
||||||
|
console.warn = (...args: unknown[]) => addMessage('warn', args);
|
||||||
|
console.error = (...args: unknown[]) => addMessage('error', args);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.warn = originalConsoleWarn;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{messages.map((msg) => {
|
||||||
|
const textProps: { color?: string } = {};
|
||||||
|
let prefix = '';
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'warn':
|
||||||
|
textProps.color = 'yellow';
|
||||||
|
prefix = 'WARN: ';
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
textProps.color = 'red';
|
||||||
|
prefix = 'ERROR: ';
|
||||||
|
break;
|
||||||
|
case 'log':
|
||||||
|
default:
|
||||||
|
prefix = 'LOG: ';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={msg.id}>
|
||||||
|
<Text {...textProps}>
|
||||||
|
{prefix}
|
||||||
|
{msg.content}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,43 +0,0 @@
|
||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Box } from 'ink';
|
|
||||||
import type { HistoryItem } from '../types.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HistoryDisplay: React.FC<HistoryDisplayProps> = ({
|
|
||||||
history,
|
|
||||||
onSubmit,
|
|
||||||
}) => (
|
|
||||||
// No grouping logic needed here anymore
|
|
||||||
<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>
|
|
||||||
);
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { HistoryItem } from '../types.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';
|
||||||
|
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
|
||||||
|
import { Box } from 'ink';
|
||||||
|
|
||||||
|
interface HistoryItemDisplayProps {
|
||||||
|
item: HistoryItem;
|
||||||
|
onSubmit: (value: PartListUnion) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||||
|
item,
|
||||||
|
onSubmit,
|
||||||
|
}) => (
|
||||||
|
<Box flexDirection="column" key={item.id}>
|
||||||
|
{/* Render standard message types */}
|
||||||
|
{item.type === 'user' && <UserMessage text={item.text} />}
|
||||||
|
{item.type === 'gemini' && <GeminiMessage text={item.text} />}
|
||||||
|
{item.type === 'gemini_content' && (
|
||||||
|
<GeminiMessageContent text={item.text} />
|
||||||
|
)}
|
||||||
|
{item.type === 'info' && <InfoMessage text={item.text} />}
|
||||||
|
{item.type === 'error' && <ErrorMessage text={item.text} />}
|
||||||
|
{item.type === 'tool_group' && (
|
||||||
|
<ToolGroupMessage
|
||||||
|
toolCalls={item.tools}
|
||||||
|
groupId={item.id}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
|
@ -14,7 +14,12 @@ interface InputPromptProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InputPrompt: React.FC<InputPromptProps> = ({ onSubmit }) => {
|
export const InputPrompt: React.FC<InputPromptProps> = ({ onSubmit }) => {
|
||||||
const [value, setValue] = React.useState('');
|
const [value, setValue] = React.useState(
|
||||||
|
"I'd like to update my web fetch tool to be a little smarter about the content it fetches from web pages. Instead of returning the entire HTML to the LLM I was extract the body text and other important information to reduce the amount of tokens we need to use.",
|
||||||
|
);
|
||||||
|
// const [value, setValue] = React.useState('Add "Hello World" to the top of README.md');
|
||||||
|
// const [value, setValue] = React.useState('show me "Hello World" in as many langauges as you can think of');
|
||||||
|
|
||||||
const { isFocused } = useFocus({ autoFocus: true });
|
const { isFocused } = useFocus({ autoFocus: true });
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
interface DiffLine {
|
interface DiffLine {
|
||||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||||
|
@ -94,6 +95,7 @@ const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||||
|
|
||||||
export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||||
diffContent,
|
diffContent,
|
||||||
|
filename,
|
||||||
tabWidth = DEFAULT_TAB_WIDTH,
|
tabWidth = DEFAULT_TAB_WIDTH,
|
||||||
}) => {
|
}) => {
|
||||||
if (!diffContent || typeof diffContent !== 'string') {
|
if (!diffContent || typeof diffContent !== 'string') {
|
||||||
|
@ -137,8 +139,11 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||||
}
|
}
|
||||||
// --- End Modification ---
|
// --- End Modification ---
|
||||||
|
|
||||||
|
const key = filename
|
||||||
|
? `diff-box-${filename}`
|
||||||
|
: `diff-box-${crypto.createHash('sha1').update(diffContent).digest('hex')}`;
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column" key={key}>
|
||||||
{/* Iterate over the lines that should be displayed (already normalized) */}
|
{/* Iterate over the lines that should be displayed (already normalized) */}
|
||||||
{displayableLines.map((line, index) => {
|
{displayableLines.map((line, index) => {
|
||||||
const key = `diff-line-${index}`;
|
const key = `diff-line-${index}`;
|
||||||
|
|
|
@ -17,7 +17,7 @@ export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
|
||||||
const prefixWidth = prefix.length;
|
const prefixWidth = prefix.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
<Box width={prefixWidth}>
|
<Box width={prefixWidth}>
|
||||||
<Text color={Colors.AccentRed}>{prefix}</Text>
|
<Text color={Colors.AccentRed}>{prefix}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box } from 'ink';
|
||||||
|
import { MarkdownRenderer } from '../../utils/MarkdownRenderer.js';
|
||||||
|
|
||||||
|
interface GeminiMessageContentProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gemini message content is a semi-hacked component. The intention is to represent a partial
|
||||||
|
* of GeminiMessage and is only used when a response gets too long. In that instance messages
|
||||||
|
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
|
||||||
|
* App.tsx to be as performant as humanly possible.
|
||||||
|
*/
|
||||||
|
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||||
|
text,
|
||||||
|
}) => {
|
||||||
|
const originalPrefix = '✦ ';
|
||||||
|
const prefixWidth = originalPrefix.length;
|
||||||
|
const renderedBlocks = MarkdownRenderer.render(text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||||
|
{renderedBlocks}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -8,7 +8,6 @@ import React from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import { PartListUnion } from '@google/genai';
|
import { PartListUnion } from '@google/genai';
|
||||||
import { DiffRenderer } from './DiffRenderer.js';
|
import { DiffRenderer } from './DiffRenderer.js';
|
||||||
import { UI_WIDTH } from '../../constants.js';
|
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
import {
|
import {
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
|
@ -88,7 +87,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
value: ToolConfirmationOutcome.ProceedOnce,
|
value: ToolConfirmationOutcome.ProceedOnce,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `Yes, allow always for ${executionProps.rootCommand} ...`,
|
label: `Yes, allow always "${executionProps.rootCommand} ..."`,
|
||||||
value: ToolConfirmationOutcome.ProceedAlways,
|
value: ToolConfirmationOutcome.ProceedAlways,
|
||||||
},
|
},
|
||||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
||||||
|
@ -96,7 +95,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1} minWidth={UI_WIDTH}>
|
<Box flexDirection="column" padding={1} minWidth="90%">
|
||||||
{/* Body Content (Diff Renderer or Command Info) */}
|
{/* Body Content (Diff Renderer or Command Info) */}
|
||||||
{/* No separate context display here anymore for edits */}
|
{/* No separate context display here anymore for edits */}
|
||||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||||
|
|
|
@ -13,12 +13,14 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
|
|
||||||
interface ToolGroupMessageProps {
|
interface ToolGroupMessageProps {
|
||||||
|
groupId: number;
|
||||||
toolCalls: IndividualToolCallDisplay[];
|
toolCalls: IndividualToolCallDisplay[];
|
||||||
onSubmit: (value: PartListUnion) => void;
|
onSubmit: (value: PartListUnion) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main component renders the border and maps the tools using ToolMessage
|
// Main component renders the border and maps the tools using ToolMessage
|
||||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
|
groupId,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -29,13 +31,23 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
key={groupId}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
|
/*
|
||||||
|
This width constraint is highly important and protects us from an Ink rendering bug.
|
||||||
|
Since the ToolGroup can typically change rendering states frequently, it can cause
|
||||||
|
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||||
|
cause tearing.
|
||||||
|
*/
|
||||||
|
width="100%"
|
||||||
|
marginLeft={1}
|
||||||
borderDimColor={hasPending}
|
borderDimColor={hasPending}
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
|
marginBottom={1}
|
||||||
>
|
>
|
||||||
{toolCalls.map((tool) => (
|
{toolCalls.map((tool) => (
|
||||||
<React.Fragment key={tool.callId}>
|
<Box key={groupId + '-' + tool.callId} flexDirection="column">
|
||||||
<ToolMessage
|
<ToolMessage
|
||||||
key={tool.callId} // Use callId as the key
|
key={tool.callId} // Use callId as the key
|
||||||
callId={tool.callId} // Pass callId
|
callId={tool.callId} // Pass callId
|
||||||
|
@ -52,7 +64,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
></ToolConfirmationMessage>
|
></ToolConfirmationMessage>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</Box>
|
||||||
))}
|
))}
|
||||||
{/* Optional: Add padding below the last item if needed,
|
{/* Optional: Add padding below the last item if needed,
|
||||||
though ToolMessage already has some vertical space implicitly */}
|
though ToolMessage already has some vertical space implicitly */}
|
||||||
|
|
|
@ -54,8 +54,8 @@ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{hasResult && (
|
{hasResult && (
|
||||||
<Box paddingLeft={statusIndicatorWidth}>
|
<Box paddingLeft={statusIndicatorWidth} width="100%">
|
||||||
<Box flexShrink={1} flexDirection="row">
|
<Box flexDirection="row">
|
||||||
{/* Use default text color (white) or gray instead of dimColor */}
|
{/* Use default text color (white) or gray instead of dimColor */}
|
||||||
{typeof resultDisplay === 'string' && (
|
{typeof resultDisplay === 'string' && (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
|
|
|
@ -28,6 +28,7 @@ import {
|
||||||
IndividualToolCallDisplay,
|
IndividualToolCallDisplay,
|
||||||
ToolCallStatus,
|
ToolCallStatus,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
import { findSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||||
|
|
||||||
const addHistoryItem = (
|
const addHistoryItem = (
|
||||||
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
setHistory: React.Dispatch<React.SetStateAction<HistoryItem[]>>,
|
||||||
|
@ -169,6 +170,28 @@ export const useGeminiStream = (
|
||||||
return false; // Not handled by a manual command.
|
return false; // Not handled by a manual command.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to update Gemini message content
|
||||||
|
const updateAndAddGeminiMessageContent = useCallback(
|
||||||
|
(
|
||||||
|
messageId: number,
|
||||||
|
previousContent: string,
|
||||||
|
nextId: number,
|
||||||
|
nextContent: string,
|
||||||
|
) => {
|
||||||
|
setHistory((prevHistory) => {
|
||||||
|
const beforeNextHistory = prevHistory.map((item) =>
|
||||||
|
item.id === messageId ? { ...item, text: previousContent } : item,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...beforeNextHistory,
|
||||||
|
{ id: nextId, type: 'gemini_content', text: nextContent },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setHistory],
|
||||||
|
);
|
||||||
|
|
||||||
// Improved submit query function
|
// Improved submit query function
|
||||||
const submitQuery = useCallback(
|
const submitQuery = useCallback(
|
||||||
async (query: PartListUnion) => {
|
async (query: PartListUnion) => {
|
||||||
|
@ -250,11 +273,37 @@ export const useGeminiStream = (
|
||||||
eventTimestamp,
|
eventTimestamp,
|
||||||
);
|
);
|
||||||
} else if (currentGeminiMessageIdRef.current !== null) {
|
} else if (currentGeminiMessageIdRef.current !== null) {
|
||||||
|
const splitPoint = findSafeSplitPoint(currentGeminiText);
|
||||||
|
|
||||||
|
if (splitPoint === currentGeminiText.length) {
|
||||||
// Update the existing message with accumulated content
|
// Update the existing message with accumulated content
|
||||||
updateGeminiMessage(
|
updateGeminiMessage(
|
||||||
currentGeminiMessageIdRef.current,
|
currentGeminiMessageIdRef.current,
|
||||||
currentGeminiText,
|
currentGeminiText,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// This indicates that we need to split up this Gemini Message.
|
||||||
|
// Splitting a message is primarily a performance consideration. There is a
|
||||||
|
// <Static> component at the root of App.tsx which takes care of rendering
|
||||||
|
// content statically or dynamically. Everything but the last message is
|
||||||
|
// treated as static in order to prevent re-rendering an entire message history
|
||||||
|
// multiple times per-second (as streaming occurs). Prior to this change you'd
|
||||||
|
// see heavy flickering of the terminal. This ensures that larger messages get
|
||||||
|
// broken up so that there are more "statically" rendered.
|
||||||
|
const originalMessageRef = currentGeminiMessageIdRef.current;
|
||||||
|
const beforeText = currentGeminiText.substring(0, splitPoint);
|
||||||
|
|
||||||
|
currentGeminiMessageIdRef.current =
|
||||||
|
getNextMessageId(userMessageTimestamp);
|
||||||
|
const afterText = currentGeminiText.substring(splitPoint);
|
||||||
|
currentGeminiText = afterText;
|
||||||
|
updateAndAddGeminiMessageContent(
|
||||||
|
originalMessageRef,
|
||||||
|
beforeText,
|
||||||
|
currentGeminiMessageIdRef.current,
|
||||||
|
afterText,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === ServerGeminiEventType.ToolCallRequest) {
|
} else if (event.type === ServerGeminiEventType.ToolCallRequest) {
|
||||||
// Reset the Gemini message tracking for the next response
|
// Reset the Gemini message tracking for the next response
|
||||||
|
@ -414,9 +463,6 @@ export const useGeminiStream = (
|
||||||
) => {
|
) => {
|
||||||
originalConfirmationDetails.onConfirm(outcome);
|
originalConfirmationDetails.onConfirm(outcome);
|
||||||
|
|
||||||
// Reset streaming state since confirmation has been chosen.
|
|
||||||
setStreamingState(StreamingState.Idle);
|
|
||||||
|
|
||||||
if (outcome === ToolConfirmationOutcome.Cancel) {
|
if (outcome === ToolConfirmationOutcome.Cancel) {
|
||||||
let resultDisplay: ToolResultDisplay | undefined;
|
let resultDisplay: ToolResultDisplay | undefined;
|
||||||
if ('fileDiff' in originalConfirmationDetails) {
|
if ('fileDiff' in originalConfirmationDetails) {
|
||||||
|
@ -444,8 +490,7 @@ export const useGeminiStream = (
|
||||||
};
|
};
|
||||||
|
|
||||||
updateFunctionResponseUI(responseInfo, ToolCallStatus.Error);
|
updateFunctionResponseUI(responseInfo, ToolCallStatus.Error);
|
||||||
|
setStreamingState(StreamingState.Idle);
|
||||||
await submitQuery(functionResponse);
|
|
||||||
} else {
|
} else {
|
||||||
const tool = toolRegistry.getTool(request.name);
|
const tool = toolRegistry.getTool(request.name);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
|
@ -469,7 +514,7 @@ export const useGeminiStream = (
|
||||||
error: undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
updateFunctionResponseUI(responseInfo, ToolCallStatus.Success);
|
updateFunctionResponseUI(responseInfo, ToolCallStatus.Success);
|
||||||
|
setStreamingState(StreamingState.Idle);
|
||||||
await submitQuery(functionResponse);
|
await submitQuery(functionResponse);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -59,6 +59,7 @@ export type HistoryItem = HistoryItemBase &
|
||||||
(
|
(
|
||||||
| { type: 'user'; text: string }
|
| { type: 'user'; text: string }
|
||||||
| { type: 'gemini'; text: string }
|
| { type: 'gemini'; text: string }
|
||||||
|
| { type: 'gemini_content'; text: string }
|
||||||
| { type: 'info'; text: string }
|
| { type: 'info'; text: string }
|
||||||
| { type: 'error'; text: string }
|
| { type: 'error'; text: string }
|
||||||
| { type: 'tool_group'; tools: IndividualToolCallDisplay[] }
|
| { type: 'tool_group'; tools: IndividualToolCallDisplay[] }
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
|
|
||||||
export const ITermDetectionWarning: React.FC = () => {
|
|
||||||
if (process.env.TERM_PROGRAM !== 'iTerm.app') {
|
|
||||||
return null; // Don't render anything if not in iTerm
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text dimColor>Note: Flickering may occur in iTerm.</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,207 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
**Background & Purpose:**
|
||||||
|
|
||||||
|
The `findSafeSplitPoint` function is designed to address the challenge of displaying or processing large, potentially streaming, pieces of Markdown text. When content (e.g., from an LLM like Gemini) arrives in chunks or grows too large for a single display unit (like a message bubble), it needs to be split. A naive split (e.g., just at a character limit) can break Markdown formatting, especially critical for multi-line elements like code blocks, lists, or blockquotes, leading to incorrect rendering.
|
||||||
|
|
||||||
|
This function aims to find an *intelligent* or "safe" index within the provided `content` string at which to make such a split, prioritizing the preservation of Markdown integrity.
|
||||||
|
|
||||||
|
**Key Expectations & Behavior (Prioritized):**
|
||||||
|
|
||||||
|
1. **No Split if Short Enough:**
|
||||||
|
* If `content.length` is less than or equal to `idealMaxLength`, the function should return `content.length` (indicating no split is necessary for length reasons).
|
||||||
|
|
||||||
|
2. **Code Block Integrity (Highest Priority for Safety):**
|
||||||
|
* The function must try to avoid splitting *inside* a fenced code block (i.e., between ` ``` ` and ` ``` `).
|
||||||
|
* If `idealMaxLength` falls within a code block:
|
||||||
|
* The function will attempt to return an index that splits the content *before* the start of that code block.
|
||||||
|
* If a code block starts at the very beginning of the `content` and `idealMaxLength` falls within it (meaning the block itself is too long for the first chunk), the function might return `0`. This effectively makes the first chunk empty, pushing the entire oversized code block to the second part of the split.
|
||||||
|
* When considering splits near code blocks, the function prefers to keep the entire code block intact in one of the resulting chunks.
|
||||||
|
|
||||||
|
3. **Markdown-Aware Newline Splitting (If Not Governed by Code Block Logic):**
|
||||||
|
* If `idealMaxLength` does not fall within a code block (or after code block considerations have been made), the function will look for natural break points by scanning backwards from `idealMaxLength`:
|
||||||
|
* **Paragraph Breaks:** It prioritizes splitting after a double newline (`\n\n`), as this typically signifies the end of a paragraph or a block-level element.
|
||||||
|
* **Single Line Breaks:** If no double newline is found in a suitable range, it will look for a single newline (`\n`).
|
||||||
|
* Any newline chosen as a split point must also not be inside a code block.
|
||||||
|
|
||||||
|
4. **Fallback to `idealMaxLength`:**
|
||||||
|
* If no "safer" split point (respecting code blocks or finding suitable newlines) is identified before or at `idealMaxLength`, and `idealMaxLength` itself is not determined to be an unsafe split point (e.g., inside a code block), the function may return a length larger than `idealMaxLength`, again it CANNOT break markdown formatting. This could happen with very long lines of text without Markdown block structures or newlines.
|
||||||
|
|
||||||
|
**In essence, `findSafeSplitPoint` tries to be a good Markdown citizen when forced to divide content, preferring structural boundaries over arbitrary character limits, with a strong emphasis on not corrupting code blocks.**
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a given character index within a string is inside a fenced (```) code block.
|
||||||
|
* @param content The full string content.
|
||||||
|
* @param indexToTest The character index to test.
|
||||||
|
* @returns True if the index is inside a code block's content, false otherwise.
|
||||||
|
*/
|
||||||
|
const isIndexInsideCodeBlock = (
|
||||||
|
content: string,
|
||||||
|
indexToTest: number,
|
||||||
|
): boolean => {
|
||||||
|
let fenceCount = 0;
|
||||||
|
let searchPos = 0;
|
||||||
|
while (searchPos < content.length) {
|
||||||
|
const nextFence = content.indexOf('```', searchPos);
|
||||||
|
if (nextFence === -1 || nextFence >= indexToTest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fenceCount++;
|
||||||
|
searchPos = nextFence + 3;
|
||||||
|
}
|
||||||
|
return fenceCount % 2 === 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the starting index of the code block that encloses the given index.
|
||||||
|
* Returns -1 if the index is not inside a code block.
|
||||||
|
* @param content The markdown content.
|
||||||
|
* @param index The index to check.
|
||||||
|
* @returns Start index of the enclosing code block or -1.
|
||||||
|
*/
|
||||||
|
const findEnclosingCodeBlockStart = (
|
||||||
|
content: string,
|
||||||
|
index: number,
|
||||||
|
): number => {
|
||||||
|
if (!isIndexInsideCodeBlock(content, index)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
let currentSearchPos = 0;
|
||||||
|
while (currentSearchPos < index) {
|
||||||
|
const blockStartIndex = content.indexOf('```', currentSearchPos);
|
||||||
|
if (blockStartIndex === -1 || blockStartIndex >= index) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const blockEndIndex = content.indexOf('```', blockStartIndex + 3);
|
||||||
|
if (blockStartIndex < index) {
|
||||||
|
if (blockEndIndex === -1 || index < blockEndIndex + 3) {
|
||||||
|
return blockStartIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blockEndIndex === -1) break;
|
||||||
|
currentSearchPos = blockEndIndex + 3;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findSafeSplitPoint = (
|
||||||
|
content: string,
|
||||||
|
idealMaxLength: number = 500,
|
||||||
|
): number => {
|
||||||
|
if (content.length <= idealMaxLength) {
|
||||||
|
return content.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enclosingBlockStartForIdealMax = findEnclosingCodeBlockStart(
|
||||||
|
content,
|
||||||
|
idealMaxLength,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (enclosingBlockStartForIdealMax !== -1) {
|
||||||
|
// idealMaxLength is inside a code block. Try to split *before* this block.
|
||||||
|
const textToSearchForNewline = content.substring(
|
||||||
|
0,
|
||||||
|
enclosingBlockStartForIdealMax,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Iteratively search for the last safe \n\n before enclosingBlockStartForIdealMax
|
||||||
|
let currentSearchFromIndex = textToSearchForNewline.length;
|
||||||
|
while (currentSearchFromIndex > 0) {
|
||||||
|
// searchEndIndex refers to character count to search within
|
||||||
|
const dnlIndex = textToSearchForNewline.lastIndexOf(
|
||||||
|
'\n\n',
|
||||||
|
currentSearchFromIndex - 1,
|
||||||
|
); // fromIndex for lastIndexOf is 0-based
|
||||||
|
if (dnlIndex === -1) break;
|
||||||
|
|
||||||
|
const potentialSplit = dnlIndex + 2;
|
||||||
|
// The split must be strictly before the block idealMaxLength was in.
|
||||||
|
// This is implicitly true if dnlIndex is found within textToSearchForNewline.
|
||||||
|
if (!isIndexInsideCodeBlock(content, potentialSplit)) {
|
||||||
|
// Condition: (potentialSplit > 0) OR (it's 0 AND the problematic block also started at 0)
|
||||||
|
if (
|
||||||
|
potentialSplit > 0 ||
|
||||||
|
(enclosingBlockStartForIdealMax === 0 && potentialSplit === 0)
|
||||||
|
) {
|
||||||
|
return potentialSplit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSearchFromIndex = dnlIndex; // Continue search before the start of this found \n\n
|
||||||
|
// (dnlIndex is start of \n\n, so next search is before it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iteratively search for the last safe \n
|
||||||
|
currentSearchFromIndex = textToSearchForNewline.length;
|
||||||
|
while (currentSearchFromIndex >= 0) {
|
||||||
|
// Can be 0 if textToSearchForNewline has length 1 and it's \n
|
||||||
|
const snlIndex = textToSearchForNewline.lastIndexOf(
|
||||||
|
'\n',
|
||||||
|
currentSearchFromIndex - 1,
|
||||||
|
);
|
||||||
|
if (snlIndex === -1) break;
|
||||||
|
|
||||||
|
const potentialSplit = snlIndex + 1;
|
||||||
|
if (!isIndexInsideCodeBlock(content, potentialSplit)) {
|
||||||
|
if (
|
||||||
|
potentialSplit > 0 ||
|
||||||
|
(enclosingBlockStartForIdealMax === 0 && potentialSplit === 0)
|
||||||
|
) {
|
||||||
|
return potentialSplit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSearchFromIndex = snlIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: split right before this code block
|
||||||
|
return enclosingBlockStartForIdealMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
// idealMaxLength is NOT inside a code block.
|
||||||
|
// Search backwards from idealMaxLength for a double newline (\n\n) not in a code block.
|
||||||
|
for (let i = Math.min(idealMaxLength, content.length) - 1; i > 0; i--) {
|
||||||
|
if (content[i] === '\n' && content[i - 1] === '\n') {
|
||||||
|
const potentialSplitPoint = i + 1;
|
||||||
|
if (potentialSplitPoint <= idealMaxLength) {
|
||||||
|
if (!isIndexInsideCodeBlock(content, potentialSplitPoint)) {
|
||||||
|
return potentialSplitPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no safe double newline, look for a single newline (\n)
|
||||||
|
for (let i = Math.min(idealMaxLength, content.length) - 1; i >= 0; i--) {
|
||||||
|
if (content[i] === '\n') {
|
||||||
|
const potentialSplitPoint = i + 1;
|
||||||
|
if (potentialSplitPoint <= idealMaxLength) {
|
||||||
|
if (!isIndexInsideCodeBlock(content, potentialSplitPoint)) {
|
||||||
|
return potentialSplitPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback logic if no prior safe split was found
|
||||||
|
if (!isIndexInsideCodeBlock(content, idealMaxLength)) {
|
||||||
|
return idealMaxLength;
|
||||||
|
} else {
|
||||||
|
// This should ideally not be reached frequently if prior logic is sound.
|
||||||
|
// enclosingBlockStartForIdealMax would have been set if idealMaxLength was in a block.
|
||||||
|
// If somehow it's still in a block, attempt to use the start of that block.
|
||||||
|
const lastResortBlockStart = findEnclosingCodeBlockStart(
|
||||||
|
content,
|
||||||
|
idealMaxLength,
|
||||||
|
); // Re-check
|
||||||
|
if (lastResortBlockStart !== -1) {
|
||||||
|
return lastResortBlockStart;
|
||||||
|
}
|
||||||
|
// Absolute fallback: if idealMaxLength is in a block and we can't find its start (very unlikely)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue