Compare commits

..

1 Commits

Author SHA1 Message Date
Shreya Keshive 0fda45d9f6 init 2026-02-10 16:14:34 -05:00
22 changed files with 264 additions and 3525 deletions
+3
View File
@@ -181,6 +181,9 @@ export interface SessionRetentionSettings {
/** Minimum retention period (safety limit, defaults to "1d") */
minRetention?: string;
/** Timestamp (ISO string or ms) to start age checks from for existing files. */
gracePeriodStart?: string;
}
export interface SettingsError {
+10
View File
@@ -319,6 +319,16 @@ const SETTINGS_SCHEMA = {
description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`,
showInDialog: false,
},
gracePeriodStart: {
type: 'string',
label: 'Grace Period Start',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Timestamp (ISO string) to start age checks from for existing files.',
showInDialog: false,
},
},
description: 'Settings for automatic session cleanup.',
},
+51
View File
@@ -141,6 +141,10 @@ import {
QUEUE_ERROR_DISPLAY_DURATION_MS,
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import {
type SessionRetentionChoice,
SessionRetentionWarningDialog,
} from './components/SessionRetentionWarningDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
@@ -242,6 +246,49 @@ export const AppContainer = (props: AppContainerProps) => {
const [warningBannerText, setWarningBannerText] = useState('');
const [bannerVisible, setBannerVisible] = useState(true);
const [showRetentionWarning, setShowRetentionWarning] = useState(false);
const retentionCheckDone = useRef(false);
useEffect(() => {
if (retentionCheckDone.current) return;
retentionCheckDone.current = true;
// We only want to show this if the user hasn't explicitly set it in User or Workspace scope.
const userSettings = settings.user.settings;
const workspaceSettings = settings.workspace.settings;
const userRetention = userSettings.general?.sessionRetention;
const workspaceRetention = workspaceSettings.general?.sessionRetention;
// Check if it's completely undefined in both explicit scopes
if (userRetention === undefined && workspaceRetention === undefined) {
setShowRetentionWarning(true);
}
}, [settings]);
const handleRetentionConfirm = useCallback(
(choice: SessionRetentionChoice) => {
const now = new Date().toISOString();
const retentionSettings: any = {
enabled: true,
maxAge: '60d',
};
if (choice === 'graceful') {
retentionSettings.gracePeriodStart = now;
}
// Store in Workspace scope as requested
settings.setValue(
SettingScope.Workspace,
'general.sessionRetention',
retentionSettings,
);
setShowRetentionWarning(false);
},
[settings],
);
const bannerData = useMemo(
() => ({
defaultText: defaultBannerText,
@@ -2231,6 +2278,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
}
if (showRetentionWarning) {
return <SessionRetentionWarningDialog onConfirm={handleRetentionConfirm} />;
}
return (
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
@@ -106,6 +106,10 @@ 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 ` +
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
import { act } from 'react';
describe('SessionRetentionWarningDialog', () => {
const onConfirm = vi.fn();
afterEach(() => {
vi.restoreAllMocks();
});
it('should render correctly', () => {
const { lastFrame } = renderWithProviders(
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Session Retention Policy');
expect(lastFrame()).toContain('Graceful Cleanup');
expect(lastFrame()).toContain('Strict Cleanup');
});
it('should confirm with "graceful" when selecting "Graceful Cleanup"', async () => {
const { stdin } = renderWithProviders(
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
);
// Default selection is "Graceful Cleanup", so just press Enter
act(() => {
stdin.write('\r');
});
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith('graceful');
});
});
it('should confirm with "strict" when selecting "Strict Cleanup"', async () => {
const { stdin } = renderWithProviders(
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
);
// Move down to "Strict Cleanup"
act(() => {
stdin.write('\x1b[B'); // Arrow Down
});
// Press Enter
act(() => {
stdin.write('\r');
});
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith('strict');
});
});
});
@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
DescriptiveRadioButtonSelect,
type DescriptiveRadioSelectItem,
} from './shared/DescriptiveRadioButtonSelect.js';
export type SessionRetentionChoice = 'graceful' | 'strict';
interface SessionRetentionWarningDialogProps {
onConfirm: (choice: SessionRetentionChoice) => void;
}
export const SessionRetentionWarningDialog = ({
onConfirm,
}: SessionRetentionWarningDialogProps): React.JSX.Element => {
const items: Array<DescriptiveRadioSelectItem<SessionRetentionChoice>> = [
{
key: 'graceful',
title: 'Graceful Cleanup (Recommended)',
description:
'Start 60-day limit from today. Existing sessions are safe for now.',
value: 'graceful',
},
{
key: 'strict',
title: 'Strict Cleanup',
description:
'Apply 60-day limit immediately. Older sessions will be deleted.',
value: 'strict',
},
];
return (
<Box
borderStyle="round"
borderColor={theme.status.warning}
flexDirection="column"
padding={1}
width={60}
>
<Box marginBottom={1}>
<Text color={theme.status.warning} bold>
Session Retention Policy
</Text>
</Box>
<Box marginBottom={1}>
<Text>
To save disk space, we recommend enabling automatic cleanup of old
sessions. Please choose how to apply the 60-day retention policy:
</Text>
</Box>
<DescriptiveRadioButtonSelect
items={items}
onSelect={onConfirm}
initialIndex={0}
/>
</Box>
);
};
@@ -8,7 +8,6 @@ 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();
@@ -191,77 +190,6 @@ 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,10 +19,6 @@ 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
@@ -184,19 +180,6 @@ 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
@@ -1,194 +0,0 @@
/**
* @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 table object rows with humanized columns without blank cells', () => {
const visualization: VisualizationDisplay = {
type: 'visualization',
kind: 'table',
title: 'Server Health Check',
data: {
columns: ['Server Name', 'CPU %', 'Memory GB', 'Status'],
rows: [
{
server_name: 'api-1',
cpu_percent: 62,
memoryGb: 8,
status: 'healthy',
},
],
},
meta: {
truncated: false,
originalItemCount: 1,
},
};
const { lastFrame } = render(
<VisualizationResultDisplay visualization={visualization} width={100} />,
);
const frame = lastFrame();
expect(frame).toContain('Server Name');
expect(frame).toContain('api-1');
expect(frame).toContain('healthy');
});
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
+60 -1
View File
@@ -198,7 +198,66 @@ async function identifySessionsToDelete(
if (retentionConfig.maxAge) {
try {
const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge);
cutoffDate = new Date(now.getTime() - maxAgeMs);
const nowMs = now.getTime();
// If gracePeriodStart is set, we treat files as if they were created at
// max(file.mtime, gracePeriodStart) for the purpose of age check?
// actually, simpler:
// The deletion condition is:
// age > maxAge AND (now > gracePeriodStart + maxAge)
// Wait, if I have a file from 2020.
// gracePeriodStart = Today.
// I want it to survive for 60 days.
// So effectively, the file's "effective mtime" is max(real_mtime, gracePeriodStart).
// We check if (now - effective_mtime) > maxAge.
if (retentionConfig.gracePeriodStart) {
const graceStart = new Date(retentionConfig.gracePeriodStart).getTime();
if (!isNaN(graceStart)) {
// We can't set a single cutoff date because it depends on the file's mtime vs graceStart?
// Actually, we can.
// effectiveAge = now - max(mtime, graceStart)
// We want effectiveAge > maxAge
// now - max(mtime, graceStart) > maxAge
// max(mtime, graceStart) < now - maxAge
// mtime < now - maxAge AND graceStart < now - maxAge
// So, BOTH must be true.
// If graceStart is recent (today), then `graceStart < now - maxAge` is FALSE.
// So nothing gets deleted.
// This works perfectly!
// So checking `graceStart < now - maxAge` is a global gate.
// If we are within the grace period (globally), we delete NOTHING based on age?
// Wait, what if a file was created *after* graceStart?
// mtime (Post-Grace) < now - maxAge.
// If graceStart is today.
// File created tomorrow.
// now is tomorrow + 61 days.
// mtime < now - maxAge (True).
// graceStart < now - maxAge (True, because graceStart was 61 days ago).
// So it deletes.
// This logic holds up.
// We just need to ensure we use the SAME cutoff for all files?
// Yes, `cutoffDate` is `now - maxAge`.
// And we also verify `graceStart < cutoffDate`.
if (graceStart > nowMs - maxAgeMs) {
// We are still within the global grace period.
// UNLESS the file is somehow older than maxAge AND older than graceStart?
// No, if `graceStart > cutoffDate`, then `max(mtime, graceStart)` is at least `graceStart`.
// `graceStart > cutoffDate` means `graceStart > now - maxAge`.
// So `now - graceStart < maxAge`.
// So effective age is ALWAYS < maxAge.
// So NOTHING deletes.
cutoffDate = null; // Disable age cleanup
} else {
cutoffDate = new Date(nowMs - maxAgeMs);
}
} else {
cutoffDate = new Date(nowMs - maxAgeMs);
}
} else {
cutoffDate = new Date(nowMs - maxAgeMs);
}
} catch {
// This should not happen as validation should have caught it,
// but handle gracefully just in case
-4
View File
@@ -36,7 +36,6 @@ 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';
@@ -2423,9 +2422,6 @@ 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)),
-1
View File
@@ -157,7 +157,6 @@ 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,7 +28,6 @@ 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';
@@ -186,9 +185,6 @@ export class PromptProvider {
isGemini3,
enableShellEfficiency: config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: config.isInteractiveShellEnabled(),
enableVisualizationTool: enabledToolNames.has(
RENDER_VISUALIZATION_TOOL_NAME,
),
}),
),
sandbox: this.withSection('sandbox', () => getSandboxMode()),
+1 -15
View File
@@ -15,7 +15,6 @@ 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,
@@ -62,7 +61,6 @@ export interface OperationalGuidelinesOptions {
isGemini3: boolean;
enableShellEfficiency: boolean;
interactiveShellEnabled: boolean;
enableVisualizationTool?: boolean;
}
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
@@ -272,7 +270,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,
)}${toolUsageVisualization(options.enableVisualizationTool)}${toolUsageRememberingFacts(options)}
)}${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
@@ -623,18 +621,6 @@ 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
? `
+1 -15
View File
@@ -14,7 +14,6 @@ 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,
@@ -64,7 +63,6 @@ export interface OperationalGuidelinesOptions {
interactive: boolean;
isGemini3: boolean;
interactiveShellEnabled: boolean;
enableVisualizationTool?: boolean;
}
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
@@ -294,7 +292,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,
)}${toolUsageVisualization(options.enableVisualizationTool)}${toolUsageRememberingFacts(options)}
)}${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
@@ -642,18 +640,6 @@ 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,7 +14,6 @@ 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
@@ -1,392 +0,0 @@
/**
* @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('maps table object rows when column labels are humanized', async () => {
const result = await tool.buildAndExecute(
{
visualizationKind: 'table',
data: {
columns: ['Server Name', 'CPU %', 'Memory GB', 'Status'],
rows: [
{
server_name: 'api-1',
cpu_percent: 62,
memoryGb: 8,
status: 'healthy',
},
],
},
},
signal,
);
expect(result.returnDisplay).toMatchObject({
type: 'visualization',
kind: 'table',
data: {
rows: [['api-1', 62, 8, 'healthy']],
},
});
});
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('accepts top-level diagram direction alias', async () => {
const result = await tool.buildAndExecute(
{
visualizationKind: 'diagram',
direction: 'TB',
data: {
nodes: [
{ id: 'start', label: 'Start' },
{ id: 'end', label: 'End' },
],
edges: [{ from: 'start', to: 'end' }],
},
},
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
-3
View File
@@ -9,7 +9,6 @@ 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';
@@ -23,7 +22,6 @@ export {
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
RENDER_VISUALIZATION_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
};
@@ -96,7 +94,6 @@ export const ALL_BUILTIN_TOOL_NAMES = [
MEMORY_TOOL_NAME,
ACTIVATE_SKILL_TOOL_NAME,
ASK_USER_TOOL_NAME,
RENDER_VISUALIZATION_TOOL_NAME,
] as const;
/**
+1 -80
View File
@@ -664,86 +664,7 @@ export interface TodoList {
todos: Todo[];
}
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 ToolResultDisplay = string | FileDiff | AnsiOutput | TodoList;
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
-245
View File
@@ -1,245 +0,0 @@
# 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.