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.