diff --git a/packages/cli/src/ui/components/DebugProfiler.tsx b/packages/cli/src/ui/components/DebugProfiler.tsx
index e68b3018dd..7448713ca1 100644
--- a/packages/cli/src/ui/components/DebugProfiler.tsx
+++ b/packages/cli/src/ui/components/DebugProfiler.tsx
@@ -106,10 +106,6 @@ export const profiler = {
}
if (idleInPastSecond >= 5) {
- if (this.openedDebugConsole === false) {
- this.openedDebugConsole = true;
- appEvents.emit(AppEvent.OpenDebugConsole);
- }
debugLogger.error(
`${idleInPastSecond} frames rendered while the app was ` +
`idle in the past second. This likely indicates severe infinite loop ` +
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
index 797e405b62..d54d3ed6dc 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx
@@ -8,6 +8,7 @@ import { renderWithProviders } from '../../../test-utils/render.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { AnsiOutput } from '@google/gemini-cli-core';
+import type { VisualizationResult as VisualizationDisplay } from './VisualizationDisplay.js';
// Mock UIStateContext partially
const mockUseUIState = vi.fn();
@@ -190,6 +191,77 @@ describe('ToolResultDisplay', () => {
expect(output).toMatchSnapshot();
});
+ it('renders visualization result', () => {
+ const visualization: VisualizationDisplay = {
+ type: 'visualization',
+ kind: 'bar',
+ title: 'Fastest BMW 0-60',
+ unit: 's',
+ data: {
+ series: [
+ {
+ name: 'BMW',
+ points: [
+ { label: 'M5 CS', value: 2.9 },
+ { label: 'M8 Competition', value: 3.0 },
+ ],
+ },
+ ],
+ },
+ meta: {
+ truncated: false,
+ originalItemCount: 2,
+ },
+ };
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('Fastest BMW 0-60');
+ expect(output).toContain('M5 CS');
+ expect(output).toContain('2.90s');
+ });
+
+ it('renders diagram visualization result', () => {
+ const visualization: VisualizationDisplay = {
+ type: 'visualization',
+ kind: 'diagram',
+ title: 'Service Graph',
+ data: {
+ diagramKind: 'architecture',
+ nodes: [
+ { id: 'ui', label: 'Web UI', type: 'frontend' },
+ { id: 'api', label: 'API', type: 'service' },
+ ],
+ edges: [{ from: 'ui', to: 'api', label: 'HTTPS' }],
+ },
+ meta: {
+ truncated: false,
+ originalItemCount: 2,
+ },
+ };
+
+ const { lastFrame } = render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('Service Graph');
+ expect(output).toContain('Web UI');
+ expect(output).toContain('API');
+ expect(output).toContain('Notes:');
+ expect(output).toContain('Web UI -> API: HTTPS');
+ });
+
it('does not fall back to plain text if availableHeight is set and not in alternate buffer', () => {
mockUseAlternateBuffer.mockReturnValue(false);
// availableHeight calculation: 20 - 1 - 5 = 14 > 3
diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
index 61f1540017..cfa8d78b7a 100644
--- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
+++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
@@ -19,6 +19,10 @@ 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 {
+ VisualizationResultDisplay,
+ type VisualizationResult,
+} from './VisualizationDisplay.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
@@ -180,6 +184,19 @@ export const ToolResultDisplay: React.FC = ({
{truncatedResultDisplay}
);
+ } else if (
+ typeof truncatedResultDisplay === 'object' &&
+ 'type' in truncatedResultDisplay &&
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ (truncatedResultDisplay as VisualizationResult).type === 'visualization'
+ ) {
+ content = (
+
+ );
} else if (
typeof truncatedResultDisplay === 'object' &&
'fileDiff' in truncatedResultDisplay
diff --git a/packages/cli/src/ui/components/messages/VisualizationDisplay.test.tsx b/packages/cli/src/ui/components/messages/VisualizationDisplay.test.tsx
new file mode 100644
index 0000000000..092f543f32
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/VisualizationDisplay.test.tsx
@@ -0,0 +1,162 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import { renderWithProviders } from '../../../test-utils/render.js';
+import {
+ VisualizationResultDisplay,
+ type VisualizationResult as VisualizationDisplay,
+} from './VisualizationDisplay.js';
+
+describe('VisualizationResultDisplay', () => {
+ const render = (ui: React.ReactElement) => renderWithProviders(ui);
+
+ it('renders bar visualization', () => {
+ const visualization: VisualizationDisplay = {
+ type: 'visualization',
+ kind: 'bar',
+ title: 'BMW 0-60',
+ unit: 's',
+ data: {
+ series: [
+ {
+ name: 'BMW',
+ points: [
+ { label: 'M5 CS', value: 2.9 },
+ { label: 'M8', value: 3.0 },
+ ],
+ },
+ ],
+ },
+ meta: {
+ truncated: false,
+ originalItemCount: 2,
+ },
+ };
+
+ const { lastFrame } = render(
+ ,
+ );
+
+ expect(lastFrame()).toContain('BMW 0-60');
+ expect(lastFrame()).toContain('M5 CS');
+ expect(lastFrame()).toContain('2.90s');
+ });
+
+ it('renders line and pie visualizations', () => {
+ const line: VisualizationDisplay = {
+ type: 'visualization',
+ kind: 'line',
+ title: 'BMW models by year',
+ xLabel: 'Year',
+ yLabel: 'Models',
+ data: {
+ series: [
+ {
+ name: 'Total',
+ points: [
+ { label: '2021', value: 15 },
+ { label: '2022', value: 16 },
+ { label: '2023', value: 17 },
+ ],
+ },
+ ],
+ },
+ meta: {
+ truncated: false,
+ originalItemCount: 3,
+ },
+ };
+
+ const pie: VisualizationDisplay = {
+ type: 'visualization',
+ kind: 'pie',
+ data: {
+ slices: [
+ { label: 'M', value: 40 },
+ { label: 'X', value: 60 },
+ ],
+ },
+ meta: {
+ truncated: false,
+ originalItemCount: 2,
+ },
+ };
+
+ const lineFrame = render(
+ ,
+ ).lastFrame();
+ const pieFrame = render(
+ ,
+ ).lastFrame();
+
+ expect(lineFrame).toContain('Total:');
+ expect(lineFrame).toContain('x: Year | y: Models');
+ expect(pieFrame).toContain('Slice');
+ expect(pieFrame).toContain('Share');
+ });
+
+ it('renders rich table visualization with metric bars', () => {
+ const visualization: VisualizationDisplay = {
+ type: 'visualization',
+ kind: 'table',
+ title: 'Risk Table',
+ data: {
+ columns: ['Path', 'Score', 'Lines'],
+ rows: [
+ ['src/core.ts', 95, 210],
+ ['src/ui.tsx', 45, 80],
+ ],
+ metricColumns: [1],
+ },
+ meta: {
+ truncated: true,
+ originalItemCount: 8,
+ },
+ };
+
+ const { lastFrame } = render(
+ ,
+ );
+
+ const frame = lastFrame();
+ expect(frame).toContain('Risk Table');
+ expect(frame).toContain('Path');
+ expect(frame).toContain('Showing truncated data (8 original items)');
+ });
+
+ it('renders diagram visualization with UML-like nodes', () => {
+ const visualization: VisualizationDisplay = {
+ type: 'visualization',
+ kind: 'diagram',
+ title: 'Service Architecture',
+ data: {
+ diagramKind: 'architecture',
+ direction: 'LR',
+ nodes: [
+ { id: 'ui', label: 'Web UI', type: 'frontend' },
+ { id: 'api', label: 'API', type: 'service' },
+ ],
+ edges: [{ from: 'ui', to: 'api', label: 'HTTPS' }],
+ },
+ meta: {
+ truncated: false,
+ originalItemCount: 2,
+ },
+ };
+
+ const { lastFrame } = render(
+ ,
+ );
+
+ const frame = lastFrame();
+ expect(frame).toContain('Service Architecture');
+ expect(frame).toContain('┌');
+ expect(frame).toContain('>');
+ expect(frame).toContain('Notes:');
+ expect(frame).toContain('Web UI -> API: HTTPS');
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/VisualizationDisplay.tsx b/packages/cli/src/ui/components/messages/VisualizationDisplay.tsx
new file mode 100644
index 0000000000..53656c7328
--- /dev/null
+++ b/packages/cli/src/ui/components/messages/VisualizationDisplay.tsx
@@ -0,0 +1,1090 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import { theme } from '../../semantic-colors.js';
+
+export type VisualizationKind = 'bar' | 'line' | 'pie' | 'table' | 'diagram';
+
+type PrimitiveCell = string | number | boolean;
+
+export interface VisualizationPoint {
+ label: string;
+ value: number;
+}
+
+export interface VisualizationSeries {
+ name: string;
+ points: VisualizationPoint[];
+}
+
+export interface VisualizationResult {
+ type: 'visualization';
+ kind: VisualizationKind;
+ title?: string;
+ subtitle?: string;
+ xLabel?: string;
+ yLabel?: string;
+ unit?: string;
+ data: Record;
+ meta?: {
+ truncated: boolean;
+ originalItemCount: number;
+ validationWarnings?: string[];
+ };
+}
+
+interface VisualizationResultDisplayProps {
+ visualization: VisualizationResult;
+ width: number;
+}
+
+interface DiagramNode {
+ id: string;
+ label: string;
+ type?: string;
+}
+
+interface DiagramEdge {
+ from: string;
+ to: string;
+ label?: string;
+}
+
+interface RenderLine {
+ text: string;
+ color?: string;
+ bold?: boolean;
+ dimColor?: boolean;
+}
+
+interface VisualizationColorSet {
+ primary: string;
+ secondary: string;
+ accent: string;
+ success: string;
+ warning: string;
+ error: string;
+ palette: string[];
+}
+
+const UNICODE_SPARK_CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
+const ASCII_SPARK_CHARS = ['.', ':', '-', '=', '+', '*', '#', '@'];
+
+function truncateText(value: string, maxWidth: number): string {
+ if (maxWidth <= 0) {
+ return '';
+ }
+ if (value.length <= maxWidth) {
+ return value;
+ }
+ if (maxWidth <= 1) {
+ return value.slice(0, maxWidth);
+ }
+ return `${value.slice(0, maxWidth - 1)}…`;
+}
+
+function padRight(value: string, width: number): string {
+ if (value.length >= width) {
+ return value;
+ }
+ return value + ' '.repeat(width - value.length);
+}
+
+function formatValue(value: number, unit?: string): string {
+ const rendered = Number.isInteger(value) ? String(value) : value.toFixed(2);
+ return unit ? `${rendered}${unit}` : rendered;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function getString(value: unknown, fallback: string): string {
+ if (typeof value === 'string' && value.trim().length > 0) {
+ return value.trim();
+ }
+ return fallback;
+}
+
+function getNumber(value: unknown, fallback = 0): number {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === 'string') {
+ const parsed = Number(value.trim().replace(/,/g, ''));
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+ return fallback;
+}
+
+function normalizeCell(value: unknown): PrimitiveCell {
+ if (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ ) {
+ return value;
+ }
+ if (value === null || value === undefined) {
+ return '';
+ }
+ return JSON.stringify(value);
+}
+
+function makeBar(value: number, max: number, width: number): string {
+ const safeMax = max <= 0 ? 1 : max;
+ const ratio = Math.max(0, Math.min(1, value / safeMax));
+ const filled = Math.max(0, Math.round(ratio * width));
+ const empty = Math.max(0, width - filled);
+ return `${'█'.repeat(filled)}${'░'.repeat(empty)}`;
+}
+
+function asSeries(data: Record): VisualizationSeries[] {
+ const rawSeries = data['series'];
+ if (!Array.isArray(rawSeries)) {
+ return [];
+ }
+
+ return rawSeries
+ .map((raw, idx) => {
+ if (!isRecord(raw) || !Array.isArray(raw['points'])) {
+ return null;
+ }
+
+ const points = raw['points']
+ .map((point): VisualizationPoint | null => {
+ if (!isRecord(point)) {
+ return null;
+ }
+ if (typeof point['label'] !== 'string') {
+ return null;
+ }
+ if (typeof point['value'] !== 'number') {
+ return null;
+ }
+ return {
+ label: point['label'],
+ value: point['value'],
+ };
+ })
+ .filter((point): point is VisualizationPoint => point !== null);
+
+ return {
+ name:
+ typeof raw['name'] === 'string' && raw['name'].trim().length > 0
+ ? raw['name']
+ : `Series ${idx + 1}`,
+ points,
+ };
+ })
+ .filter(
+ (series): series is VisualizationSeries =>
+ series !== null && series.points.length > 0,
+ );
+}
+
+function asSlices(
+ data: Record,
+): Array<{ label: string; value: number }> {
+ const rawSlices = Array.isArray(data['slices']) ? data['slices'] : [];
+ return rawSlices
+ .map((slice) => {
+ if (!isRecord(slice)) {
+ return null;
+ }
+ if (
+ typeof slice['label'] !== 'string' ||
+ typeof slice['value'] !== 'number'
+ ) {
+ return null;
+ }
+ return {
+ label: slice['label'],
+ value: slice['value'],
+ };
+ })
+ .filter(
+ (slice): slice is { label: string; value: number } => slice !== null,
+ );
+}
+
+function asTable(data: Record): {
+ columns: string[];
+ rows: PrimitiveCell[][];
+ metricColumns: number[];
+} {
+ const rawRows = Array.isArray(data['rows']) ? data['rows'] : [];
+ const rawColumns = Array.isArray(data['columns']) ? data['columns'] : [];
+
+ const columns = rawColumns.map((column, idx) =>
+ getString(column, `Column ${idx + 1}`),
+ );
+ const rows = rawRows
+ .filter((row) => Array.isArray(row) || isRecord(row))
+ .map((row) => {
+ if (Array.isArray(row)) {
+ return row.map((cell) => normalizeCell(cell));
+ }
+
+ const record = row;
+ const keys = columns.length > 0 ? columns : Object.keys(record);
+ return keys.map((key) => normalizeCell(record[key]));
+ });
+
+ const inferredColumns =
+ columns.length > 0
+ ? columns
+ : Array.from(
+ { length: rows.reduce((max, row) => Math.max(max, row.length), 0) },
+ (_, idx) => `Column ${idx + 1}`,
+ );
+
+ const explicitMetricColumns = Array.isArray(data['metricColumns'])
+ ? data['metricColumns']
+ .map((value) => getNumber(value, -1))
+ .filter(
+ (value) =>
+ Number.isInteger(value) &&
+ value >= 0 &&
+ value < inferredColumns.length,
+ )
+ : [];
+
+ const autoMetricColumns =
+ explicitMetricColumns.length > 0
+ ? explicitMetricColumns
+ : inferredColumns
+ .map((_, index) => index)
+ .filter((index) =>
+ rows.some((row) => typeof row[index] === 'number'),
+ );
+
+ return {
+ columns: inferredColumns,
+ rows,
+ metricColumns: autoMetricColumns,
+ };
+}
+
+function asDiagram(data: Record): {
+ diagramKind: 'architecture' | 'flowchart';
+ direction: 'LR' | 'TB';
+ nodes: DiagramNode[];
+ edges: DiagramEdge[];
+} {
+ const diagramKindRaw = getString(data['diagramKind'], 'architecture');
+ const diagramKind =
+ diagramKindRaw === 'flowchart' ? 'flowchart' : 'architecture';
+ const directionRaw = getString(data['direction'], 'LR');
+ const direction = directionRaw === 'TB' ? 'TB' : 'LR';
+
+ const rawNodes = Array.isArray(data['nodes']) ? data['nodes'] : [];
+ const rawEdges = Array.isArray(data['edges']) ? data['edges'] : [];
+
+ const nodes = rawNodes
+ .filter(isRecord)
+ .map((node) => ({
+ id: getString(node['id'], ''),
+ label: getString(node['label'], '(node)'),
+ type:
+ typeof node['type'] === 'string' && node['type'].trim().length > 0
+ ? node['type'].trim()
+ : undefined,
+ }))
+ .filter((node) => node.id.length > 0);
+
+ const edges = rawEdges
+ .filter(isRecord)
+ .map((edge) => ({
+ from: getString(edge['from'], ''),
+ to: getString(edge['to'], ''),
+ label:
+ typeof edge['label'] === 'string' && edge['label'].trim().length > 0
+ ? edge['label'].trim()
+ : undefined,
+ }))
+ .filter((edge) => edge.from.length > 0 && edge.to.length > 0);
+
+ return {
+ diagramKind,
+ direction,
+ nodes,
+ edges,
+ };
+}
+
+function uniqueColors(values: string[]): string[] {
+ const seen = new Set();
+ const colors: string[] = [];
+ for (const value of values) {
+ const color = value.trim();
+ if (color.length === 0 || seen.has(color)) {
+ continue;
+ }
+ seen.add(color);
+ colors.push(color);
+ }
+ return colors;
+}
+
+function buildColorSet(): VisualizationColorSet {
+ const gradient =
+ Array.isArray(theme.ui.gradient) && theme.ui.gradient.length > 0
+ ? theme.ui.gradient
+ : [];
+ const palette = uniqueColors([
+ ...gradient,
+ theme.text.link,
+ theme.text.accent,
+ theme.status.success,
+ theme.status.warning,
+ theme.status.error,
+ ]);
+
+ return {
+ primary: theme.text.primary,
+ secondary: theme.text.secondary,
+ accent: theme.text.accent,
+ success: theme.status.success,
+ warning: theme.status.warning,
+ error: theme.status.error,
+ palette: palette.length > 0 ? palette : [theme.text.link],
+ };
+}
+
+function colorAtPalette(
+ palette: string[],
+ index: number,
+ fallback: string,
+): string {
+ if (palette.length === 0) {
+ return fallback;
+ }
+ return palette[index % palette.length] ?? fallback;
+}
+
+function noDataLine(colors: VisualizationColorSet): RenderLine[] {
+ return [{ text: '(no data)', color: colors.secondary, dimColor: true }];
+}
+
+function styleTableLines(
+ lines: string[],
+ colors: VisualizationColorSet,
+): RenderLine[] {
+ if (lines.length === 1 && lines[0] === '(no data)') {
+ return noDataLine(colors);
+ }
+
+ return lines.map((text, index) => {
+ if (index === 0) {
+ return { text, color: colors.accent, bold: true };
+ }
+ if (index === 1) {
+ return { text, color: colors.secondary };
+ }
+ return {
+ text,
+ color: colorAtPalette(colors.palette, index - 2, colors.primary),
+ };
+ });
+}
+
+function styleDiagramLines(
+ lines: string[],
+ colors: VisualizationColorSet,
+): RenderLine[] {
+ if (lines.length === 1 && lines[0] === '(no data)') {
+ return noDataLine(colors);
+ }
+
+ let accentIndex = 0;
+ return lines.map((text, index) => {
+ if (text === 'Notes:') {
+ return { text, color: colors.accent, bold: true };
+ }
+ if (text.startsWith('Kind:')) {
+ return { text, color: colors.secondary };
+ }
+ if (text.includes('->') && text.includes(':')) {
+ return { text, color: colors.warning };
+ }
+ if (text.trim().length === 0) {
+ return { text };
+ }
+ if (
+ text.includes('┌') ||
+ text.includes('┐') ||
+ text.includes('└') ||
+ text.includes('┘') ||
+ text.includes('│') ||
+ text.includes('─') ||
+ text.includes('┼') ||
+ text.includes('>') ||
+ text.includes('v')
+ ) {
+ return {
+ text,
+ color: colorAtPalette(colors.palette, accentIndex++, colors.primary),
+ };
+ }
+ return {
+ text,
+ color: colorAtPalette(colors.palette, index, colors.primary),
+ };
+ });
+}
+
+function renderBarLines(
+ series: VisualizationSeries,
+ width: number,
+ unit?: string,
+): string[] {
+ const points = series.points;
+ if (points.length === 0) {
+ return ['(no data)'];
+ }
+
+ const labelWidth = Math.max(
+ 6,
+ Math.min(
+ Math.floor(width * 0.35),
+ points.reduce((m, p) => Math.max(m, p.label.length), 0),
+ ),
+ );
+ const values = points.map((point) => formatValue(point.value, unit));
+ const valueWidth = values.reduce(
+ (acc, value) => Math.max(acc, value.length),
+ 0,
+ );
+ const barWidth = Math.max(6, width - labelWidth - valueWidth - 4);
+ const maxValue = Math.max(...points.map((point) => point.value), 1);
+
+ return points.map((point, index) => {
+ const bar = makeBar(point.value, maxValue, barWidth);
+ const label = padRight(truncateText(point.label, labelWidth), labelWidth);
+ const value = padRight(values[index], valueWidth);
+ return `${label} | ${bar} ${value}`;
+ });
+}
+
+function downsamplePoints(
+ points: VisualizationPoint[],
+ targetSize: number,
+): VisualizationPoint[] {
+ if (points.length <= targetSize || targetSize <= 2) {
+ return points;
+ }
+
+ const sampled: VisualizationPoint[] = [points[0]];
+ const interval = (points.length - 1) / (targetSize - 1);
+ for (let i = 1; i < targetSize - 1; i += 1) {
+ sampled.push(points[Math.round(i * interval)]);
+ }
+ sampled.push(points[points.length - 1]);
+ return sampled;
+}
+
+function renderSparkline(
+ points: VisualizationPoint[],
+ width: number,
+ useAscii: boolean,
+): string {
+ if (points.length === 0) {
+ return '';
+ }
+
+ const chars = useAscii ? ASCII_SPARK_CHARS : UNICODE_SPARK_CHARS;
+ const chartWidth = Math.max(6, width);
+ const sampled = downsamplePoints(points, chartWidth);
+ const min = Math.min(...sampled.map((point) => point.value));
+ const max = Math.max(...sampled.map((point) => point.value));
+ const range = max - min;
+
+ return sampled
+ .map((point) => {
+ if (range === 0) {
+ return chars[Math.floor(chars.length / 2)];
+ }
+ const normalized = (point.value - min) / range;
+ const index = Math.round(normalized * (chars.length - 1));
+ return chars[index];
+ })
+ .join('');
+}
+
+function renderLineLines(
+ series: VisualizationSeries[],
+ width: number,
+ unit?: string,
+): string[] {
+ const lines: string[] = [];
+ const useAscii = width < 48;
+
+ for (const [index, currentSeries] of series.entries()) {
+ const name = currentSeries.name?.trim() || `Series ${index + 1}`;
+ const sparklineWidth = Math.max(12, width - name.length - 3);
+ const sparkline = renderSparkline(
+ currentSeries.points,
+ sparklineWidth,
+ useAscii,
+ );
+
+ const first = currentSeries.points[0];
+ const last = currentSeries.points[currentSeries.points.length - 1];
+ const summary =
+ first && last
+ ? ` (${first.label}: ${formatValue(first.value, unit)} -> ${last.label}: ${formatValue(last.value, unit)})`
+ : '';
+
+ lines.push(`${name}: ${sparkline}${summary}`);
+ }
+
+ return lines;
+}
+
+function renderTableLines(
+ table: {
+ columns: string[];
+ rows: PrimitiveCell[][];
+ metricColumns: number[];
+ },
+ width: number,
+): string[] {
+ if (table.columns.length === 0 || table.rows.length === 0) {
+ return ['(no data)'];
+ }
+
+ const maxColumns = Math.min(4, table.columns.length);
+ const visibleColumns = table.columns.slice(0, maxColumns);
+ const barColumn = table.metricColumns.find((index) => index < maxColumns);
+
+ const colWidth = Math.max(
+ 8,
+ Math.floor((width - (maxColumns - 1) * 3) / maxColumns),
+ );
+
+ const header = visibleColumns
+ .map((column) => padRight(truncateText(column, colWidth), colWidth))
+ .join(' | ');
+ const separator = '-'.repeat(Math.min(width, header.length));
+
+ let maxMetric = 1;
+ if (barColumn !== undefined) {
+ maxMetric = Math.max(
+ 1,
+ ...table.rows
+ .map((row) =>
+ typeof row[barColumn] === 'number' ? Number(row[barColumn]) : 0,
+ )
+ .filter((value) => Number.isFinite(value)),
+ );
+ }
+
+ const body = table.rows.map((row) => {
+ const cells = visibleColumns.map((_, index) => {
+ const raw = row[index];
+ return padRight(truncateText(String(raw ?? ''), colWidth), colWidth);
+ });
+
+ if (barColumn !== undefined && typeof row[barColumn] === 'number') {
+ const metricBar = makeBar(
+ Number(row[barColumn]),
+ maxMetric,
+ Math.max(8, Math.floor(width * 0.18)),
+ );
+ return `${cells.join(' | ')} ${metricBar}`;
+ }
+
+ return cells.join(' | ');
+ });
+
+ return [header, separator, ...body];
+}
+
+interface DiagramPlacement {
+ node: DiagramNode;
+ x: number;
+ y: number;
+}
+
+function drawCanvasChar(
+ canvas: string[][],
+ x: number,
+ y: number,
+ char: string,
+): void {
+ if (y < 0 || y >= canvas.length || x < 0 || x >= canvas[0].length) {
+ return;
+ }
+
+ const existing = canvas[y][x];
+ if (existing === ' ' || existing === char) {
+ canvas[y][x] = char;
+ return;
+ }
+
+ if (
+ (existing === '─' && char === '│') ||
+ (existing === '│' && char === '─')
+ ) {
+ canvas[y][x] = '┼';
+ return;
+ }
+
+ if (char === '>' || char === 'v') {
+ canvas[y][x] = char;
+ }
+}
+
+function drawHorizontal(
+ canvas: string[][],
+ y: number,
+ startX: number,
+ endX: number,
+): void {
+ const from = Math.min(startX, endX);
+ const to = Math.max(startX, endX);
+ for (let x = from; x <= to; x += 1) {
+ drawCanvasChar(canvas, x, y, '─');
+ }
+}
+
+function drawVertical(
+ canvas: string[][],
+ x: number,
+ startY: number,
+ endY: number,
+): void {
+ const from = Math.min(startY, endY);
+ const to = Math.max(startY, endY);
+ for (let y = from; y <= to; y += 1) {
+ drawCanvasChar(canvas, x, y, '│');
+ }
+}
+
+function drawBox(
+ canvas: string[][],
+ x: number,
+ y: number,
+ width: number,
+ label: string,
+): void {
+ drawCanvasChar(canvas, x, y, '┌');
+ drawCanvasChar(canvas, x + width - 1, y, '┐');
+ drawCanvasChar(canvas, x, y + 2, '└');
+ drawCanvasChar(canvas, x + width - 1, y + 2, '┘');
+
+ for (let i = 1; i < width - 1; i += 1) {
+ drawCanvasChar(canvas, x + i, y, '─');
+ drawCanvasChar(canvas, x + i, y + 2, '─');
+ }
+
+ drawCanvasChar(canvas, x, y + 1, '│');
+ drawCanvasChar(canvas, x + width - 1, y + 1, '│');
+
+ const text = truncateText(label, width - 4);
+ const padded = padRight(text, width - 4);
+ for (let i = 0; i < padded.length; i += 1) {
+ drawCanvasChar(canvas, x + 2 + i, y + 1, padded[i]);
+ }
+}
+
+function computeNodeRanks(
+ nodes: DiagramNode[],
+ edges: DiagramEdge[],
+): Map {
+ const rank = new Map();
+ const indegree = new Map();
+ const outgoing = new Map();
+
+ for (const node of nodes) {
+ rank.set(node.id, 0);
+ indegree.set(node.id, 0);
+ outgoing.set(node.id, []);
+ }
+
+ for (const edge of edges) {
+ if (!indegree.has(edge.from) || !indegree.has(edge.to)) {
+ continue;
+ }
+ indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
+ outgoing.get(edge.from)?.push(edge.to);
+ }
+
+ const queue: string[] = [];
+ for (const [id, count] of indegree.entries()) {
+ if (count === 0) {
+ queue.push(id);
+ }
+ }
+
+ while (queue.length > 0) {
+ const id = queue.shift();
+ if (!id) {
+ break;
+ }
+ const baseRank = rank.get(id) ?? 0;
+ for (const next of outgoing.get(id) ?? []) {
+ rank.set(next, Math.max(rank.get(next) ?? 0, baseRank + 1));
+ indegree.set(next, (indegree.get(next) ?? 1) - 1);
+ if ((indegree.get(next) ?? 0) === 0) {
+ queue.push(next);
+ }
+ }
+ }
+
+ return rank;
+}
+
+function layoutDiagram(
+ nodes: DiagramNode[],
+ edges: DiagramEdge[],
+ direction: 'LR' | 'TB',
+ widthLimit: number,
+): { placements: Map; boxWidth: number } | null {
+ const rankByNode = computeNodeRanks(nodes, edges);
+ const groups = new Map();
+ for (const node of nodes) {
+ const rank = rankByNode.get(node.id) ?? 0;
+ const group = groups.get(rank) ?? [];
+ group.push(node);
+ groups.set(rank, group);
+ }
+
+ const ranks = Array.from(groups.keys()).sort((a, b) => a - b);
+ if (ranks.length === 0) {
+ return null;
+ }
+
+ const maxLabel = Math.max(...nodes.map((node) => node.label.length), 8);
+ const rankCount = ranks.length;
+ const maxPerRank = Math.max(
+ ...ranks.map((rank) => groups.get(rank)?.length ?? 0),
+ 1,
+ );
+
+ let boxWidth = Math.max(12, Math.min(26, maxLabel + 4));
+ let hGap = 6;
+ const vGap = 2;
+ const boxHeight = 3;
+
+ const estimatedWidth = () => {
+ if (direction === 'LR') {
+ return rankCount * boxWidth + (rankCount - 1) * hGap;
+ }
+ return maxPerRank * boxWidth + (maxPerRank - 1) * hGap;
+ };
+
+ while (estimatedWidth() > widthLimit && (boxWidth > 10 || hGap > 2)) {
+ if (hGap > 2) {
+ hGap -= 1;
+ } else {
+ boxWidth -= 1;
+ }
+ }
+
+ if (estimatedWidth() > widthLimit) {
+ return null;
+ }
+
+ const placements = new Map();
+ if (direction === 'LR') {
+ const maxColumnHeight = Math.max(
+ ...ranks.map((rank) => {
+ const count = groups.get(rank)?.length ?? 0;
+ return count * boxHeight + Math.max(0, count - 1) * vGap;
+ }),
+ );
+
+ for (const [column, rank] of ranks.entries()) {
+ const group = groups.get(rank) ?? [];
+ const columnHeight =
+ group.length * boxHeight + Math.max(0, group.length - 1) * vGap;
+ const startY = Math.floor((maxColumnHeight - columnHeight) / 2);
+ for (const [row, node] of group.entries()) {
+ placements.set(node.id, {
+ node,
+ x: column * (boxWidth + hGap),
+ y: startY + row * (boxHeight + vGap),
+ });
+ }
+ }
+ } else {
+ const maxRowWidth = Math.max(
+ ...ranks.map((rank) => {
+ const count = groups.get(rank)?.length ?? 0;
+ return count * boxWidth + Math.max(0, count - 1) * hGap;
+ }),
+ );
+
+ for (const [rowIndex, rank] of ranks.entries()) {
+ const group = groups.get(rank) ?? [];
+ const rowWidth =
+ group.length * boxWidth + Math.max(0, group.length - 1) * hGap;
+ const startX = Math.floor((maxRowWidth - rowWidth) / 2);
+ for (const [column, node] of group.entries()) {
+ placements.set(node.id, {
+ node,
+ x: startX + column * (boxWidth + hGap),
+ y: rowIndex * (boxHeight + vGap),
+ });
+ }
+ }
+ }
+
+ return { placements, boxWidth };
+}
+
+function drawDiagramEdge(
+ canvas: string[][],
+ source: DiagramPlacement,
+ target: DiagramPlacement,
+ direction: 'LR' | 'TB',
+ boxWidth: number,
+): void {
+ const boxHeight = 3;
+
+ if (direction === 'LR') {
+ const sx = source.x + boxWidth;
+ const sy = source.y + 1;
+ const tx = target.x - 1;
+ const ty = target.y + 1;
+
+ const bendX = Math.max(sx + 1, Math.floor((sx + tx) / 2));
+ drawHorizontal(canvas, sy, sx, bendX);
+ drawVertical(canvas, bendX, sy, ty);
+ drawHorizontal(canvas, ty, bendX, tx);
+ drawCanvasChar(canvas, tx, ty, '>');
+ return;
+ }
+
+ const sx = source.x + Math.floor(boxWidth / 2);
+ const sy = source.y + boxHeight;
+ const tx = target.x + Math.floor(boxWidth / 2);
+ const ty = target.y - 1;
+
+ const bendY = Math.max(sy + 1, Math.floor((sy + ty) / 2));
+ drawVertical(canvas, sx, sy, bendY);
+ drawHorizontal(canvas, bendY, sx, tx);
+ drawVertical(canvas, tx, bendY, ty);
+ drawCanvasChar(canvas, tx, ty, 'v');
+}
+
+function canvasToLines(canvas: string[][]): string[] {
+ const rendered = canvas.map((row) => row.join('').replace(/\s+$/g, ''));
+ while (rendered.length > 0 && rendered[0].trim().length === 0) {
+ rendered.shift();
+ }
+ while (
+ rendered.length > 0 &&
+ rendered[rendered.length - 1].trim().length === 0
+ ) {
+ rendered.pop();
+ }
+ return rendered.length > 0 ? rendered : ['(no data)'];
+}
+
+function renderDiagramLines(
+ diagram: {
+ diagramKind: 'architecture' | 'flowchart';
+ direction: 'LR' | 'TB';
+ nodes: DiagramNode[];
+ edges: DiagramEdge[];
+ },
+ width: number,
+): string[] {
+ if (diagram.nodes.length === 0) {
+ return ['(no data)'];
+ }
+
+ const layout = layoutDiagram(
+ diagram.nodes,
+ diagram.edges,
+ diagram.direction,
+ Math.max(30, width - 1),
+ );
+ if (!layout) {
+ const nodeMap = new Map(diagram.nodes.map((node) => [node.id, node]));
+ const fallbackEdges =
+ diagram.edges.length === 0
+ ? ['(no connections)']
+ : diagram.edges.map((edge) => {
+ const from = nodeMap.get(edge.from)?.label ?? edge.from;
+ const to = nodeMap.get(edge.to)?.label ?? edge.to;
+ return `[${from}] -> [${to}]${edge.label ? ` (${edge.label})` : ''}`;
+ });
+ return [
+ ...fallbackEdges,
+ `Kind: ${diagram.diagramKind} | Direction: ${diagram.direction} | Layout: fallback`,
+ ];
+ }
+
+ const placements = Array.from(layout.placements.values());
+ const canvasWidth =
+ Math.max(...placements.map((item) => item.x + layout.boxWidth)) + 2;
+ const canvasHeight = Math.max(...placements.map((item) => item.y + 3)) + 2;
+ const canvas = Array.from({ length: canvasHeight }, () =>
+ Array.from({ length: canvasWidth }, () => ' '),
+ );
+
+ for (const placement of placements) {
+ const descriptor = placement.node.type
+ ? `${placement.node.label} «${placement.node.type}»`
+ : placement.node.label;
+ drawBox(canvas, placement.x, placement.y, layout.boxWidth, descriptor);
+ }
+
+ for (const edge of diagram.edges) {
+ const source = layout.placements.get(edge.from);
+ const target = layout.placements.get(edge.to);
+ if (!source || !target) {
+ continue;
+ }
+ drawDiagramEdge(canvas, source, target, diagram.direction, layout.boxWidth);
+ }
+
+ const lines: string[] = canvasToLines(canvas);
+ const labeledEdges = diagram.edges.filter((edge) => edge.label);
+ if (labeledEdges.length > 0) {
+ const nodeMap = new Map(diagram.nodes.map((node) => [node.id, node]));
+ lines.push('Notes:');
+ for (const edge of labeledEdges) {
+ const from = nodeMap.get(edge.from)?.label ?? edge.from;
+ const to = nodeMap.get(edge.to)?.label ?? edge.to;
+ lines.push(`${from} -> ${to}: ${edge.label}`);
+ }
+ }
+
+ lines.push(`Kind: ${diagram.diagramKind} | Direction: ${diagram.direction}`);
+ return lines;
+}
+
+function buildKindLines(
+ visualization: VisualizationResult,
+ width: number,
+ colors: VisualizationColorSet,
+): RenderLine[] {
+ if (!isRecord(visualization.data)) {
+ return [{ text: '(invalid visualization data)', color: colors.error }];
+ }
+
+ switch (visualization.kind) {
+ case 'bar': {
+ const series = asSeries(visualization.data);
+ if (series.length === 0) {
+ return noDataLine(colors);
+ }
+ return renderBarLines(series[0], width, visualization.unit).map(
+ (text, index) => ({
+ text,
+ color: colorAtPalette(colors.palette, index, colors.primary),
+ }),
+ );
+ }
+ case 'line': {
+ const series = asSeries(visualization.data);
+ if (series.length === 0) {
+ return noDataLine(colors);
+ }
+ return renderLineLines(series, width, visualization.unit).map(
+ (text, index) => ({
+ text,
+ color: colorAtPalette(colors.palette, index, colors.primary),
+ }),
+ );
+ }
+ case 'pie': {
+ const slices = asSlices(visualization.data);
+ if (slices.length === 0) {
+ return noDataLine(colors);
+ }
+ const total = slices.reduce((sum, slice) => sum + slice.value, 0);
+ const rows = slices.map((slice) => {
+ const pct =
+ total > 0 ? ((slice.value / total) * 100).toFixed(1) : '0.0';
+ return [
+ slice.label,
+ `${formatValue(slice.value, visualization.unit)} (${pct}%)`,
+ ];
+ });
+ return styleTableLines(
+ renderTableLines(
+ { columns: ['Slice', 'Share'], rows, metricColumns: [] },
+ width,
+ ),
+ colors,
+ );
+ }
+ case 'table': {
+ return styleTableLines(
+ renderTableLines(asTable(visualization.data), width),
+ colors,
+ );
+ }
+ case 'diagram': {
+ return styleDiagramLines(
+ renderDiagramLines(asDiagram(visualization.data), width),
+ colors,
+ );
+ }
+ default:
+ return [{ text: 'Unsupported visualization kind', color: colors.error }];
+ }
+}
+
+export const VisualizationResultDisplay = ({
+ visualization,
+ width,
+}: VisualizationResultDisplayProps) => {
+ const chartWidth = Math.max(20, width);
+ const colors = buildColorSet();
+ const lines = buildKindLines(visualization, chartWidth, colors);
+
+ return (
+
+ {visualization.title && (
+
+ {visualization.title}
+
+ )}
+ {visualization.subtitle && (
+ {visualization.subtitle}
+ )}
+ {lines.map((line, index) => (
+
+ {line.text}
+
+ ))}
+ {(visualization.xLabel || visualization.yLabel) && (
+
+ {visualization.xLabel ? `x: ${visualization.xLabel}` : ''}
+ {visualization.xLabel && visualization.yLabel ? ' | ' : ''}
+ {visualization.yLabel ? `y: ${visualization.yLabel}` : ''}
+
+ )}
+ {visualization.meta?.truncated && (
+
+ Showing truncated data ({visualization.meta.originalItemCount}{' '}
+ original items)
+
+ )}
+ {Array.isArray(visualization.meta?.validationWarnings) &&
+ visualization.meta.validationWarnings.length > 0 && (
+
+ Warnings: {visualization.meta.validationWarnings.join('; ')}
+
+ )}
+
+ );
+};
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 45a3a953b5..86906a86a0 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -36,6 +36,7 @@ import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
+import { RenderVisualizationTool } from '../tools/render-visualization.js';
import { GeminiClient } from '../core/client.js';
import { BaseLlmClient } from '../core/baseLlmClient.js';
import type { HookDefinition, HookEventName } from '../hooks/types.js';
@@ -2422,6 +2423,9 @@ export class Config {
maybeRegister(AskUserTool, () =>
registry.registerTool(new AskUserTool(this.messageBus)),
);
+ maybeRegister(RenderVisualizationTool, () =>
+ registry.registerTool(new RenderVisualizationTool(this.messageBus)),
+ );
if (this.getUseWriteTodos()) {
maybeRegister(WriteTodosTool, () =>
registry.registerTool(new WriteTodosTool(this.messageBus)),
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8232f73570..d165399ef6 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -157,6 +157,7 @@ export * from './tools/read-many-files.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-tool.js';
export * from './tools/write-todos.js';
+export * from './tools/render-visualization.js';
// MCP OAuth
export { MCPOAuthProvider } from './mcp/oauth-provider.js';
diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts
index 47f7e936cf..3d0164835c 100644
--- a/packages/core/src/prompts/promptProvider.ts
+++ b/packages/core/src/prompts/promptProvider.ts
@@ -28,6 +28,7 @@ import {
ENTER_PLAN_MODE_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
+ RENDER_VISUALIZATION_TOOL_NAME,
} from '../tools/tool-names.js';
import { resolveModel, isPreviewModel } from '../config/models.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
@@ -185,6 +186,9 @@ export class PromptProvider {
isGemini3,
enableShellEfficiency: config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: config.isInteractiveShellEnabled(),
+ enableVisualizationTool: enabledToolNames.has(
+ RENDER_VISUALIZATION_TOOL_NAME,
+ ),
}),
),
sandbox: this.withSection('sandbox', () => getSandboxMode()),
diff --git a/packages/core/src/prompts/snippets.legacy.ts b/packages/core/src/prompts/snippets.legacy.ts
index 8d46fd6a1a..1e2c092b26 100644
--- a/packages/core/src/prompts/snippets.legacy.ts
+++ b/packages/core/src/prompts/snippets.legacy.ts
@@ -15,6 +15,7 @@ import {
GREP_TOOL_NAME,
MEMORY_TOOL_NAME,
READ_FILE_TOOL_NAME,
+ RENDER_VISUALIZATION_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
@@ -61,6 +62,7 @@ export interface OperationalGuidelinesOptions {
isGemini3: boolean;
enableShellEfficiency: boolean;
interactiveShellEnabled: boolean;
+ enableVisualizationTool?: boolean;
}
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
@@ -270,7 +272,7 @@ ${shellEfficiencyGuidelines(options.enableShellEfficiency)}
- **Command Execution:** Use the '${SHELL_TOOL_NAME}' tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
options.interactive,
options.interactiveShellEnabled,
- )}${toolUsageRememberingFacts(options)}
+ )}${toolUsageVisualization(options.enableVisualizationTool)}${toolUsageRememberingFacts(options)}
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
## Interaction Details
@@ -621,6 +623,18 @@ function toolUsageRememberingFacts(
return base + suffix;
}
+function toolUsageVisualization(enabled?: boolean): string {
+ if (!enabled) {
+ return '';
+ }
+
+ return `
+- **Visualization Tool:** Use '${RENDER_VISUALIZATION_TOOL_NAME}' for compact visual output with these kinds only: \`bar\`, \`line\`, \`pie\`, \`table\`, \`diagram\`.
+- **Canonical data shapes:** bar/line -> \`data.series=[{name,points:[{label,value}]}]\`; pie -> \`data.slices=[{label,value}]\`; table -> \`data.columns + data.rows\`; diagram -> \`data.nodes + data.edges\` with optional \`direction: "LR"|"TB"\`.
+- **Shorthand accepted:** bar/line and pie also accept key/value maps; table accepts \`headers\` alias; diagram accepts \`links\`/\`connections\` and edge keys \`source/target\`.
+- **Selection rule:** For engineering dashboards (tests/build/risk/trace/coverage/cost), consolidate into a single rich \`table\`; for UML-like architecture and flow, use \`diagram\`.`;
+}
+
function gitRepoKeepUserInformed(interactive: boolean): string {
return interactive
? `
diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts
index 2508181816..c679514e01 100644
--- a/packages/core/src/prompts/snippets.ts
+++ b/packages/core/src/prompts/snippets.ts
@@ -14,6 +14,7 @@ import {
GREP_TOOL_NAME,
MEMORY_TOOL_NAME,
READ_FILE_TOOL_NAME,
+ RENDER_VISUALIZATION_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
@@ -63,6 +64,7 @@ export interface OperationalGuidelinesOptions {
interactive: boolean;
isGemini3: boolean;
interactiveShellEnabled: boolean;
+ enableVisualizationTool?: boolean;
}
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
@@ -292,7 +294,7 @@ export function renderOperationalGuidelines(
- **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
options.interactive,
options.interactiveShellEnabled,
- )}${toolUsageRememberingFacts(options)}
+ )}${toolUsageVisualization(options.enableVisualizationTool)}${toolUsageRememberingFacts(options)}
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
## Interaction Details
@@ -640,6 +642,18 @@ function toolUsageRememberingFacts(
return base + suffix;
}
+function toolUsageVisualization(enabled?: boolean): string {
+ if (!enabled) {
+ return '';
+ }
+
+ return `
+- **Visualization Tool:** Use ${formatToolName(RENDER_VISUALIZATION_TOOL_NAME)} for compact visual output with these kinds only: \`bar\`, \`line\`, \`pie\`, \`table\`, \`diagram\`.
+- **Canonical data shapes:** bar/line -> \`data.series=[{name,points:[{label,value}]}]\`; pie -> \`data.slices=[{label,value}]\`; table -> \`data.columns + data.rows\`; diagram -> \`data.nodes + data.edges\` with optional \`direction: "LR"|"TB"\`.
+- **Shorthand accepted:** bar/line and pie also accept key/value maps; table accepts \`headers\` alias; diagram accepts \`links\`/\`connections\` and edge keys \`source/target\`.
+- **Selection rule:** For engineering dashboards (tests/build/risk/trace/coverage/cost), consolidate into a single rich \`table\`; for UML-like architecture and flow, use \`diagram\`.`;
+}
+
function gitRepoKeepUserInformed(interactive: boolean): string {
return interactive
? `
diff --git a/packages/core/src/tools/definitions/coreTools.ts b/packages/core/src/tools/definitions/coreTools.ts
index 71fe1793e9..631ec6a372 100644
--- a/packages/core/src/tools/definitions/coreTools.ts
+++ b/packages/core/src/tools/definitions/coreTools.ts
@@ -14,6 +14,7 @@ export const LS_TOOL_NAME = 'list_directory';
export const READ_FILE_TOOL_NAME = 'read_file';
export const SHELL_TOOL_NAME = 'run_shell_command';
export const WRITE_FILE_TOOL_NAME = 'write_file';
+export const RENDER_VISUALIZATION_TOOL_NAME = 'render_visualization';
// ============================================================================
// READ_FILE TOOL
diff --git a/packages/core/src/tools/render-visualization.test.ts b/packages/core/src/tools/render-visualization.test.ts
new file mode 100644
index 0000000000..d494e41efe
--- /dev/null
+++ b/packages/core/src/tools/render-visualization.test.ts
@@ -0,0 +1,339 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+ RenderVisualizationTool,
+ type RenderVisualizationToolParams,
+ renderVisualizationTestUtils,
+} from './render-visualization.js';
+import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
+
+const signal = new AbortController().signal;
+
+describe('RenderVisualizationTool', () => {
+ const tool = new RenderVisualizationTool(createMockMessageBus());
+
+ it('renders bar visualization', async () => {
+ const params: RenderVisualizationToolParams = {
+ visualizationKind: 'bar',
+ title: 'BMW 0-60',
+ unit: 's',
+ sort: 'asc',
+ data: {
+ series: [
+ {
+ name: 'BMW',
+ points: [
+ { label: 'M5 CS', value: 2.9 },
+ { label: 'M8 Competition', value: 3.0 },
+ { label: 'XM Label Red', value: 3.7 },
+ ],
+ },
+ ],
+ },
+ };
+
+ const result = await tool.buildAndExecute(params, signal);
+
+ expect(result.llmContent).toContain('Visualization rendered: bar');
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'bar',
+ title: 'BMW 0-60',
+ });
+ });
+
+ it('renders line visualization with multiple series', async () => {
+ const params: RenderVisualizationToolParams = {
+ visualizationKind: 'line',
+ data: {
+ series: [
+ {
+ name: 'Sedan',
+ points: [
+ { label: '2021', value: 5 },
+ { label: '2022', value: 6 },
+ { label: '2023', value: 7 },
+ ],
+ },
+ {
+ name: 'SUV',
+ points: [
+ { label: '2021', value: 4 },
+ { label: '2022', value: 5 },
+ { label: '2023', value: 6 },
+ ],
+ },
+ ],
+ },
+ };
+
+ const result = await tool.buildAndExecute(params, signal);
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'line',
+ });
+ });
+
+ it('accepts shorthand bar payloads from root key/value maps', async () => {
+ const result = await tool.buildAndExecute(
+ {
+ visualizationKind: 'bar',
+ data: {
+ North: 320,
+ South: 580,
+ East: 450,
+ West: 490,
+ },
+ },
+ signal,
+ );
+
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'bar',
+ });
+ const display = result.returnDisplay as {
+ data: {
+ series: Array<{ points: Array<{ label: string; value: number }> }>;
+ };
+ };
+ expect(display.data.series[0]?.points).toEqual(
+ expect.arrayContaining([
+ { label: 'North', value: 320 },
+ { label: 'South', value: 580 },
+ { label: 'East', value: 450 },
+ { label: 'West', value: 490 },
+ ]),
+ );
+ });
+
+ it('renders pie visualization', async () => {
+ const params: RenderVisualizationToolParams = {
+ visualizationKind: 'pie',
+ data: {
+ slices: [
+ { label: 'M', value: 40 },
+ { label: 'X', value: 35 },
+ { label: 'i', value: 25 },
+ ],
+ },
+ };
+
+ const result = await tool.buildAndExecute(params, signal);
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'pie',
+ });
+ });
+
+ it('accepts shorthand pie payloads from series points', async () => {
+ const result = await tool.buildAndExecute(
+ {
+ visualizationKind: 'pie',
+ data: {
+ series: [
+ {
+ name: 'Browsers',
+ points: [
+ { label: 'Chrome', value: 65 },
+ { label: 'Safari', value: 18 },
+ ],
+ },
+ ],
+ },
+ },
+ signal,
+ );
+
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'pie',
+ data: {
+ slices: [
+ { label: 'Chrome', value: 65 },
+ { label: 'Safari', value: 18 },
+ ],
+ },
+ });
+ });
+
+ it('renders rich table visualization', async () => {
+ const params: RenderVisualizationToolParams = {
+ visualizationKind: 'table',
+ data: {
+ columns: ['Path', 'Score', 'Lines'],
+ rows: [
+ ['src/core.ts', 90, 220],
+ ['src/ui.tsx', 45, 80],
+ ],
+ metricColumns: [1, 2],
+ },
+ };
+
+ const result = await tool.buildAndExecute(params, signal);
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'table',
+ });
+ });
+
+ it('accepts table headers alias', async () => {
+ const result = await tool.buildAndExecute(
+ {
+ visualizationKind: 'table',
+ data: {
+ headers: ['Name', 'Score'],
+ rows: [
+ ['alpha', 90],
+ ['beta', 75],
+ ],
+ },
+ },
+ signal,
+ );
+
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'table',
+ data: {
+ columns: ['Name', 'Score'],
+ },
+ });
+ });
+
+ it('renders diagram visualization', async () => {
+ const params: RenderVisualizationToolParams = {
+ visualizationKind: 'diagram',
+ data: {
+ diagramKind: 'architecture',
+ direction: 'LR',
+ nodes: [
+ { id: 'ui', label: 'Web UI', type: 'frontend' },
+ { id: 'api', label: 'API', type: 'service' },
+ { id: 'db', label: 'Postgres', type: 'database' },
+ ],
+ edges: [
+ { from: 'ui', to: 'api', label: 'HTTPS' },
+ { from: 'api', to: 'db', label: 'SQL' },
+ ],
+ },
+ };
+
+ const result = await tool.buildAndExecute(params, signal);
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'diagram',
+ });
+ });
+
+ it('accepts shorthand diagram payload aliases', async () => {
+ const result = await tool.buildAndExecute(
+ {
+ visualizationKind: 'diagram',
+ data: {
+ diagramKind: 'flowchart',
+ direction: 'top-bottom',
+ nodes: ['Start', 'Validate', 'Done'],
+ links: [
+ { source: 'start', target: 'validate', label: 'ok' },
+ { source: 'validate', target: 'done' },
+ ],
+ },
+ },
+ signal,
+ );
+
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'diagram',
+ data: {
+ direction: 'TB',
+ },
+ });
+ });
+
+ it('converts legacy dashboard payloads into table', async () => {
+ const params: RenderVisualizationToolParams = {
+ visualizationKind: 'table',
+ data: {
+ failures: [
+ {
+ testName: 'should compile',
+ file: 'src/a.test.ts',
+ durationMs: 120,
+ status: 'failed',
+ isNew: true,
+ },
+ ],
+ },
+ };
+
+ const result = await tool.buildAndExecute(params, signal);
+ expect(result.returnDisplay).toMatchObject({
+ type: 'visualization',
+ kind: 'table',
+ data: {
+ columns: ['Status', 'Test', 'DurationMs', 'File', 'IsNew'],
+ },
+ });
+ });
+
+ it('rejects invalid payloads', async () => {
+ await expect(
+ tool.buildAndExecute(
+ {
+ visualizationKind: 'bar',
+ data: {
+ series: [
+ {
+ name: 'BMW',
+ points: [{ label: 'M5', value: -1 }],
+ },
+ ],
+ },
+ },
+ signal,
+ ),
+ ).rejects.toThrow('bar does not support negative values');
+
+ await expect(
+ tool.buildAndExecute(
+ {
+ visualizationKind: 'diagram',
+ data: {
+ diagramKind: 'flowchart',
+ nodes: [{ id: 'start', label: 'Start' }],
+ edges: [],
+ },
+ },
+ signal,
+ ),
+ ).rejects.toThrow('flowchart requires at least one edge');
+ });
+
+ it('exposes normalization helper for tests', () => {
+ const normalized = renderVisualizationTestUtils.normalizeByKind(
+ 'table',
+ {
+ columns: ['Name', 'Score'],
+ rows: [
+ ['A', 10],
+ ['B', 20],
+ ],
+ },
+ 'none',
+ 10,
+ );
+
+ expect(normalized.originalItemCount).toBe(2);
+ expect(normalized.truncated).toBe(false);
+ expect(normalized.data).toMatchObject({
+ columns: ['Name', 'Score'],
+ });
+ });
+});
diff --git a/packages/core/src/tools/render-visualization.ts b/packages/core/src/tools/render-visualization.ts
new file mode 100644
index 0000000000..badd8620cd
--- /dev/null
+++ b/packages/core/src/tools/render-visualization.ts
@@ -0,0 +1,1277 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { MessageBus } from '../confirmation-bus/message-bus.js';
+import {
+ BaseDeclarativeTool,
+ BaseToolInvocation,
+ DIAGRAM_KINDS,
+ Kind,
+ VISUALIZATION_KINDS,
+ type DiagramEdge,
+ type DiagramNode,
+ type ToolInvocation,
+ type ToolResult,
+ type VisualizationData,
+ type VisualizationDisplay,
+ type VisualizationKind,
+ type VisualizationPoint,
+ type VisualizationSeries,
+} from './tools.js';
+import { RENDER_VISUALIZATION_TOOL_NAME } from './tool-names.js';
+
+const DEFAULT_MAX_ITEMS = 30;
+const MAX_ALLOWED_ITEMS = 200;
+
+const SORT_OPTIONS = ['none', 'asc', 'desc'] as const;
+type SortMode = (typeof SORT_OPTIONS)[number];
+
+type PrimitiveCell = string | number | boolean;
+
+export interface RenderVisualizationToolParams {
+ visualizationKind: VisualizationKind;
+ title?: string;
+ subtitle?: string;
+ xLabel?: string;
+ yLabel?: string;
+ unit?: string;
+ data: unknown;
+ sourceContext?: string;
+ sort?: SortMode;
+ maxItems?: number;
+}
+
+interface NormalizedVisualization {
+ data: VisualizationData;
+ truncated: boolean;
+ originalItemCount: number;
+ validationWarnings: string[];
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function ensureRecord(
+ value: unknown,
+ message: string,
+): Record {
+ if (!isRecord(value)) {
+ throw new Error(message);
+ }
+ return value;
+}
+
+function parseNumericValue(value: unknown, fieldName: string): number {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (trimmed.length === 0) {
+ throw new Error(`Invalid numeric value for field "${fieldName}".`);
+ }
+ const normalized = trimmed.replace(/,/g, '');
+ const asNumber = Number(normalized);
+ if (Number.isFinite(asNumber)) {
+ return asNumber;
+ }
+ const match = normalized.match(/-?\d+(?:\.\d+)?/);
+ if (match) {
+ const parsed = Number(match[0]);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+ }
+
+ throw new Error(`Invalid numeric value for field "${fieldName}".`);
+}
+
+function getString(value: unknown, fallback: string): string {
+ if (typeof value === 'string' && value.trim().length > 0) {
+ return value.trim();
+ }
+ return fallback;
+}
+
+function getBoolean(value: unknown): boolean {
+ if (typeof value === 'boolean') {
+ return value;
+ }
+ if (typeof value === 'string') {
+ const normalized = value.trim().toLowerCase();
+ if (normalized === 'true') {
+ return true;
+ }
+ if (normalized === 'false') {
+ return false;
+ }
+ }
+ return false;
+}
+
+function normalizeCell(value: unknown): PrimitiveCell {
+ if (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ ) {
+ return value;
+ }
+ if (value === null || value === undefined) {
+ return '';
+ }
+ return JSON.stringify(value);
+}
+
+function applySort(
+ points: VisualizationPoint[],
+ sort: SortMode,
+): VisualizationPoint[] {
+ if (sort === 'none') {
+ return points;
+ }
+
+ const comparator =
+ sort === 'asc'
+ ? (a: VisualizationPoint, b: VisualizationPoint) => a.value - b.value
+ : (a: VisualizationPoint, b: VisualizationPoint) => b.value - a.value;
+ return [...points].sort(comparator);
+}
+
+function truncateItems(
+ items: T[],
+ maxItems: number,
+): {
+ items: T[];
+ truncated: boolean;
+ originalItemCount: number;
+} {
+ const originalItemCount = items.length;
+ const truncatedItems = items.slice(0, maxItems);
+ return {
+ items: truncatedItems,
+ truncated: originalItemCount > truncatedItems.length,
+ originalItemCount,
+ };
+}
+
+function downsampleLine(
+ points: VisualizationPoint[],
+ maxItems: number,
+): VisualizationPoint[] {
+ if (points.length <= maxItems) {
+ return points;
+ }
+ if (maxItems <= 2) {
+ return [points[0], points[points.length - 1]].slice(0, maxItems);
+ }
+
+ const result: VisualizationPoint[] = [points[0]];
+ const interval = (points.length - 1) / (maxItems - 1);
+ for (let i = 1; i < maxItems - 1; i += 1) {
+ result.push(points[Math.round(i * interval)]);
+ }
+ result.push(points[points.length - 1]);
+ return result;
+}
+
+function tryParseNumericValue(value: unknown): number | undefined {
+ try {
+ return parseNumericValue(value, 'value');
+ } catch {
+ return undefined;
+ }
+}
+
+function parsePointRecord(
+ value: unknown,
+ index: number,
+ fieldPrefix: string,
+): VisualizationPoint {
+ const pointRecord = ensureRecord(
+ value,
+ `${fieldPrefix}[${index}] must be an object.`,
+ );
+ const label =
+ (typeof pointRecord['label'] === 'string' &&
+ pointRecord['label'].trim().length > 0
+ ? pointRecord['label']
+ : undefined) ??
+ (typeof pointRecord['x'] === 'string' && pointRecord['x'].trim().length > 0
+ ? pointRecord['x']
+ : undefined) ??
+ (typeof pointRecord['name'] === 'string' &&
+ pointRecord['name'].trim().length > 0
+ ? pointRecord['name']
+ : undefined) ??
+ (typeof pointRecord['key'] === 'string' &&
+ pointRecord['key'].trim().length > 0
+ ? pointRecord['key']
+ : undefined) ??
+ (typeof pointRecord['category'] === 'string' &&
+ pointRecord['category'].trim().length > 0
+ ? pointRecord['category']
+ : undefined) ??
+ `Item ${index + 1}`;
+
+ const rawValue =
+ pointRecord['value'] ??
+ pointRecord['y'] ??
+ pointRecord['amount'] ??
+ pointRecord['count'] ??
+ pointRecord['total'];
+ const numericValue = parseNumericValue(
+ rawValue,
+ `${fieldPrefix}[${index}].value`,
+ );
+
+ return {
+ label: getString(label, `Item ${index + 1}`),
+ value: numericValue,
+ };
+}
+
+function pointsFromArray(
+ values: unknown,
+ fieldPrefix: string,
+): VisualizationPoint[] {
+ if (!Array.isArray(values) || values.length === 0) {
+ return [];
+ }
+
+ return values.map((value, index) =>
+ parsePointRecord(value, index, fieldPrefix),
+ );
+}
+
+function pointsFromNumericMap(
+ values: Record,
+ reservedKeys: Set,
+): VisualizationPoint[] {
+ return Object.entries(values)
+ .filter(([key]) => !reservedKeys.has(key))
+ .map(([key, rawValue]) => {
+ const numericValue = tryParseNumericValue(rawValue);
+ if (numericValue === undefined) {
+ return null;
+ }
+ return {
+ label: key,
+ value: numericValue,
+ };
+ })
+ .filter((point): point is VisualizationPoint => point !== null);
+}
+
+function normalizeSeriesArray(
+ data: Record,
+ allowMultiSeries: boolean,
+): { series: VisualizationSeries[]; warnings: string[] } {
+ const warnings: string[] = [];
+ const rawSeries = data['series'];
+
+ if (Array.isArray(rawSeries) && rawSeries.length > 0) {
+ // Accept shorthand: data.series is a points array rather than series array.
+ const looksLikePointList = rawSeries.every((entry) => {
+ if (!isRecord(entry)) {
+ return false;
+ }
+ return entry['points'] === undefined && entry['value'] !== undefined;
+ });
+
+ if (looksLikePointList) {
+ const points = pointsFromArray(rawSeries, 'series');
+ if (points.length === 0) {
+ throw new Error('series shorthand must contain non-empty points.');
+ }
+ return {
+ series: [{ name: 'Series 1', points }],
+ warnings: [
+ 'Converted shorthand `data.series` point list into one series.',
+ ],
+ };
+ }
+
+ const series = rawSeries.map((raw, seriesIndex) => {
+ const seriesRecord = ensureRecord(
+ raw,
+ '`data.series` items must be objects.',
+ );
+ const name = getString(seriesRecord['name'], `Series ${seriesIndex + 1}`);
+
+ let points = pointsFromArray(
+ seriesRecord['points'] ?? seriesRecord['data'],
+ `series[${seriesIndex}].points`,
+ );
+ if (points.length === 0 && isRecord(seriesRecord['values'])) {
+ points = pointsFromNumericMap(
+ seriesRecord['values'],
+ new Set(['title', 'name']),
+ );
+ if (points.length > 0) {
+ warnings.push(
+ `Converted series[${seriesIndex}].values key/value map into points.`,
+ );
+ }
+ }
+
+ if (points.length === 0) {
+ throw new Error('Each series must have non-empty `points`.');
+ }
+
+ return { name, points };
+ });
+
+ if (!allowMultiSeries && series.length > 1) {
+ throw new Error('bar supports exactly one series.');
+ }
+
+ return { series, warnings };
+ }
+
+ const rawPoints = data['points'] ?? data['data'];
+ if (Array.isArray(rawPoints) && rawPoints.length > 0) {
+ return {
+ series: [
+ { name: 'Series 1', points: pointsFromArray(rawPoints, 'points') },
+ ],
+ warnings: ['Converted shorthand `data.points` into one series.'],
+ };
+ }
+
+ if (isRecord(data['values'])) {
+ const points = pointsFromNumericMap(
+ data['values'],
+ new Set(['title', 'name']),
+ );
+ if (points.length > 0) {
+ return {
+ series: [{ name: 'Series 1', points }],
+ warnings: [
+ 'Converted shorthand `data.values` key/value map into points.',
+ ],
+ };
+ }
+ }
+
+ const rootPoints = pointsFromNumericMap(
+ data,
+ new Set([
+ 'series',
+ 'points',
+ 'data',
+ 'values',
+ 'title',
+ 'subtitle',
+ 'xLabel',
+ 'yLabel',
+ 'unit',
+ 'sort',
+ 'maxItems',
+ 'sourceContext',
+ ]),
+ );
+ if (rootPoints.length > 0) {
+ return {
+ series: [{ name: 'Series 1', points: rootPoints }],
+ warnings: ['Converted root key/value map into one series.'],
+ };
+ }
+
+ throw new Error(
+ 'bar/line requires series data. Accepted forms: data.series[].points[], data.points[], data.values{}, or root key/value map.',
+ );
+}
+
+function normalizeBar(
+ data: Record,
+ sort: SortMode,
+ maxItems: number,
+): NormalizedVisualization {
+ const normalized = normalizeSeriesArray(data, false);
+ const series = normalized.series;
+
+ let points = series[0].points;
+ const negative = points.find((point) => point.value < 0);
+ if (negative) {
+ throw new Error(
+ `bar does not support negative values (label: "${negative.label}").`,
+ );
+ }
+
+ points = applySort(points, sort);
+ const truncated = truncateItems(points, maxItems);
+
+ return {
+ data: {
+ series: [{ name: series[0].name, points: truncated.items }],
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: normalized.warnings,
+ };
+}
+
+function normalizeLine(
+ data: Record,
+ maxItems: number,
+): NormalizedVisualization {
+ const normalized = normalizeSeriesArray(data, true);
+ const series = normalized.series;
+ let truncated = false;
+ let originalItemCount = 0;
+
+ const normalizedSeries = series.map((item) => {
+ originalItemCount += item.points.length;
+ if (item.points.length > maxItems) {
+ truncated = true;
+ return {
+ name: item.name,
+ points: downsampleLine(item.points, maxItems),
+ };
+ }
+
+ return item;
+ });
+
+ return {
+ data: {
+ series: normalizedSeries,
+ },
+ truncated,
+ originalItemCount,
+ validationWarnings: normalized.warnings,
+ };
+}
+
+function normalizePie(
+ data: Record,
+ sort: SortMode,
+ maxItems: number,
+): NormalizedVisualization {
+ const warnings: string[] = [];
+
+ let slices = pointsFromArray(data['slices'], 'slices').map((point) => ({
+ label: point.label,
+ value: point.value,
+ }));
+
+ if (slices.length === 0) {
+ try {
+ const normalizedSeries = normalizeSeriesArray(data, true);
+ const firstSeries = normalizedSeries.series[0];
+ if (firstSeries && firstSeries.points.length > 0) {
+ slices = firstSeries.points.map((point) => ({
+ label: point.label,
+ value: point.value,
+ }));
+ warnings.push(
+ 'Converted series-based payload to pie slices using the first series.',
+ );
+ warnings.push(...normalizedSeries.warnings);
+ }
+ } catch {
+ // Fall through to other shorthand formats below.
+ }
+ }
+
+ if (slices.length === 0) {
+ slices = pointsFromNumericMap(
+ data,
+ new Set([
+ 'series',
+ 'points',
+ 'data',
+ 'slices',
+ 'values',
+ 'title',
+ 'subtitle',
+ 'xLabel',
+ 'yLabel',
+ 'unit',
+ 'sort',
+ 'maxItems',
+ ]),
+ ).map((point) => ({
+ label: point.label,
+ value: point.value,
+ }));
+
+ if (slices.length > 0) {
+ warnings.push('Converted root key/value map to pie slices.');
+ }
+ }
+
+ if (slices.length === 0) {
+ throw new Error(
+ 'pie requires data.slices[] (or shorthand key/value map). Each item needs label + value.',
+ );
+ }
+
+ for (const slice of slices) {
+ if (slice.value < 0) {
+ throw new Error('pie slices cannot be negative.');
+ }
+ }
+
+ if (sort !== 'none') {
+ slices.sort((a, b) =>
+ sort === 'asc' ? a.value - b.value : b.value - a.value,
+ );
+ }
+
+ const truncated = truncateItems(slices, maxItems);
+ const total = truncated.items.reduce((sum, item) => sum + item.value, 0);
+ if (total <= 0) {
+ throw new Error('pie requires total value > 0.');
+ }
+
+ return {
+ data: {
+ slices: truncated.items,
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+}
+
+function treeToTable(root: Record): {
+ columns: string[];
+ rows: PrimitiveCell[][];
+} {
+ const rows: PrimitiveCell[][] = [];
+ const visit = (node: Record, parent: string) => {
+ const label = getString(node['label'], '(node)');
+ const impact =
+ typeof node['impact'] === 'string' && node['impact'].trim().length > 0
+ ? node['impact'].trim()
+ : '';
+ rows.push([label, parent, impact]);
+ const children = Array.isArray(node['children'])
+ ? node['children'].filter(isRecord)
+ : [];
+ children.forEach((child) => visit(child, label));
+ };
+
+ visit(root, '');
+ return {
+ columns: ['Node', 'Parent', 'Impact'],
+ rows,
+ };
+}
+
+function normalizeTable(
+ data: Record,
+ maxItems: number,
+): NormalizedVisualization {
+ const warnings: string[] = [];
+
+ // Legacy dashboard payloads: convert to generic table.
+ if (Array.isArray(data['failures'])) {
+ const rows = data['failures']
+ .filter(isRecord)
+ .map((item) => [
+ getString(item['status'], 'failed'),
+ getString(item['testName'], '(test)'),
+ parseNumericValue(item['durationMs'] ?? 0, 'durationMs'),
+ getString(item['file'], '(file)'),
+ getBoolean(item['isNew']),
+ ]);
+ const truncated = truncateItems(rows, maxItems);
+ warnings.push('Converted legacy test_dashboard payload to table.');
+ return {
+ data: {
+ columns: ['Status', 'Test', 'DurationMs', 'File', 'IsNew'],
+ rows: truncated.items,
+ metricColumns: [2],
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+ }
+
+ if (Array.isArray(data['runs'])) {
+ const rows = data['runs']
+ .filter(isRecord)
+ .map((item) => [
+ getString(item['label'], '(run)'),
+ parseNumericValue(item['totalMs'] ?? 0, 'totalMs'),
+ getString(item['status'], 'unknown'),
+ getString(item['failedStep'], ''),
+ ]);
+ const truncated = truncateItems(rows, maxItems);
+ warnings.push('Converted legacy build_timeline payload to table.');
+ return {
+ data: {
+ columns: ['Run', 'TotalMs', 'Status', 'FailedStep'],
+ rows: truncated.items,
+ metricColumns: [1],
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+ }
+
+ if (Array.isArray(data['steps'])) {
+ const rows = data['steps']
+ .filter(isRecord)
+ .map((item) => [
+ getString(item['phase'], '(phase)'),
+ getString(item['tool'], '(tool)'),
+ parseNumericValue(item['durationMs'] ?? 0, 'durationMs'),
+ getString(item['status'], 'ok'),
+ ]);
+ const truncated = truncateItems(rows, maxItems);
+ warnings.push('Converted legacy agent_trace payload to table.');
+ return {
+ data: {
+ columns: ['Phase', 'Tool', 'DurationMs', 'Status'],
+ rows: truncated.items,
+ metricColumns: [2],
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+ }
+
+ if (Array.isArray(data['files'])) {
+ const first = data['files'][0];
+ if (isRecord(first)) {
+ const keys = Object.keys(first);
+ const rows = data['files']
+ .filter(isRecord)
+ .map((item) => keys.map((key) => normalizeCell(item[key])));
+ const truncated = truncateItems(rows, maxItems);
+ const metricColumns = keys
+ .map((key, index) => ({ key, index }))
+ .filter((item) =>
+ [
+ 'score',
+ 'linesChanged',
+ 'changedLines',
+ 'beforePct',
+ 'afterPct',
+ 'deltaPct',
+ 'touches',
+ 'errors',
+ 'failedTests',
+ 'calls',
+ 'totalMs',
+ ].includes(item.key),
+ )
+ .map((item) => item.index);
+ warnings.push('Converted legacy files payload to table.');
+ return {
+ data: {
+ columns: keys,
+ rows: truncated.items,
+ metricColumns: metricColumns.length > 0 ? metricColumns : undefined,
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+ }
+ }
+
+ if (isRecord(data['summary']) && Array.isArray(data['byTool'])) {
+ const summary = ensureRecord(data['summary'], 'summary must be an object.');
+ const summaryRows: PrimitiveCell[][] = [
+ [
+ 'inputTokens',
+ parseNumericValue(summary['inputTokens'] ?? 0, 'inputTokens'),
+ ],
+ [
+ 'outputTokens',
+ parseNumericValue(summary['outputTokens'] ?? 0, 'outputTokens'),
+ ],
+ ['toolCalls', parseNumericValue(summary['toolCalls'] ?? 0, 'toolCalls')],
+ ['elapsedMs', parseNumericValue(summary['elapsedMs'] ?? 0, 'elapsedMs')],
+ ];
+
+ const byToolRows = data['byTool']
+ .filter(isRecord)
+ .map((item) => [
+ getString(item['tool'], '(tool)'),
+ parseNumericValue(item['calls'] ?? 0, 'calls'),
+ item['totalMs'] === undefined
+ ? 0
+ : parseNumericValue(item['totalMs'], 'totalMs'),
+ ]);
+
+ const rows = [...summaryRows, ...byToolRows];
+ const truncated = truncateItems(rows, maxItems);
+ warnings.push('Converted legacy cost_meter payload to table.');
+ return {
+ data: {
+ columns: ['Metric/Tool', 'Value/Calls', 'DurationMs'],
+ rows: truncated.items,
+ metricColumns: [1, 2],
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+ }
+
+ if (isRecord(data['root'])) {
+ const tree = treeToTable(data['root']);
+ const truncated = truncateItems(tree.rows, maxItems);
+ warnings.push('Converted legacy impact_graph payload to table.');
+ return {
+ data: {
+ columns: tree.columns,
+ rows: truncated.items,
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+ }
+
+ const rawRows = Array.isArray(data['rows'])
+ ? data['rows']
+ : Array.isArray(data['data'])
+ ? data['data']
+ : undefined;
+ if (!Array.isArray(rawRows) || rawRows.length === 0) {
+ const keyValueRows = pointsFromNumericMap(
+ data,
+ new Set([
+ 'columns',
+ 'headers',
+ 'rows',
+ 'data',
+ 'metricColumns',
+ 'title',
+ 'subtitle',
+ 'sourceContext',
+ ]),
+ ).map((point) => [point.label, point.value]);
+ if (keyValueRows.length > 0) {
+ const truncated = truncateItems(keyValueRows, maxItems);
+ warnings.push('Converted root key/value map into two-column table.');
+ return {
+ data: {
+ columns: ['Key', 'Value'],
+ rows: truncated.items,
+ metricColumns: [1],
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+ }
+
+ throw new Error(
+ 'table requires rows as a non-empty array. Accepted: data.rows or data.data with optional columns/headers.',
+ );
+ }
+
+ let columns = Array.isArray(data['columns'])
+ ? data['columns'].map((column, idx) =>
+ getString(column, `Column ${idx + 1}`),
+ )
+ : Array.isArray(data['headers'])
+ ? data['headers'].map((column, idx) =>
+ getString(column, `Column ${idx + 1}`),
+ )
+ : [];
+ if (!Array.isArray(data['columns']) && Array.isArray(data['headers'])) {
+ warnings.push('Converted `headers` to `columns`.');
+ }
+
+ const firstRow = rawRows.find((row) => Array.isArray(row) || isRecord(row));
+ if (columns.length === 0 && isRecord(firstRow)) {
+ columns = Object.keys(firstRow);
+ }
+
+ const rows = rawRows.map((rawRow, rowIndex) => {
+ if (Array.isArray(rawRow)) {
+ return rawRow.map((cell) => normalizeCell(cell));
+ }
+
+ if (isRecord(rawRow)) {
+ if (columns.length === 0) {
+ columns = Object.keys(rawRow);
+ }
+ return columns.map((column) => normalizeCell(rawRow[column]));
+ }
+
+ throw new Error(`table row ${rowIndex} must be an array or object.`);
+ });
+
+ if (columns.length === 0) {
+ const maxCols = rows.reduce((max, row) => Math.max(max, row.length), 0);
+ columns = Array.from({ length: maxCols }, (_, idx) => `Column ${idx + 1}`);
+ }
+
+ const metricColumns = Array.isArray(data['metricColumns'])
+ ? data['metricColumns']
+ .map((value) => parseNumericValue(value, 'metricColumns[]'))
+ .filter(
+ (value) =>
+ Number.isInteger(value) && value >= 0 && value < columns.length,
+ )
+ : undefined;
+
+ const truncated = truncateItems(rows, maxItems);
+
+ return {
+ data: {
+ columns,
+ rows: truncated.items,
+ metricColumns:
+ metricColumns && metricColumns.length > 0 ? metricColumns : undefined,
+ },
+ truncated: truncated.truncated,
+ originalItemCount: truncated.originalItemCount,
+ validationWarnings: warnings,
+ };
+}
+
+function normalizeDiagram(
+ data: Record,
+ maxItems: number,
+): NormalizedVisualization {
+ let diagramKind: 'architecture' | 'flowchart' = 'architecture';
+ const diagramKindCandidate = getString(data['diagramKind'], 'architecture');
+ if (diagramKindCandidate === 'flowchart') {
+ diagramKind = 'flowchart';
+ } else if (diagramKindCandidate !== 'architecture') {
+ throw new Error(`diagramKind must be one of: ${DIAGRAM_KINDS.join(', ')}`);
+ }
+
+ const directionCandidate = getString(data['direction'], 'LR').toUpperCase();
+ const direction =
+ directionCandidate === 'TB' ||
+ directionCandidate === 'TOP-BOTTOM' ||
+ directionCandidate === 'TOP_TO_BOTTOM' ||
+ directionCandidate === 'VERTICAL'
+ ? 'TB'
+ : 'LR';
+
+ let nodesInput = data['nodes'] ?? data['boxes'];
+ let edgesInput = data['edges'] ?? data['links'] ?? data['connections'];
+
+ const warnings: string[] = [];
+ const slugify = (value: string): string =>
+ value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'node';
+
+ // Accept legacy impact tree payload for diagrams.
+ if (
+ (!Array.isArray(nodesInput) || !Array.isArray(edgesInput)) &&
+ isRecord(data['root'])
+ ) {
+ const nodes: DiagramNode[] = [];
+ const edges: DiagramEdge[] = [];
+ const seen = new Set();
+
+ const visit = (node: Record, parentId?: string) => {
+ const label = getString(node['label'], '(node)');
+ let id = slugify(label);
+ let suffix = 2;
+ while (seen.has(id)) {
+ id = `${slugify(label)}-${suffix}`;
+ suffix += 1;
+ }
+ seen.add(id);
+ nodes.push({
+ id,
+ label,
+ type: getString(node['impact'], '') || undefined,
+ });
+ if (parentId) {
+ edges.push({ from: parentId, to: id });
+ }
+
+ const children = Array.isArray(node['children'])
+ ? node['children'].filter(isRecord)
+ : [];
+ children.forEach((child) => visit(child, id));
+ };
+
+ visit(data['root']);
+ nodesInput = nodes;
+ edgesInput = edges;
+ diagramKind = 'flowchart';
+ warnings.push(
+ 'Converted tree-style root payload into diagram nodes/edges.',
+ );
+ }
+
+ if (!Array.isArray(nodesInput) || nodesInput.length === 0) {
+ throw new Error('diagram requires non-empty `data.nodes`.');
+ }
+ if (!Array.isArray(edgesInput)) {
+ throw new Error('diagram requires `data.edges` array.');
+ }
+
+ const ids = new Set();
+ let convertedStringNodes = false;
+ const nodes = nodesInput.map((rawNode, index) => {
+ if (typeof rawNode === 'string') {
+ const label = getString(rawNode, `Node ${index + 1}`);
+ let id = slugify(label);
+ let suffix = 2;
+ while (ids.has(id)) {
+ id = `${slugify(label)}-${suffix}`;
+ suffix += 1;
+ }
+ ids.add(id);
+ convertedStringNodes = true;
+ return { id, label };
+ }
+
+ const node = ensureRecord(rawNode, `nodes[${index}] must be an object.`);
+ const idCandidate =
+ (typeof node['id'] === 'string' && node['id'].trim().length > 0
+ ? node['id']
+ : undefined) ??
+ (typeof node['key'] === 'string' && node['key'].trim().length > 0
+ ? node['key']
+ : undefined) ??
+ (typeof node['name'] === 'string' && node['name'].trim().length > 0
+ ? slugify(node['name'])
+ : undefined) ??
+ (typeof node['label'] === 'string' && node['label'].trim().length > 0
+ ? slugify(node['label'])
+ : undefined);
+ const id = getString(idCandidate, '');
+ if (id.length === 0) {
+ throw new Error(`nodes[${index}].id must be a non-empty string.`);
+ }
+ if (ids.has(id)) {
+ throw new Error(`Duplicate node id detected: "${id}".`);
+ }
+ ids.add(id);
+
+ return {
+ id,
+ label: getString(node['label'], id),
+ type:
+ typeof node['type'] === 'string' && node['type'].trim().length > 0
+ ? node['type'].trim()
+ : undefined,
+ };
+ });
+ if (convertedStringNodes) {
+ warnings.push('Converted string nodes into {id,label} nodes.');
+ }
+
+ const idByAlias = new Map();
+ for (const node of nodes) {
+ idByAlias.set(node.id.toLowerCase(), node.id);
+ idByAlias.set(slugify(node.id).toLowerCase(), node.id);
+ idByAlias.set(node.label.toLowerCase(), node.id);
+ idByAlias.set(slugify(node.label).toLowerCase(), node.id);
+ }
+ const resolveNodeId = (candidate: string): string => {
+ if (ids.has(candidate)) {
+ return candidate;
+ }
+ const normalized = candidate.trim().toLowerCase();
+ return idByAlias.get(normalized) ?? candidate;
+ };
+
+ const edges = edgesInput.map((rawEdge, index) => {
+ const edge = ensureRecord(rawEdge, `edges[${index}] must be an object.`);
+ const from = resolveNodeId(
+ getString(
+ edge['from'] ?? edge['source'] ?? edge['fromId'] ?? edge['start'],
+ '',
+ ),
+ );
+ const to = resolveNodeId(
+ getString(
+ edge['to'] ?? edge['target'] ?? edge['toId'] ?? edge['end'],
+ '',
+ ),
+ );
+ if (!from || !to) {
+ throw new Error(`edges[${index}] requires non-empty from/to.`);
+ }
+ if (!ids.has(from)) {
+ throw new Error(
+ `edges[${index}].from references unknown node id "${from}".`,
+ );
+ }
+ if (!ids.has(to)) {
+ throw new Error(`edges[${index}].to references unknown node id "${to}".`);
+ }
+
+ return {
+ from,
+ to,
+ label:
+ typeof (edge['label'] ?? edge['name']) === 'string' &&
+ String(edge['label'] ?? edge['name']).trim().length > 0
+ ? String(edge['label'] ?? edge['name']).trim()
+ : undefined,
+ };
+ });
+
+ if (diagramKind === 'flowchart' && edges.length === 0) {
+ throw new Error('flowchart requires at least one edge.');
+ }
+
+ const originalNodeCount = nodes.length;
+ const originalEdgeCount = edges.length;
+ const truncatedNodes = nodes.slice(0, maxItems);
+ const allowedIds = new Set(truncatedNodes.map((node) => node.id));
+ const truncatedEdges = edges.filter(
+ (edge) => allowedIds.has(edge.from) && allowedIds.has(edge.to),
+ );
+
+ return {
+ data: {
+ diagramKind,
+ direction,
+ nodes: truncatedNodes,
+ edges: truncatedEdges,
+ },
+ truncated:
+ truncatedNodes.length < originalNodeCount ||
+ truncatedEdges.length < originalEdgeCount,
+ originalItemCount: originalNodeCount,
+ validationWarnings: warnings,
+ };
+}
+
+function normalizeByKind(
+ kind: VisualizationKind,
+ data: unknown,
+ sort: SortMode,
+ maxItems: number,
+): NormalizedVisualization {
+ const payload = ensureRecord(data, '`data` must be an object.');
+
+ switch (kind) {
+ case 'bar':
+ return normalizeBar(payload, sort, maxItems);
+ case 'line':
+ return normalizeLine(payload, maxItems);
+ case 'pie':
+ return normalizePie(payload, sort, maxItems);
+ case 'table':
+ return normalizeTable(payload, maxItems);
+ case 'diagram':
+ return normalizeDiagram(payload, maxItems);
+ default:
+ throw new Error(`Unsupported visualization kind: ${kind as string}`);
+ }
+}
+
+class RenderVisualizationToolInvocation extends BaseToolInvocation<
+ RenderVisualizationToolParams,
+ ToolResult
+> {
+ getDescription(): string {
+ return `Render a ${this.params.visualizationKind} visualization.`;
+ }
+
+ async execute(_signal: AbortSignal): Promise {
+ const sort = this.params.sort ?? 'none';
+ const maxItems = Math.max(
+ 1,
+ Math.min(this.params.maxItems ?? DEFAULT_MAX_ITEMS, MAX_ALLOWED_ITEMS),
+ );
+
+ const normalized = normalizeByKind(
+ this.params.visualizationKind,
+ this.params.data,
+ sort,
+ maxItems,
+ );
+
+ const returnDisplay: VisualizationDisplay = {
+ type: 'visualization',
+ kind: this.params.visualizationKind,
+ title: this.params.title,
+ subtitle: this.params.subtitle,
+ xLabel: this.params.xLabel,
+ yLabel: this.params.yLabel,
+ unit: this.params.unit,
+ data: normalized.data,
+ meta: {
+ truncated: normalized.truncated,
+ originalItemCount: normalized.originalItemCount,
+ validationWarnings:
+ normalized.validationWarnings.length > 0
+ ? normalized.validationWarnings
+ : undefined,
+ },
+ };
+
+ const llmContent = [
+ `Visualization rendered: ${this.params.visualizationKind}`,
+ this.params.title ? `Title: ${this.params.title}` : '',
+ `Items rendered: ${normalized.originalItemCount}`,
+ normalized.truncated
+ ? `Data was truncated to maxItems=${maxItems} (original items: ${normalized.originalItemCount}).`
+ : '',
+ this.params.sourceContext
+ ? `Source context: ${this.params.sourceContext}`
+ : '',
+ ]
+ .filter((line) => line.length > 0)
+ .join('\n');
+
+ return {
+ llmContent,
+ returnDisplay,
+ };
+ }
+}
+
+export const RENDER_VISUALIZATION_DESCRIPTION = `Render compact terminal visualizations.
+
+Use one tool with five kinds:
+- chart: bar, line, pie
+- structured view: table
+- graph/uml-like view: diagram
+
+Canonical payloads:
+- bar/line: \`data.series=[{name, points:[{label,value}]}]\`
+- pie: \`data.slices=[{label,value}]\`
+- table: \`data.columns + data.rows\`
+- diagram: \`data.nodes + data.edges\` (+ optional \`direction: "LR"|"TB"\`)
+
+Accepted shorthand (auto-normalized):
+- bar/line can use \`data.points\`, \`data.values\`, or a root key/value map like \`{North: 320, South: 580}\`
+- pie can use slices, series points, or key/value map
+- table can use \`headers\` as alias of \`columns\`
+- diagram accepts \`links\`/\`connections\`, edge keys \`source/target\`, and string nodes
+
+When users ask for engineering dashboards (tests, builds, risk, trace, coverage, cost), prefer \`table\` and encode metrics in rows/columns.
+When users ask for architecture/flow/UML-like diagrams, use \`diagram\` with nodes/edges and set \`direction\`.`;
+
+export class RenderVisualizationTool extends BaseDeclarativeTool<
+ RenderVisualizationToolParams,
+ ToolResult
+> {
+ static readonly Name = RENDER_VISUALIZATION_TOOL_NAME;
+
+ constructor(messageBus: MessageBus) {
+ super(
+ RenderVisualizationTool.Name,
+ 'RenderVisualization',
+ RENDER_VISUALIZATION_DESCRIPTION,
+ Kind.Other,
+ {
+ type: 'object',
+ properties: {
+ visualizationKind: {
+ type: 'string',
+ enum: VISUALIZATION_KINDS,
+ description: 'Visualization kind to render.',
+ },
+ title: {
+ type: 'string',
+ description: 'Optional visualization title.',
+ },
+ subtitle: {
+ type: 'string',
+ description: 'Optional visualization subtitle.',
+ },
+ xLabel: {
+ type: 'string',
+ description: 'Optional x-axis label.',
+ },
+ yLabel: {
+ type: 'string',
+ description: 'Optional y-axis label.',
+ },
+ unit: {
+ type: 'string',
+ description: 'Optional unit label for values.',
+ },
+ data: {
+ type: 'object',
+ description:
+ 'Payload for the chosen kind. Canonical: bar/line series->points(label,value), pie slices(label,value), table columns+rows, diagram nodes+edges. Shorthand maps/aliases are accepted.',
+ additionalProperties: true,
+ },
+ sourceContext: {
+ type: 'string',
+ description: 'Optional provenance summary for the data.',
+ },
+ sort: {
+ type: 'string',
+ enum: SORT_OPTIONS,
+ description: 'Optional sort mode for bar/pie visualizations.',
+ },
+ maxItems: {
+ type: 'number',
+ description: `Maximum items to render (default ${DEFAULT_MAX_ITEMS}, max ${MAX_ALLOWED_ITEMS}).`,
+ },
+ },
+ required: ['visualizationKind', 'data'],
+ additionalProperties: false,
+ },
+ messageBus,
+ false,
+ false,
+ );
+ }
+
+ protected override validateToolParamValues(
+ params: RenderVisualizationToolParams,
+ ): string | null {
+ if (!VISUALIZATION_KINDS.includes(params.visualizationKind)) {
+ return `visualizationKind must be one of: ${VISUALIZATION_KINDS.join(', ')}`;
+ }
+
+ if (!isRecord(params.data)) {
+ return 'data must be an object.';
+ }
+
+ if (params.sort && !SORT_OPTIONS.includes(params.sort)) {
+ return `sort must be one of: ${SORT_OPTIONS.join(', ')}`;
+ }
+
+ if (params.maxItems !== undefined) {
+ if (!Number.isFinite(params.maxItems) || params.maxItems < 1) {
+ return 'maxItems must be a positive number.';
+ }
+ if (params.maxItems > MAX_ALLOWED_ITEMS) {
+ return `maxItems cannot exceed ${MAX_ALLOWED_ITEMS}.`;
+ }
+ }
+
+ return null;
+ }
+
+ protected createInvocation(
+ params: RenderVisualizationToolParams,
+ messageBus: MessageBus,
+ _toolName?: string,
+ _toolDisplayName?: string,
+ ): ToolInvocation {
+ return new RenderVisualizationToolInvocation(
+ params,
+ messageBus,
+ _toolName,
+ _toolDisplayName,
+ );
+ }
+}
+
+// Exported for targeted unit tests.
+export const renderVisualizationTestUtils = {
+ parseNumericValue,
+ normalizeByKind,
+};
diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts
index 70e882ebe1..a9526c2abc 100644
--- a/packages/core/src/tools/tool-names.ts
+++ b/packages/core/src/tools/tool-names.ts
@@ -9,6 +9,7 @@ import {
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
+ RENDER_VISUALIZATION_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from './definitions/coreTools.js';
@@ -22,6 +23,7 @@ export {
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
+ RENDER_VISUALIZATION_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
};
@@ -94,6 +96,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [
MEMORY_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
ASK_USER_TOOL_NAME,
+ RENDER_VISUALIZATION_TOOL_NAME,
] as const;
/**
diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts
index 3d90e80699..afc0fa1591 100644
--- a/packages/core/src/tools/tools.ts
+++ b/packages/core/src/tools/tools.ts
@@ -664,7 +664,86 @@ export interface TodoList {
todos: Todo[];
}
-export type ToolResultDisplay = string | FileDiff | AnsiOutput | TodoList;
+export const VISUALIZATION_KINDS = [
+ 'bar',
+ 'line',
+ 'pie',
+ 'table',
+ 'diagram',
+] as const;
+
+export type VisualizationKind = (typeof VISUALIZATION_KINDS)[number];
+export const DIAGRAM_KINDS = ['architecture', 'flowchart'] as const;
+export type DiagramKind = (typeof DIAGRAM_KINDS)[number];
+
+export interface DiagramNode {
+ id: string;
+ label: string;
+ type?: string;
+}
+
+export interface DiagramEdge {
+ from: string;
+ to: string;
+ label?: string;
+}
+
+export interface VisualizationPoint {
+ label: string;
+ value: number;
+}
+
+export interface VisualizationSeries {
+ name: string;
+ points: VisualizationPoint[];
+}
+
+export interface VisualizationPieSlice {
+ label: string;
+ value: number;
+}
+
+export interface VisualizationTableData {
+ columns: string[];
+ rows: Array>;
+ metricColumns?: number[];
+}
+
+export interface VisualizationDiagramData {
+ diagramKind: DiagramKind;
+ direction?: 'LR' | 'TB';
+ nodes: DiagramNode[];
+ edges: DiagramEdge[];
+}
+
+export type VisualizationData =
+ | { series: VisualizationSeries[] }
+ | { slices: VisualizationPieSlice[] }
+ | VisualizationTableData
+ | VisualizationDiagramData;
+
+export interface VisualizationDisplay {
+ type: 'visualization';
+ kind: VisualizationKind;
+ title?: string;
+ subtitle?: string;
+ xLabel?: string;
+ yLabel?: string;
+ unit?: string;
+ data: VisualizationData;
+ meta?: {
+ truncated: boolean;
+ originalItemCount: number;
+ validationWarnings?: string[];
+ };
+}
+
+export type ToolResultDisplay =
+ | string
+ | FileDiff
+ | AnsiOutput
+ | TodoList
+ | VisualizationDisplay;
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
diff --git a/plan/visualer.md b/plan/visualer.md
new file mode 100644
index 0000000000..1b4120cd67
--- /dev/null
+++ b/plan/visualer.md
@@ -0,0 +1,245 @@
+# Built-in Native Visualization Tool for Gemini CLI (V1)
+
+## Summary
+
+Implement a first-class built-in tool `render_visualization` (no MCP) that
+supports `bar`, `line`, and `table` outputs in the terminal using Ink-native
+rendering. The model will be guided to use this tool during natural-language
+reasoning loops (research -> normalize -> visualize), so users do not need to
+format input data manually.
+
+This design directly supports these scenarios:
+
+- "Fastest 3 BMW + comparative 0-60 chart"
+- Follow-up in same session: "BMW models per year for last 5 years" with dynamic
+ data volumes and consistent cross-platform rendering.
+
+## Goals and Success Criteria
+
+- User asks a natural-language question requiring comparison/trend display.
+- Model can:
+
+1. Gather data via existing tools (web/file/etc),
+2. Transform raw findings into visualization schema,
+3. Call `render_visualization` correctly,
+4. Render readable inline chart/table in CLI.
+
+- Works across modern terminals on macOS, Linux, Windows.
+- Follow-up turns in same session continue to use tool correctly without user
+ schema knowledge.
+
+## Product Decisions (Locked)
+
+- V1 scope: one tool with 3 visualization types: `bar | line | table`.
+- UI surface: inline tool result panel only.
+- Rendering stack: pure Ink + custom renderers (no chart library dependency in
+ v1).
+- Invocation behavior: explicit user visualization intent
+ (chart/show/compare/trend/table).
+- Prompting: tool description + explicit system-prompt guidance snippet for
+ visualization decisioning.
+- Data entry: primary structured schema; optional text parsing fallback for
+ convenience.
+
+## Public API / Interface Changes
+
+### New built-in tool
+
+- Name: `render_visualization`
+- Display: `Render Visualization`
+- Category: built-in core tool (`Kind.Other`)
+
+### Tool input schema
+
+- `chartType` (required): `"bar" | "line" | "table"`
+- `title` (optional): string
+- `subtitle` (optional): string
+- `xLabel` (optional): string
+- `yLabel` (optional): string
+- `series` (required): array of series
+- `series[].name` (required): string
+- `series[].points` (required): array of `{ label: string, value: number }`
+- `sort` (optional): `"none" | "asc" | "desc"` (applies to single-series
+ bar/table)
+- `maxPoints` (optional): number (default 30, cap 200)
+- `inputText` (optional fallback): string (JSON/table/CSV-like text to parse
+ when `series` omitted)
+- `unit` (optional): string (e.g., `"s"`)
+
+### Tool output display type
+
+Add new `ToolResultDisplay` variant:
+
+- `VisualizationDisplay`:
+- `type: "visualization"`
+- `chartType: "bar" | "line" | "table"`
+- `title?`, `subtitle?`, `xLabel?`, `yLabel?`, `unit?`
+- `series`
+- `meta`:
+ `{ truncated: boolean, originalPointCount: number, fallbackMode?: "unicode"|"ascii" }`
+
+## Architecture and Data Flow
+
+## 1. Tool implementation (core)
+
+- New file: `packages/core/src/tools/render-visualization.ts`
+- Implements validation + normalization pipeline:
+
+1. Resolve input source:
+
+- use `series` if provided,
+- else parse `inputText`.
+
+2. Validate numeric values (finite only), normalize labels as strings.
+3. Apply chart-specific constraints:
+
+- `line`: preserve chronological order unless explicit sort disabled.
+- `bar/table`: optional sort.
+
+4. Apply volume controls (`maxPoints`, truncation metadata).
+5. Return:
+
+- `llmContent`: concise factual summary + normalized data preview.
+- `returnDisplay`: typed `VisualizationDisplay`.
+
+## 2. Built-in registration
+
+- Register in `packages/core/src/config/config.ts` (`createToolRegistry()`).
+- Add tool-name constants and built-in lists:
+- `packages/core/src/tools/tool-names.ts`
+- `packages/core/src/tools/definitions/coreTools.ts`
+
+## 3. Prompt guidance (critical for this scenario)
+
+- Update prompt snippets so model reliably chooses this tool:
+- In tool-usage guidance section: "When user asks to compare, chart, trend, or
+ show tabular metrics, gather/compute data first, then call
+ `render_visualization` with structured series."
+- Keep short and deterministic; do not over-prescribe style.
+- Include 1 canonical example in tool description: "BMW 0-60 comparison."
+
+## 4. CLI rendering (Ink)
+
+- Extend `packages/cli/src/ui/components/messages/ToolResultDisplay.tsx` to
+ handle `VisualizationDisplay`.
+- Add renderer components:
+- `VisualizationDisplay.tsx` dispatcher
+- `BarChartDisplay.tsx`
+- `LineChartDisplay.tsx`
+- `TableVizDisplay.tsx`
+
+### Rendering behavior
+
+- Inline panel, width-aware, no alternate screen required.
+- Unicode first (`█`, box chars), ASCII fallback when needed.
+- Label truncation + right-aligned values.
+- Height caps to preserve conversational viewport.
+- Multi-series behavior:
+- V1: `line` supports multi-series.
+- `bar/table`: single-series required in v1 (validation error if more than one).
+
+## 5. Natural-language to schema reliability strategy
+
+- Primary expectation: model transforms researched data into `series`.
+- Fallback parser for `inputText` supports:
+- JSON object map
+- JSON array records
+- Markdown table
+- CSV-like 2-column text
+- Ambiguous prose parsing intentionally rejected with actionable error +
+ accepted examples.
+- This avoids silent bad charts and improves reasoning-loop consistency.
+
+## 6. Dynamic volume strategy
+
+- Defaults:
+- `maxPoints = 30`
+- Hard cap `200`
+- For larger sets:
+- `bar/table`: keep top N by absolute value (or chronological when user asks
+ "last N years").
+- `line`: downsample uniformly while preserving first/last points.
+- Always annotate truncation in `meta` and human-readable footer.
+
+## Testing Plan
+
+## Core tests
+
+- New: `packages/core/src/tools/render-visualization.test.ts`
+- Cases:
+- Valid single-series bar (BMW 0-60 style).
+- Valid line trend (yearly counts).
+- Valid table rendering payload.
+- Multi-series line accepted.
+- Multi-series bar rejected.
+- `inputText` parse success for JSON/table/CSV.
+- Ambiguous prose rejected with guidance.
+- Sort behavior correctness.
+- Volume truncation/downsampling correctness.
+- Unit handling and numeric validation.
+
+## Registration and schema tests
+
+- Update `packages/core/src/config/config.test.ts`:
+- tool registers by default
+- respects `tools.core` allowlist and `tools.exclude`
+- Update tool definition snapshots for model function declarations.
+
+## Prompt tests
+
+- Update prompt snapshot tests to assert presence of visualization guidance line
+ and tool name substitution.
+
+## UI tests
+
+- New:
+- `packages/cli/src/ui/components/messages/VisualizationDisplay.test.tsx`
+- `BarChartDisplay.test.tsx`
+- `LineChartDisplay.test.tsx`
+- `TableVizDisplay.test.tsx`
+- Validate:
+- width adaptation (narrow/normal)
+- unicode/ascii fallback
+- long labels
+- truncation indicators
+- no overflow crashes
+
+## Integration tests
+
+- Add scenario tests for:
+
+1. Research + bar chart call pattern.
+2. Same-session follow-up question with different metric and chart type.
+
+- Validate tool call args shape and final rendered output branch selection.
+
+## Rollout Plan
+
+- Feature flag: `experimental.visualizationToolV1` default `false`.
+- Dogfood + internal beta.
+- Telemetry:
+- invocation rate
+- schema validation failure rate
+- parse fallback usage
+- render fallback (unicode->ascii) rate
+- per-chart-type success.
+- Flip default to `true` after stability threshold.
+
+## Risks and Mitigations
+
+- Risk: model under-calls tool.
+- Mitigation: explicit prompt guidance + strong tool description examples.
+- Risk: terminal incompatibilities.
+- Mitigation: deterministic ASCII fallback and conservative layout.
+- Risk: oversized datasets.
+- Mitigation: hard caps + truncation/downsampling metadata.
+- Risk: noisy prose parsing.
+- Mitigation: strict parser and explicit rejection path.
+
+## Assumptions and Defaults
+
+- Modern terminal baseline supports ANSI color and common Unicode; fallback
+ always available.
+- V1 interactivity (keyboard-driven selections/buttons) is out of scope.
+- Browser/WebView rendering is out of scope.
+- V1 excludes negative-value bars.