feat(cli): introduce visualize tool for rich data display

- Implement `visualize` tool in core to support tables, charts, and diffs.
- Add `RichDataDisplay` UI component using Ink for rendering visualizations.
- Integrate visualization support into `ToolResultDisplay`.
- Update system prompts to encourage the use of the `visualize` tool for structured data.
- Add `info` semantic color to themes.
- Fix shell parser initialization in tests.
This commit is contained in:
Bryan Morgan
2026-02-10 19:53:38 -05:00
parent 4a48d7cf93
commit 545a23f120
18 changed files with 1133 additions and 20 deletions
@@ -112,6 +112,7 @@ describe('<Header />', () => {
error: '',
success: '',
warning: '',
info: '',
},
});
const Gradient = await import('ink-gradient');
@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { RichDataDisplay } from './RichDataDisplay.js';
import { describe, it, expect } from 'vitest';
describe('RichDataDisplay', () => {
it('should render table visualization', () => {
const data = {
type: 'table' as const,
data: [{ name: 'Test', value: 123 }],
};
const { lastFrame } = renderWithProviders(
<RichDataDisplay data={data} availableWidth={80} />,
);
const output = lastFrame();
expect(output).toContain('name');
expect(output).toContain('value');
expect(output).toContain('Test');
expect(output).toContain('123');
});
it('should render bar chart visualization', () => {
const data = {
type: 'bar_chart' as const,
title: 'Sales',
data: [
{ label: 'Q1', value: 10 },
{ label: 'Q2', value: 20 },
],
};
const { lastFrame } = renderWithProviders(
<RichDataDisplay data={data} availableWidth={80} />,
);
const output = lastFrame();
expect(output).toContain('Sales');
expect(output).toContain('Q1');
expect(output).toContain('Q2');
expect(output).toContain('█'); // Check for bar character
});
it('should render line chart visualization', () => {
const data = {
type: 'line_chart' as const,
title: 'Trends',
data: [
{ label: 'Jan', value: 10 },
{ label: 'Feb', value: 20 },
{ label: 'Mar', value: 15 },
],
};
const { lastFrame } = renderWithProviders(
<RichDataDisplay data={data} availableWidth={80} />,
);
const output = lastFrame();
expect(output).toContain('Trends');
expect(output).toContain('Jan');
expect(output).toContain('Feb');
expect(output).toContain('Mar');
expect(output).toContain('•'); // Check for plot point
expect(output).toContain('│'); // Check for axis
});
it('should render pie chart visualization', () => {
const data = {
type: 'pie_chart' as const,
title: 'Market Share',
data: [
{ label: 'A', value: 50 },
{ label: 'B', value: 50 },
],
};
const { lastFrame } = renderWithProviders(
<RichDataDisplay data={data} availableWidth={80} />,
);
const output = lastFrame();
expect(output).toContain('Market Share');
expect(output).toContain('A');
expect(output).toContain('B');
expect(output).toContain('50.0%');
expect(output).toContain('█'); // Check for proportional bar
});
it('should show saved file path', () => {
const data = {
type: 'table' as const,
data: [],
savedFilePath: '/path/to/file.csv',
};
const { lastFrame } = renderWithProviders(
<RichDataDisplay data={data} availableWidth={80} />,
);
const output = lastFrame();
expect(output).toContain('Saved to: /path/to/file.csv');
});
it('should render diff visualization', () => {
const data = {
type: 'diff' as const,
data: {
fileDiff:
'diff --git a/file.txt b/file.txt\nindex 123..456 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-foo\n+bar',
fileName: 'file.txt',
},
};
const { lastFrame } = renderWithProviders(
<RichDataDisplay data={data} availableWidth={80} />,
);
const output = lastFrame();
expect(output).toContain('foo');
expect(output).toContain('bar');
});
});
@@ -0,0 +1,321 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { Table, type Column } from '../Table.js';
import { DiffRenderer } from './DiffRenderer.js';
import * as Diff from 'diff';
import type { RichVisualization } from '@google/gemini-cli-core';
import { getPlainTextLength } from '../../utils/InlineMarkdownRenderer.js';
interface RichDataDisplayProps {
data: RichVisualization;
availableWidth: number;
}
export const RichDataDisplay: React.FC<RichDataDisplayProps> = ({
data,
availableWidth,
}) => {
const {
type,
title,
data: rawData,
columns: providedColumns,
savedFilePath,
} = data;
const normalizeData = (
data: unknown[],
providedCols: typeof providedColumns,
) =>
data.map((item) => {
const record = item as Record<string, unknown>;
let label = 'Unknown';
let value = 0;
if (providedCols && providedCols.length >= 2) {
label = String(record[providedCols[0].key]);
value = Number(record[providedCols[1].key]);
} else {
// Auto-detect
const keys = Object.keys(record);
const labelKey =
keys.find((k) => typeof record[k] === 'string') || keys[0];
const valueKey = keys.find((k) => typeof record[k] === 'number');
if (labelKey) label = String(record[labelKey]);
if (valueKey) value = Number(record[valueKey]);
}
return { label, value };
});
const renderContent = () => {
if (type === 'table' && Array.isArray(rawData)) {
const tableData = rawData as Array<Record<string, unknown>>;
// Infer columns if not provided
let columns: Array<Column<Record<string, unknown>>> = [];
if (providedColumns) {
columns = providedColumns.map((col) => ({
key: col.key,
header: col.label,
}));
} else if (tableData.length > 0) {
columns = Object.keys(tableData[0]).map((key) => ({
key,
header: key,
}));
}
// Calculate widths based on content
const paddingPerCol = 2; // Extra buffer
const columnContentWidths = columns.map((col) => {
const headerWidth = getPlainTextLength(String(col.header));
const maxDataWidth = Math.max(
...tableData.map((row) =>
getPlainTextLength(String(row[col.key] || '')),
),
0,
);
return Math.max(headerWidth, maxDataWidth) + paddingPerCol;
});
const totalContentWidth = columnContentWidths.reduce((a, b) => a + b, 0);
if (totalContentWidth > availableWidth && columns.length > 0) {
// Scale down if exceeds available width
const scaleFactor = availableWidth / totalContentWidth;
columns = columns.map((col, i) => ({
...col,
width: Math.max(4, Math.floor(columnContentWidths[i] * scaleFactor)),
}));
} else {
// Use content widths or distribute remaining space
columns = columns.map((col, i) => ({
...col,
width: columnContentWidths[i],
}));
}
return <Table data={tableData} columns={columns} />;
} else if (type === 'bar_chart' && Array.isArray(rawData)) {
const normalized = normalizeData(rawData as unknown[], providedColumns);
const maxValue = Math.max(...normalized.map((d) => d.value), 1);
const maxLabelLen = Math.max(...normalized.map((d) => d.label.length), 1);
const barAreaWidth = Math.max(10, availableWidth - maxLabelLen - 10);
return (
<Box flexDirection="column">
{normalized.map((item, i) => {
const barLen = Math.max(
0,
Math.floor((item.value / maxValue) * barAreaWidth),
);
const bar = '█'.repeat(barLen);
return (
<Box key={i}>
<Text>{item.label.padEnd(maxLabelLen + 1)}</Text>
<Text color={theme.text.accent}>{bar}</Text>
<Text> {item.value}</Text>
</Box>
);
})}
</Box>
);
} else if (type === 'line_chart' && Array.isArray(rawData)) {
const normalized = normalizeData(rawData as unknown[], providedColumns);
if (normalized.length === 0) return <Text>No data to display.</Text>;
const maxValue = Math.max(...normalized.map((d) => d.value), 0);
const minValue = Math.min(...normalized.map((d) => d.value), 0);
const range = Math.max(maxValue - minValue, 1);
const chartHeight = 10;
// Plotting
const rows: string[][] = Array.from({ length: chartHeight }, () =>
Array.from({ length: normalized.length }, () => ' '),
);
normalized.forEach((item, x) => {
const y = Math.min(
chartHeight - 1,
Math.max(
0,
Math.floor(((item.value - minValue) / range) * (chartHeight - 1)),
),
);
rows[chartHeight - 1 - y][x] = '•';
});
return (
<Box flexDirection="column" marginTop={1}>
{rows.map((row, i) => {
const yValue =
minValue + (range * (chartHeight - 1 - i)) / (chartHeight - 1);
return (
<Box key={i}>
<Text color={theme.text.secondary} dimColor>
{yValue.toFixed(1).padStart(8)}
</Text>
<Text color={theme.text.accent}>{row.join(' ')}</Text>
</Box>
);
})}
<Box marginLeft={10}>
<Text color={theme.text.secondary} dimColor>
{'──'.repeat(normalized.length)}
</Text>
</Box>
<Box marginLeft={10}>
{normalized.map((item, i) => (
<Box key={i} width={3}>
<Text color={theme.text.secondary} dimColor wrap="truncate-end">
{item.label}
</Text>
</Box>
))}
</Box>
</Box>
);
} else if (type === 'pie_chart' && Array.isArray(rawData)) {
const normalized = normalizeData(rawData as unknown[], providedColumns);
const total = normalized.reduce((sum, item) => sum + item.value, 0);
const colors = [
theme.text.accent,
theme.status.success,
theme.status.warning,
theme.status.info,
'#FF6B6B',
'#4D96FF',
'#6BCB77',
'#FFD93D',
];
return (
<Box flexDirection="column" marginTop={1}>
{/* Proportional Bar */}
<Box height={1} marginBottom={1}>
{normalized.map((item, i) => {
const percent = total > 0 ? item.value / total : 0;
const barWidth = Math.max(
1,
Math.floor(percent * availableWidth),
);
return (
<Text key={i} color={colors[i % colors.length]}>
{'█'.repeat(barWidth)}
</Text>
);
})}
</Box>
{/* Legend */}
{normalized.map((item, i) => {
const percent = total > 0 ? (item.value / total) * 100 : 0;
return (
<Box key={i}>
<Text color={colors[i % colors.length]}> </Text>
<Text bold>{item.label}: </Text>
<Text>{item.value} </Text>
<Text color={theme.text.secondary}>
({percent.toFixed(1)}%)
</Text>
</Box>
);
})}
</Box>
);
} else if (
type === 'diff' &&
(typeof rawData === 'object' || typeof rawData === 'string') &&
rawData
) {
let diffContent: string | undefined;
let filename = 'Diff';
if (typeof rawData === 'string') {
diffContent = rawData;
} else {
const diffData = rawData as {
fileDiff?: string;
fileName?: string;
old?: string;
new?: string;
oldContent?: string;
newContent?: string;
originalContent?: string;
};
diffContent = diffData.fileDiff;
filename = diffData.fileName || 'Diff';
if (!diffContent) {
const oldVal =
diffData.old ?? diffData.oldContent ?? diffData.originalContent;
const newVal = diffData.new ?? diffData.newContent;
if (oldVal !== undefined && newVal !== undefined) {
diffContent = Diff.createPatch(
filename,
String(oldVal),
String(newVal),
);
}
}
}
if (diffContent) {
return (
<DiffRenderer
diffContent={diffContent}
filename={filename}
availableTerminalHeight={20} // Reasonable default or pass from props
terminalWidth={availableWidth}
/>
);
} else {
return (
<Box flexDirection="column">
<Text color={theme.status.error}>
Error: Diff data missing &apos;fileDiff&apos; property.
</Text>
<Text color={theme.text.secondary} dimColor>
Expected data to be a string or an object with
&apos;fileDiff&apos;, or both &apos;old&apos; and &apos;new&apos;
content.
</Text>
<Text color={theme.text.secondary} dimColor>
Received keys:{' '}
{typeof rawData === 'object'
? Object.keys(rawData).join(', ')
: 'none (string)'}
</Text>
</Box>
);
}
}
return <Text>Unknown visualization type: {type}</Text>;
};
return (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
{title && (
<Text bold color={theme.text.accent} underline>
{title}
</Text>
)}
{renderContent()}
{savedFilePath && (
<Text color={theme.status.success} dimColor>
{`Saved to: ${savedFilePath}`}
</Text>
)}
</Box>
);
};
@@ -301,4 +301,44 @@ describe('ToolResultDisplay', () => {
expect(output).not.toContain('Line 1');
expect(output).toContain('Line 50');
});
it('renders rich visualization result (diff)', () => {
const richResult = {
type: 'diff' as const,
data: {
fileDiff:
'diff --git a/test.ts b/test.ts\n--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
fileName: 'test.ts',
},
};
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay={richResult}
terminalWidth={80}
availableTerminalHeight={20}
/>,
);
const output = lastFrame();
expect(output).toContain('old');
expect(output).toContain('new');
});
it('renders rich visualization result (table)', () => {
const richResult = {
type: 'table' as const,
data: [{ name: 'Test', value: 123 }],
};
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay={richResult}
terminalWidth={80}
availableTerminalHeight={20}
/>,
);
const output = lastFrame();
expect(output).toContain('Test');
expect(output).toContain('123');
});
});
@@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core';
import type {
AnsiOutput,
AnsiLine,
RichVisualization,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -19,6 +23,7 @@ import { Scrollable } from '../shared/Scrollable.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { RichDataDisplay } from './RichDataDisplay.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
@@ -190,6 +195,20 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
terminalWidth={childWidth}
/>
);
} else if (
typeof truncatedResultDisplay === 'object' &&
'type' in truncatedResultDisplay &&
'data' in truncatedResultDisplay &&
['table', 'bar_chart', 'pie_chart', 'line_chart', 'diff'].includes(
(truncatedResultDisplay as RichVisualization).type,
)
) {
content = (
<RichDataDisplay
data={truncatedResultDisplay as RichVisualization}
availableWidth={childWidth}
/>
);
} else {
const shouldDisableTruncation =
isAlternateBuffer ||
+1
View File
@@ -55,6 +55,7 @@ const noColorSemanticColors: SemanticColors = {
error: '',
success: '',
warning: '',
info: '',
},
};
@@ -35,6 +35,7 @@ export interface SemanticColors {
error: string;
success: string;
warning: string;
info: string;
};
}
@@ -67,6 +68,7 @@ export const lightSemanticColors: SemanticColors = {
error: lightTheme.AccentRed,
success: lightTheme.AccentGreen,
warning: lightTheme.AccentYellow,
info: lightTheme.AccentBlue,
},
};
@@ -99,6 +101,7 @@ export const darkSemanticColors: SemanticColors = {
error: darkTheme.AccentRed,
success: darkTheme.AccentGreen,
warning: darkTheme.AccentYellow,
info: darkTheme.AccentBlue,
},
};
@@ -131,5 +134,6 @@ export const ansiSemanticColors: SemanticColors = {
error: ansiTheme.AccentRed,
success: ansiTheme.AccentGreen,
warning: ansiTheme.AccentYellow,
info: ansiTheme.AccentBlue,
},
};
+2
View File
@@ -149,6 +149,7 @@ export class Theme {
error: this.colors.AccentRed,
success: this.colors.AccentGreen,
warning: this.colors.AccentYellow,
info: this.colors.AccentBlue,
},
};
this._colorMap = Object.freeze(this._buildColorMap(rawMappings)); // Build and freeze the map
@@ -414,6 +415,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
error: customTheme.status?.error ?? colors.AccentRed,
success: customTheme.status?.success ?? colors.AccentGreen,
warning: customTheme.status?.warning ?? colors.AccentYellow,
info: customTheme.status?.info ?? colors.AccentBlue,
},
};
@@ -177,13 +177,67 @@ export const RenderInline = React.memo(RenderInlineInternal);
* This is useful for calculating column widths in tables
*/
export const getPlainTextLength = (text: string): number => {
const cleanText = text
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/`(.*?)`/g, '$1')
.replace(/<u>(.*?)<\/u>/g, '$1')
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
const inlineRegex =
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
const cleanText = text.replace(inlineRegex, (fullMatch) => {
if (
fullMatch.startsWith('**') &&
fullMatch.endsWith('**') &&
fullMatch.length > BOLD_MARKER_LENGTH * 2
) {
return fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH);
}
if (
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
(fullMatch.startsWith('_') && fullMatch.endsWith('_')))
) {
return fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH);
}
if (
fullMatch.startsWith('~~') &&
fullMatch.endsWith('~~') &&
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
) {
return fullMatch.slice(
STRIKETHROUGH_MARKER_LENGTH,
-STRIKETHROUGH_MARKER_LENGTH,
);
}
if (
fullMatch.startsWith('`') &&
fullMatch.endsWith('`') &&
fullMatch.length > INLINE_CODE_MARKER_LENGTH
) {
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
return codeMatch[2];
}
}
if (
fullMatch.startsWith('[') &&
fullMatch.includes('](') &&
fullMatch.endsWith(')')
) {
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
return linkMatch[1];
}
}
if (
fullMatch.startsWith('<u>') &&
fullMatch.endsWith('</u>') &&
fullMatch.length >
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1
) {
return fullMatch.slice(
UNDERLINE_TAG_START_LENGTH,
-UNDERLINE_TAG_END_LENGTH,
);
}
return fullMatch;
});
return stringWidth(cleanText);
};