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:
Taylor Mullen
2025-04-25 17:11:08 -07:00
committed by N. Taylor Mullen
parent aa65a4a1fc
commit 5be89befef
15 changed files with 514 additions and 102 deletions

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,12 @@ interface InputPromptProps {
}
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 });
useInput(

View File

@@ -7,6 +7,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import crypto from 'crypto';
interface DiffLine {
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> = ({
diffContent,
filename,
tabWidth = DEFAULT_TAB_WIDTH,
}) => {
if (!diffContent || typeof diffContent !== 'string') {
@@ -137,8 +139,11 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
}
// --- End Modification ---
const key = filename
? `diff-box-${filename}`
: `diff-box-${crypto.createHash('sha1').update(diffContent).digest('hex')}`;
return (
<Box flexDirection="column">
<Box flexDirection="column" key={key}>
{/* Iterate over the lines that should be displayed (already normalized) */}
{displayableLines.map((line, index) => {
const key = `diff-line-${index}`;

View File

@@ -17,7 +17,7 @@ export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box flexDirection="row" marginBottom={1}>
<Box width={prefixWidth}>
<Text color={Colors.AccentRed}>{prefix}</Text>
</Box>

View File

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

View File

@@ -8,7 +8,6 @@ import React from 'react';
import { Box, Text, useInput } from 'ink';
import { PartListUnion } from '@google/genai';
import { DiffRenderer } from './DiffRenderer.js';
import { UI_WIDTH } from '../../constants.js';
import { Colors } from '../../colors.js';
import {
ToolCallConfirmationDetails,
@@ -88,7 +87,7 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedOnce,
},
{
label: `Yes, allow always for ${executionProps.rootCommand} ...`,
label: `Yes, allow always "${executionProps.rootCommand} ..."`,
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
@@ -96,7 +95,7 @@ export const ToolConfirmationMessage: React.FC<
}
return (
<Box flexDirection="column" padding={1} minWidth={UI_WIDTH}>
<Box flexDirection="column" padding={1} minWidth="90%">
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>

View File

@@ -13,12 +13,14 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { Colors } from '../../colors.js';
interface ToolGroupMessageProps {
groupId: number;
toolCalls: IndividualToolCallDisplay[];
onSubmit: (value: PartListUnion) => void;
}
// Main component renders the border and maps the tools using ToolMessage
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
groupId,
toolCalls,
onSubmit,
}) => {
@@ -29,13 +31,23 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
return (
<Box
key={groupId}
flexDirection="column"
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}
borderColor={borderColor}
marginBottom={1}
>
{toolCalls.map((tool) => (
<React.Fragment key={tool.callId}>
<Box key={groupId + '-' + tool.callId} flexDirection="column">
<ToolMessage
key={tool.callId} // Use callId as the key
callId={tool.callId} // Pass callId
@@ -52,7 +64,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
onSubmit={onSubmit}
></ToolConfirmationMessage>
)}
</React.Fragment>
</Box>
))}
{/* Optional: Add padding below the last item if needed,
though ToolMessage already has some vertical space implicitly */}

View File

@@ -54,8 +54,8 @@ export const ToolMessage: React.FC<IndividualToolCallDisplay> = ({
</Box>
</Box>
{hasResult && (
<Box paddingLeft={statusIndicatorWidth}>
<Box flexShrink={1} flexDirection="row">
<Box paddingLeft={statusIndicatorWidth} width="100%">
<Box flexDirection="row">
{/* Use default text color (white) or gray instead of dimColor */}
{typeof resultDisplay === 'string' && (
<Box flexDirection="column">