mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 13:22:35 -07:00
Add experimental built-in visualization tooling
This commit is contained in:
@@ -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 ` +
|
||||
|
||||
@@ -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(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={visualization}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={visualization}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
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
|
||||
|
||||
@@ -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<ToolResultDisplayProps> = ({
|
||||
{truncatedResultDisplay}
|
||||
</Text>
|
||||
);
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'object' &&
|
||||
'type' in truncatedResultDisplay &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(truncatedResultDisplay as VisualizationResult).type === 'visualization'
|
||||
) {
|
||||
content = (
|
||||
<VisualizationResultDisplay
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
visualization={truncatedResultDisplay as VisualizationResult}
|
||||
width={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'object' &&
|
||||
'fileDiff' in truncatedResultDisplay
|
||||
|
||||
@@ -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(
|
||||
<VisualizationResultDisplay visualization={visualization} width={80} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<VisualizationResultDisplay visualization={line} width={70} />,
|
||||
).lastFrame();
|
||||
const pieFrame = render(
|
||||
<VisualizationResultDisplay visualization={pie} width={70} />,
|
||||
).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(
|
||||
<VisualizationResultDisplay visualization={visualization} width={90} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<VisualizationResultDisplay visualization={visualization} width={90} />,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
? `
|
||||
|
||||
@@ -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
|
||||
? `
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Array<string | number | boolean>>;
|
||||
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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user