Compare commits

..

2 Commits

Author SHA1 Message Date
jacob314 1817ce8bad Polish UX to be width agnostic 2026-03-04 00:43:08 -08:00
jacob314 2717cacb8c test(cli): fix flaky QuotaDisplay snapshot and env leakage in StatusDisplay 2026-03-02 12:22:11 -08:00
64 changed files with 495 additions and 944 deletions
+6 -22
View File
@@ -143,27 +143,13 @@ based on the task description.
### Customizing Policies
Plan Mode's default tool restrictions are managed by the [policy engine] and
defined in the built-in [`plan.toml`] file. The built-in policy (Tier 1)
enforces the read-only state, but you can customize these rules by creating your
own policies in your `~/.gemini/policies/` directory (Tier 2).
Plan Mode is designed to be read-only by default to ensure safety during the
research phase. However, you may occasionally need to allow specific tools to
assist in your planning.
#### Example: Automatically approve read-only MCP tools
By default, read-only MCP tools require user confirmation in Plan Mode. You can
use `toolAnnotations` and the `mcpName` wildcard to customize this behavior for
your specific environment.
`~/.gemini/policies/mcp-read-only.toml`
```toml
[[rule]]
mcpName = "*"
toolAnnotations = { readOnlyHint = true }
decision = "allow"
priority = 100
modes = ["plan"]
```
Because user policies (Tier 2) have a higher base priority than built-in
policies (Tier 1), you can override Plan Mode's default restrictions by creating
a rule in your `~/.gemini/policies/` directory.
#### Example: Allow git commands in Plan Mode
@@ -257,5 +243,3 @@ argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
[`ask_user`]: /docs/tools/ask-user.md
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
[`plan.toml`]:
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
-14
View File
@@ -170,20 +170,6 @@ messages and how to resolve them.
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
continues, open a new terminal window or restart your IDE.
### Manual PID override
If automatic IDE detection fails, or if you are running Gemini CLI in a
standalone terminal and want to manually associate it with a specific IDE
instance, you can set the `GEMINI_CLI_IDE_PID` environment variable to the
process ID (PID) of your IDE.
```bash
export GEMINI_CLI_IDE_PID=12345
```
When this variable is set, Gemini CLI will skip automatic detection and attempt
to connect using the provided PID.
### Configuration errors
- **Message:**
-5
View File
@@ -1274,11 +1274,6 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Specifies the default Gemini model to use.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"`
- **`GEMINI_CLI_IDE_PID`**:
- Manually specifies the PID of the IDE process to use for integration. This
is useful when running Gemini CLI in a standalone terminal while still
wanting to associate it with a specific IDE instance.
- Overrides the automatic IDE detection logic.
- **`GEMINI_CLI_HOME`**:
- Specifies the root directory for Gemini CLI's user-level configuration and
storage.
-4
View File
@@ -205,10 +205,6 @@ toolName = "run_shell_command"
# to form a composite name like "mcpName__toolName".
mcpName = "my-custom-server"
# (Optional) Metadata hints provided by the tool. A rule matches if all
# key-value pairs provided here are present in the tool's annotations.
toolAnnotations = { readOnlyHint = true }
# (Optional) A regex to match against the tool's arguments.
argsPattern = '"command":"(git|npm)'
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-warnings=DEP0040
#!/usr/bin/env node
/**
* @license
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-warnings=DEP0040
#!/usr/bin/env node
/**
* @license
@@ -530,7 +530,6 @@ Would you like to attempt to install via "git clone" instead?`,
return this.loadedExtensions;
}
for (const subdir of fs.readdirSync(extensionsDir)) {
if (subdir === '.env') continue;
const extensionDir = path.join(extensionsDir, subdir);
await this.loadExtension(extensionDir);
}
-11
View File
@@ -280,17 +280,6 @@ describe('extension tests', () => {
]);
});
it('should ignore .env directory in extensions folder', async () => {
// Create a .env directory
const envDir = path.join(userExtensionsDir, '.env');
fs.mkdirSync(envDir);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toEqual([]);
const { debugLogger } = await import('@google/gemini-cli-core');
expect(debugLogger.error).not.toHaveBeenCalled();
});
it('should annotate disabled extensions', async () => {
createExtension({
extensionsDir: userExtensionsDir,
@@ -468,32 +468,6 @@ describe('extensionSettings', () => {
expect(mockIsAvailable).toHaveBeenCalled();
expect(mockListSecrets).not.toHaveBeenCalled();
});
it('should throw error if .env is a directory when prompting for settings', async () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
};
const envFilePath = path.join(extensionDir, '.env');
if (fs.existsSync(envFilePath)) {
fs.unlinkSync(envFilePath);
}
fs.mkdirSync(envFilePath);
mockRequestSetting.mockResolvedValue('new-value');
await expect(
maybePromptForSettings(
config,
'12345',
mockRequestSetting,
undefined,
undefined,
),
).rejects.toThrow(
`Cannot update user-scoped settings because "${envFilePath}" is a directory.`,
);
});
});
describe('promptForSetting', () => {
@@ -616,23 +590,6 @@ describe('extensionSettings', () => {
SENSITIVE_VAR: 'workspace-secret',
});
});
it('should ignore .env if it is a directory', async () => {
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
if (fs.existsSync(userEnvPath)) {
fs.unlinkSync(userEnvPath);
}
fs.mkdirSync(userEnvPath);
const contents = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.USER,
tempWorkspaceDir,
);
expect(contents).toEqual({});
});
});
describe('getEnvContents (merged)', () => {
@@ -933,27 +890,5 @@ describe('extensionSettings', () => {
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
expect(actualContent).toContain('VAR1="value with \\"quotes\\""');
});
it('should throw error if .env is a directory when updating a non-sensitive setting', async () => {
const envFilePath = path.join(extensionDir, '.env');
if (fs.existsSync(envFilePath)) {
fs.unlinkSync(envFilePath);
}
fs.mkdirSync(envFilePath);
mockRequestSetting.mockResolvedValue('new-value');
await expect(
updateSetting(
config,
'12345',
'VAR1',
mockRequestSetting,
ExtensionSettingScope.USER,
tempWorkspaceDir,
),
).rejects.toThrow(
`Cannot update user-scoped settings because "${envFilePath}" is a directory.`,
);
});
});
});
@@ -124,15 +124,6 @@ export async function maybePromptForSettings(
const envContent = formatEnvContent(nonSensitiveSettings);
if (
fsSync.existsSync(envFilePath) &&
fsSync.statSync(envFilePath).isDirectory()
) {
throw new Error(
`Cannot update ${scope}-scoped settings because "${envFilePath}" is a directory.`,
);
}
await fs.writeFile(envFilePath, envContent);
}
@@ -181,7 +172,7 @@ export async function getScopedEnvContents(
);
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let customEnv: Record<string, string> = {};
if (fsSync.existsSync(envFilePath) && fsSync.statSync(envFilePath).isFile()) {
if (fsSync.existsSync(envFilePath)) {
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
customEnv = dotenv.parse(envFile);
}
@@ -267,16 +258,6 @@ export async function updateSetting(
// For non-sensitive settings, we need to read the existing .env file,
// update the value, and write it back, preserving any other values.
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
if (
fsSync.existsSync(envFilePath) &&
fsSync.statSync(envFilePath).isDirectory()
) {
throw new Error(
`Cannot update ${scope}-scoped settings because "${envFilePath}" is a directory.`,
);
}
let envContent = '';
if (fsSync.existsSync(envFilePath)) {
envContent = await fs.readFile(envFilePath, 'utf-8');
@@ -342,7 +323,7 @@ async function clearSettings(
envFilePath: string,
keychain: KeychainTokenStorage,
) {
if (fsSync.existsSync(envFilePath) && fsSync.statSync(envFilePath).isFile()) {
if (fsSync.existsSync(envFilePath)) {
await fs.writeFile(envFilePath, '');
}
if (!(await keychain.isAvailable())) {
+3 -5
View File
@@ -393,11 +393,9 @@ export const render = (
exitOnCtrlC: false,
patchConsole: false,
onRender: (metrics: RenderMetrics) => {
const output = isInkRenderMetrics(metrics) ? metrics.output : '...';
const staticOutput = isInkRenderMetrics(metrics)
? (metrics.staticOutput ?? '')
: '';
stdout.onRender(staticOutput, output);
if (isInkRenderMetrics(metrics)) {
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
}
},
});
});
-6
View File
@@ -53,12 +53,6 @@ export const Colors: ColorsTheme = {
get DarkGray() {
return themeManager.getColors().DarkGray;
},
get InputBackground() {
return themeManager.getColors().InputBackground;
},
get MessageBackground() {
return themeManager.getColors().MessageBackground;
},
get GradientColors() {
return themeManager.getActiveTheme().colors.GradientColors;
},
@@ -182,7 +182,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('15%');
expect(lastFrame()).toContain('85%');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -96,8 +96,6 @@ describe('<Header />', () => {
},
background: {
primary: '',
message: '',
input: '',
diff: { added: '', removed: '' },
},
border: {
+13 -2
View File
@@ -56,6 +56,10 @@ import {
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
} from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
@@ -222,6 +226,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
hintMode,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
@@ -1417,8 +1422,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
) : null}
<HalfLinePaddedBox
backgroundBaseColor={theme.background.input}
backgroundOpacity={1}
backgroundBaseColor={
hintMode ? theme.text.accent : theme.text.secondary
}
backgroundOpacity={
showCursor
? DEFAULT_INPUT_BACKGROUND_OPACITY
: DEFAULT_BACKGROUND_OPACITY
}
useBackgroundColor={useBackgroundColor}
>
<Box
@@ -5,10 +5,20 @@
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QuotaDisplay } from './QuotaDisplay.js';
describe('QuotaDisplay', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'America/Los_Angeles');
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-02T20:29:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});
it('should not render when remaining is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={undefined} limit={100} />,
@@ -36,7 +46,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should not render when usage > 20%', async () => {
it('should not render when usage < 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={85} limit={100} />,
);
@@ -45,7 +55,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render yellow when usage < 20%', async () => {
it('should render warning when used >= 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={15} limit={100} />,
);
@@ -54,7 +64,7 @@ describe('QuotaDisplay', () => {
unmount();
});
it('should render red when usage < 5%', async () => {
it('should render critical when used >= 95%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<QuotaDisplay remaining={4} limit={100} />,
);
+18 -16
View File
@@ -7,9 +7,9 @@
import type React from 'react';
import { Text } from 'ink';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { formatResetTime } from '../utils/formatters.js';
@@ -30,26 +30,24 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
return null;
}
const percentage = (remaining / limit) * 100;
const usedPercentage = 100 - (remaining / limit) * 100;
if (percentage > QUOTA_THRESHOLD_HIGH) {
if (usedPercentage < QUOTA_USED_WARNING_THRESHOLD) {
return null;
}
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
const resetInfo =
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
if (remaining === 0) {
const resetMsg = resetTime
? `, resets in ${formatResetTime(resetTime, true)}`
: '';
return (
<Text color={color}>
{terse
? 'Limit reached'
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`}
{terse ? 'Limit reached' : `Limit reached${resetMsg}`}
</Text>
);
}
@@ -57,8 +55,12 @@ export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
return (
<Text color={color}>
{terse
? `${percentage.toFixed(0)}%`
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`}
? `${usedPercentage.toFixed(0)}%`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime, true)})`
: ''
}`}
</Text>
);
};
@@ -9,9 +9,9 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { formatResetTime } from '../utils/formatters.js';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
interface QuotaStatsInfoProps {
@@ -31,19 +31,24 @@ export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
return null;
}
const percentage = (remaining / limit) * 100;
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
const usedPercentage = 100 - (remaining / limit) * 100;
const color = getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
});
return (
<Box flexDirection="column" marginTop={0} marginBottom={0}>
<Text color={color}>
{remaining === 0
? `Limit reached`
: `${percentage.toFixed(0)}% usage remaining`}
{resetTime && `, ${formatResetTime(resetTime)}`}
? `Limit reached${
resetTime ? `, resets in ${formatResetTime(resetTime, true)}` : ''
}`
: `${usedPercentage.toFixed(0)}% used${
resetTime
? ` (Limit resets in ${formatResetTime(resetTime, true)})`
: ''
}`}
</Text>
{showDetails && (
<>
@@ -67,7 +67,7 @@ export const ShortcutsHelp: React.FC = () => {
return (
<Box flexDirection="column" width="100%">
<SectionHeader title=" Shortcuts" subtitle=" See /help for more" />
<SectionHeader title="Shortcuts (for more, see /help)" />
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
{itemsForDisplay.map((item, index) => (
<Box
@@ -465,9 +465,9 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Usage remaining');
expect(output).toContain('75.0%');
expect(output).toContain('resets in 1h 30m');
expect(output).toContain('Model usage');
expect(output).toContain('25%');
expect(output).toContain('Usage resets');
expect(output).toMatchSnapshot();
vi.useRealTimers();
@@ -521,8 +521,8 @@ describe('<StatsDisplay />', () => {
await waitUntilReady();
const output = lastFrame();
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
expect(output).toContain('65% usage remaining');
// (1 - 710/1100) * 100 = 35.5%
expect(output).toContain('35%');
expect(output).toContain('Usage limit: 1,100');
expect(output).toMatchSnapshot();
@@ -571,8 +571,8 @@ describe('<StatsDisplay />', () => {
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('-'); // for requests
expect(output).toContain('50.0%');
expect(output).toContain('resets in 2h');
expect(output).toContain('50%');
expect(output).toContain('Usage resets');
expect(output).toMatchSnapshot();
vi.useRealTimers();
+164 -40
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Box, Text, useStdout } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import { formatDuration, formatResetTime } from '../utils/formatters.js';
@@ -19,6 +19,9 @@ import {
USER_AGREEMENT_RATE_MEDIUM,
CACHE_EFFICIENCY_HIGH,
CACHE_EFFICIENCY_MEDIUM,
getUsedStatusColor,
QUOTA_USED_WARNING_THRESHOLD,
QUOTA_USED_CRITICAL_THRESHOLD,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import {
@@ -155,6 +158,8 @@ const ModelUsageTable: React.FC<{
useGemini3_1,
useCustomToolModel,
}) => {
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 84;
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
if (rows.length === 0) {
@@ -163,12 +168,47 @@ const ModelUsageTable: React.FC<{
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
const nameWidth = 25;
const requestsWidth = 7;
const nameWidth = 23;
const requestsWidth = 5;
const uncachedWidth = 15;
const cachedWidth = 14;
const outputTokensWidth = 15;
const usageLimitWidth = showQuotaColumn ? 28 : 0;
const percentageWidth = showQuotaColumn ? 6 : 0;
const resetWidth = 22;
// Total width of other columns (including parent box paddingX={2})
const fixedWidth = nameWidth + requestsWidth + percentageWidth + resetWidth;
const outerPadding = 4;
const availableForUsage = terminalWidth - outerPadding - fixedWidth;
const usageLimitWidth = showQuotaColumn
? Math.max(10, Math.min(24, availableForUsage))
: 0;
const progressBarWidth = Math.max(2, usageLimitWidth - 4);
const renderProgressBar = (
usedFraction: number,
color: string,
totalSteps = 20,
) => {
let filledSteps = Math.round(usedFraction * totalSteps);
// If something is used (fraction > 0) but rounds to 0, show 1 tick.
// If < 100% (fraction < 1) but rounds to 20, show 19 ticks.
if (usedFraction > 0 && usedFraction < 1) {
filledSteps = Math.min(Math.max(filledSteps, 1), totalSteps - 1);
}
const emptySteps = Math.max(0, totalSteps - filledSteps);
return (
<Box flexDirection="row" flexShrink={0}>
<Text wrap="truncate-end">
<Text color={color}>{'▬'.repeat(filledSteps)}</Text>
<Text color={theme.border.default}>{'▬'.repeat(emptySteps)}</Text>
</Text>
</Box>
);
};
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
green: CACHE_EFFICIENCY_HIGH,
@@ -179,25 +219,13 @@ const ModelUsageTable: React.FC<{
nameWidth +
requestsWidth +
(showQuotaColumn
? usageLimitWidth
? usageLimitWidth + percentageWidth + resetWidth
: uncachedWidth + cachedWidth + outputTokensWidth);
const isAuto = currentModel && isAutoModel(currentModel);
const modelUsageTitle = isAuto
? `${getDisplayString(currentModel)} Usage`
: `Model Usage`;
return (
<Box flexDirection="column" marginBottom={1}>
{/* Header */}
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Text bold color={theme.text.primary} wrap="truncate-end">
{modelUsageTitle}
</Text>
</Box>
</Box>
{isAuto &&
showQuotaColumn &&
pooledRemaining !== undefined &&
@@ -216,7 +244,7 @@ const ModelUsageTable: React.FC<{
)}
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Box width={nameWidth} flexShrink={0}>
<Text bold color={theme.text.primary}>
Model
</Text>
@@ -267,15 +295,31 @@ const ModelUsageTable: React.FC<{
</>
)}
{showQuotaColumn && (
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
<Text bold color={theme.text.primary}>
Usage remaining
</Text>
</Box>
<>
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={4}
flexShrink={0}
>
<Text bold color={theme.text.primary}>
Model usage
</Text>
</Box>
<Box width={percentageWidth} flexShrink={0} />
<Box
width={resetWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={2}
flexShrink={0}
>
<Text bold color={theme.text.primary} wrap="truncate-end">
Usage resets
</Text>
</Box>
</>
)}
</Box>
@@ -292,7 +336,7 @@ const ModelUsageTable: React.FC<{
{rows.map((row) => (
<Box key={row.key}>
<Box width={nameWidth}>
<Box width={nameWidth} flexShrink={0}>
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
wrap="truncate-end"
@@ -352,20 +396,100 @@ const ModelUsageTable: React.FC<{
</Box>
</>
)}
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-end"
>
{row.bucket &&
row.bucket.remainingFraction != null &&
row.bucket.resetTime && (
{showQuotaColumn && (
<>
<Box
width={usageLimitWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={4}
flexShrink={0}
>
{row.bucket && row.bucket.remainingFraction != null && (
<Box flexDirection="row" flexShrink={0}>
{(() => {
const actualUsedFraction =
1 - row.bucket.remainingFraction;
const effectiveUsedFraction =
actualUsedFraction === 0 && row.isActive
? 0.001
: actualUsedFraction;
const usedPercentage = effectiveUsedFraction * 100;
const statusColor =
getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
}) ??
(row.isActive ? theme.text.primary : theme.ui.comment);
return renderProgressBar(
effectiveUsedFraction,
statusColor,
progressBarWidth,
);
})()}
</Box>
)}
</Box>
<Box
width={percentageWidth}
flexDirection="column"
alignItems="flex-end"
flexShrink={0}
>
{row.bucket && row.bucket.remainingFraction != null && (
<Box>
{(() => {
const actualUsedFraction =
1 - row.bucket.remainingFraction;
const effectiveUsedFraction =
actualUsedFraction === 0 && row.isActive
? 0.001
: actualUsedFraction;
const usedPercentage = effectiveUsedFraction * 100;
const statusColor =
getUsedStatusColor(usedPercentage, {
warning: QUOTA_USED_WARNING_THRESHOLD,
critical: QUOTA_USED_CRITICAL_THRESHOLD,
}) ??
(row.isActive ? theme.text.primary : theme.ui.comment);
const percentageText =
usedPercentage > 0 && usedPercentage < 1
? `${usedPercentage.toFixed(1)}%`
: `${usedPercentage.toFixed(0)}%`;
return row.bucket.remainingFraction === 0 ? (
<Text color={theme.status.error} wrap="truncate-end">
Limit
</Text>
) : (
<Text color={statusColor} wrap="truncate-end">
{percentageText}
</Text>
);
})()}
</Box>
)}
</Box>
<Box
width={resetWidth}
flexDirection="column"
alignItems="flex-start"
paddingLeft={2}
flexShrink={0}
>
<Text color={theme.text.secondary} wrap="truncate-end">
{(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
{formatResetTime(row.bucket.resetTime)}
{row.bucket?.resetTime &&
formatResetTime(row.bucket.resetTime, 'column')
? formatResetTime(row.bucket.resetTime, 'column')
: ''}
</Text>
)}
</Box>
</Box>
</>
)}
</Box>
))}
@@ -89,11 +89,12 @@ const renderStatusDisplay = async (
};
describe('StatusDisplay', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
});
afterEach(() => {
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -112,7 +113,7 @@ describe('StatusDisplay', () => {
});
it('renders system md indicator if env var is set', async () => {
process.env['GEMINI_SYSTEM_MD'] = 'true';
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -6,7 +6,7 @@ exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] =
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%
" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 85%
"
`;
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `
"15%
"85%
"
`;
exports[`QuotaDisplay > should render red when usage < 5% 1`] = `
"/stats 4% usage remaining
exports[`QuotaDisplay > should render critical when used >= 95% 1`] = `
"96% used
"
`;
@@ -15,12 +15,12 @@ exports[`QuotaDisplay > should render terse limit reached message 1`] = `
"
`;
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
"/stats 15% usage remaining, resets in 1h
exports[`QuotaDisplay > should render warning when used >= 80% 1`] = `
"85% used
"
`;
exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `
"/stats 15% usage remaining
exports[`QuotaDisplay > should render with reset time when provided 1`] = `
"85% used (Limit resets in 1h)
"
`;
@@ -1,8 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
"────────────────────────────────────────
Shortcuts See /help for more
"── Shortcuts (for more, see /help) ─────
! shell mode
@ select file or folder
Esc Esc clear & rewind
@@ -17,8 +16,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
"────────────────────────────────────────
Shortcuts See /help for more
"── Shortcuts (for more, see /help) ─────
! shell mode
@ select file or folder
Esc Esc clear & rewind
@@ -33,8 +31,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Shortcuts See /help for more
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
@@ -43,8 +40,7 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Shortcuts See /help for more
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
@@ -117,10 +117,9 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 0 100 │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 100 0 100
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -162,16 +161,15 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
auto Usage
│ 65% usage remaining │
35% used
│ Usage limit: 1,100 │
│ Usage limits span all sessions and reset daily. │
│ For a full token breakdown, run \`/stats model\`. │
│ │
│ Model Reqs Usage remaining
│ ────────────────────────────────────────────────────────────
│ gemini-2.5-pro -
│ gemini-2.5-flash -
│ Model Reqs Model usage Usage resets
│ ────────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90%
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30%
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -193,10 +191,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Usage remaining
────────────────────────────────────────────────────────────
│ gemini-2.5-flash - 50.0% resets in 2h │
│ Model Reqs Model usage Usage resets
────────────────────────────────────────────────────────────────────────────────
gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% 6:00 AM (2h)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -218,10 +215,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Usage remaining
────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
│ Model Reqs Model usage Usage resets
────────────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% 5:30 AM (1h 30m)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -283,11 +279,10 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » API Time: 19.5s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 3 500 500 2,000 │
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 3 500 500 2,000
│ gemini-2.5-flash 5 15,000 10,000 15,000
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -312,10 +307,9 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ Model Usage
Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 50 50 100 │
│ Model Reqs Input Tokens Cache Reads Output Tokens
────────────────────────────────────────────────────────────────────────
gemini-2.5-pro 1 50 50 100
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
@@ -15,6 +15,7 @@ import {
calculateTransformedLine,
} from '../shared/text-buffer.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserMessageProps {
@@ -51,8 +52,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
return (
<HalfLinePaddedBox
backgroundBaseColor={theme.background.message}
backgroundOpacity={1}
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
@@ -8,6 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
import { useConfig } from '../../contexts/ConfigContext.js';
interface UserShellMessageProps {
@@ -27,8 +28,8 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
return (
<HalfLinePaddedBox
backgroundBaseColor={theme.background.message}
backgroundOpacity={1}
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
@@ -30,15 +30,9 @@ describe('<SectionHeader />', () => {
title: 'Narrow Container',
width: 25,
},
{
description: 'renders correctly with a subtitle',
title: 'Shortcuts',
subtitle: ' See /help for more',
width: 40,
},
])('$description', async ({ title, subtitle, width }) => {
])('$description', async ({ title, width }) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<SectionHeader title={title} subtitle={subtitle} />,
<SectionHeader title={title} />,
{ width },
);
await waitUntilReady();
@@ -8,13 +8,16 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
export const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
title,
subtitle,
}) => (
<Box width="100%" flexDirection="column" overflow="hidden">
export const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Box width="100%" flexDirection="row" overflow="hidden">
<Text color={theme.text.secondary} wrap="truncate-end">
{`── ${title}`}
</Text>
<Box
width="100%"
flexGrow={1}
flexShrink={0}
minWidth={2}
marginLeft={1}
borderStyle="single"
borderTop
borderBottom={false}
@@ -22,15 +25,5 @@ export const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
borderRight={false}
borderColor={theme.text.secondary}
/>
<Box flexDirection="row">
<Text color={theme.text.primary} bold wrap="truncate-end">
{title}
</Text>
{subtitle && (
<Text color={theme.text.secondary} wrap="truncate-end">
{subtitle}
</Text>
)}
</Box>
</Box>
);
@@ -1,25 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `
"─────────────────────────
Narrow Container
"── Narrow Container ─────
"
`;
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `
"────────────────────
Very Long Header Ti…
"── Very Long Hea… ──
"
`;
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `
"────────────────────────────────────────
My Header
"
`;
exports[`<SectionHeader /> > 'renders correctly with a subtitle' 1`] = `
"────────────────────────────────────────
Shortcuts See /help for more
"── My Header ───────────────────────────
"
`;
+1 -1
View File
@@ -37,7 +37,7 @@ export const EXPAND_HINT_DURATION_MS = 5000;
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
export const DEFAULT_BORDER_OPACITY = 0.4;
export const DEFAULT_BORDER_OPACITY = 0.2;
export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
-4
View File
@@ -24,8 +24,6 @@ const noColorColorsTheme: ColorsTheme = {
Comment: '',
Gray: '',
DarkGray: '',
InputBackground: '',
MessageBackground: '',
};
const noColorSemanticColors: SemanticColors = {
@@ -38,8 +36,6 @@ const noColorSemanticColors: SemanticColors = {
},
background: {
primary: '',
message: '',
input: '',
diff: {
added: '',
removed: '',
@@ -16,8 +16,6 @@ export interface SemanticColors {
};
background: {
primary: string;
message: string;
input: string;
diff: {
added: string;
removed: string;
@@ -50,15 +48,13 @@ export const lightSemanticColors: SemanticColors = {
},
background: {
primary: lightTheme.Background,
message: lightTheme.MessageBackground!,
input: lightTheme.InputBackground!,
diff: {
added: lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
},
},
border: {
default: lightTheme.DarkGray,
default: lightTheme.Gray,
focused: lightTheme.AccentBlue,
},
ui: {
@@ -84,15 +80,13 @@ export const darkSemanticColors: SemanticColors = {
},
background: {
primary: darkTheme.Background,
message: darkTheme.MessageBackground!,
input: darkTheme.InputBackground!,
diff: {
added: darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
},
},
border: {
default: darkTheme.DarkGray,
default: darkTheme.Gray,
focused: darkTheme.AccentBlue,
},
ui: {
@@ -36,8 +36,6 @@ const semanticColors: SemanticColors = {
},
background: {
primary: '#002b36',
message: '#073642',
input: '#073642',
diff: {
added: '#00382f',
removed: '#3d0115',
@@ -36,8 +36,6 @@ const semanticColors: SemanticColors = {
},
background: {
primary: '#fdf6e3',
message: '#eee8d5',
input: '#eee8d5',
diff: {
added: '#d7f2d7',
removed: '#f2d7d7',
+12 -25
View File
@@ -29,11 +29,7 @@ import {
getThemeTypeFromBackgroundColor,
resolveColor,
} from './color-utils.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
import { ANSI } from './ansi.js';
import { ANSILight } from './ansi-light.js';
import { NoColorTheme } from './no-color.js';
@@ -314,21 +310,7 @@ class ThemeManager {
this.cachedColors = {
...colors,
Background: this.terminalBackground,
DarkGray: interpolateColor(
this.terminalBackground,
colors.Gray,
DEFAULT_BORDER_OPACITY,
),
InputBackground: interpolateColor(
this.terminalBackground,
colors.Gray,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
MessageBackground: interpolateColor(
this.terminalBackground,
colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
),
DarkGray: interpolateColor(colors.Gray, this.terminalBackground, 0.5),
};
} else {
this.cachedColors = colors;
@@ -354,22 +336,27 @@ class ThemeManager {
this.terminalBackground &&
this.isThemeCompatible(activeTheme, this.terminalBackground)
) {
const colors = this.getColors();
this.cachedSemanticColors = {
...semanticColors,
background: {
...semanticColors.background,
primary: this.terminalBackground,
message: colors.MessageBackground!,
input: colors.InputBackground!,
},
border: {
...semanticColors.border,
default: colors.DarkGray,
default: interpolateColor(
this.terminalBackground,
activeTheme.colors.Gray,
DEFAULT_BORDER_OPACITY,
),
},
ui: {
...semanticColors.ui,
dark: colors.DarkGray,
dark: interpolateColor(
activeTheme.colors.Gray,
this.terminalBackground,
0.5,
),
},
};
} else {
+7 -7
View File
@@ -37,11 +37,11 @@ describe('createCustomTheme', () => {
it('should interpolate DarkGray when not provided', () => {
const theme = createCustomTheme(baseTheme);
// Interpolate between Background (#000000) and Gray (#cccccc) at 0.4
// Interpolate between Gray (#cccccc) and Background (#000000) at 0.5
// #cccccc is RGB(204, 204, 204)
// #000000 is RGB(0, 0, 0)
// Result is RGB(82, 82, 82) which is #525252
expect(theme.colors.DarkGray).toBe('#525252');
// Midpoint is RGB(102, 102, 102) which is #666666
expect(theme.colors.DarkGray).toBe('#666666');
});
it('should use provided DarkGray', () => {
@@ -64,8 +64,8 @@ describe('createCustomTheme', () => {
},
};
const theme = createCustomTheme(customTheme);
// Should be interpolated between #000000 and #cccccc at 0.4 -> #525252
expect(theme.colors.DarkGray).toBe('#525252');
// Should be interpolated between #cccccc and #000000 at 0.5 -> #666666
expect(theme.colors.DarkGray).toBe('#666666');
});
it('should prefer text.secondary over Gray for interpolation', () => {
@@ -81,8 +81,8 @@ describe('createCustomTheme', () => {
},
};
const theme = createCustomTheme(customTheme);
// Interpolate between #000000 and #cccccc -> #525252
expect(theme.colors.DarkGray).toBe('#525252');
// Interpolate between #cccccc and #000000 -> #666666
expect(theme.colors.DarkGray).toBe('#666666');
});
});
+17 -61
View File
@@ -15,11 +15,7 @@ import {
} from './color-utils.js';
import type { CustomTheme } from '@google/gemini-cli-core';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
DEFAULT_BORDER_OPACITY,
} from '../constants.js';
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
export type { CustomTheme };
@@ -41,8 +37,6 @@ export interface ColorsTheme {
Comment: string;
Gray: string;
DarkGray: string;
InputBackground?: string;
MessageBackground?: string;
GradientColors?: string[];
}
@@ -61,17 +55,7 @@ export const lightTheme: ColorsTheme = {
DiffRemoved: '#FFCCCC',
Comment: '#008000',
Gray: '#97a0b0',
DarkGray: interpolateColor('#FAFAFA', '#97a0b0', DEFAULT_BORDER_OPACITY),
InputBackground: interpolateColor(
'#FAFAFA',
'#97a0b0',
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
MessageBackground: interpolateColor(
'#FAFAFA',
'#97a0b0',
DEFAULT_BACKGROUND_OPACITY,
),
DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -90,17 +74,7 @@ export const darkTheme: ColorsTheme = {
DiffRemoved: '#430000',
Comment: '#6C7086',
Gray: '#6C7086',
DarkGray: interpolateColor('#1E1E2E', '#6C7086', DEFAULT_BORDER_OPACITY),
InputBackground: interpolateColor(
'#1E1E2E',
'#6C7086',
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
MessageBackground: interpolateColor(
'#1E1E2E',
'#6C7086',
DEFAULT_BACKGROUND_OPACITY,
),
DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5),
GradientColors: ['#4796E4', '#847ACE', '#C3677F'],
};
@@ -120,8 +94,6 @@ export const ansiTheme: ColorsTheme = {
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
InputBackground: 'black',
MessageBackground: 'black',
};
export class Theme {
@@ -159,27 +131,17 @@ export class Theme {
},
background: {
primary: this.colors.Background,
message:
this.colors.MessageBackground ??
interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_BACKGROUND_OPACITY,
),
input:
this.colors.InputBackground ??
interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
diff: {
added: this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
},
},
border: {
default: this.colors.DarkGray,
default: interpolateColor(
this.colors.Background,
this.colors.Gray,
DEFAULT_BORDER_OPACITY,
),
focused: this.colors.AccentBlue,
},
ui: {
@@ -280,20 +242,10 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
DarkGray:
customTheme.DarkGray ??
interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
DEFAULT_BORDER_OPACITY,
customTheme.background?.primary ?? customTheme.Background ?? '',
0.5,
),
InputBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
DEFAULT_INPUT_BACKGROUND_OPACITY,
),
MessageBackground: interpolateColor(
customTheme.background?.primary ?? customTheme.Background ?? '',
customTheme.text?.secondary ?? customTheme.Gray ?? '',
DEFAULT_BACKGROUND_OPACITY,
),
GradientColors: customTheme.ui?.gradient ?? customTheme.GradientColors,
};
@@ -448,15 +400,19 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
},
background: {
primary: customTheme.background?.primary ?? colors.Background,
message: colors.MessageBackground!,
input: colors.InputBackground!,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
},
},
border: {
default: colors.DarkGray,
default:
customTheme.border?.default ??
interpolateColor(
colors.Background,
colors.Gray,
DEFAULT_BORDER_OPACITY,
),
focused: customTheme.border?.focused ?? colors.AccentBlue,
},
ui: {
+19
View File
@@ -19,6 +19,9 @@ export const CACHE_EFFICIENCY_MEDIUM = 15;
export const QUOTA_THRESHOLD_HIGH = 20;
export const QUOTA_THRESHOLD_MEDIUM = 5;
export const QUOTA_USED_WARNING_THRESHOLD = 80;
export const QUOTA_USED_CRITICAL_THRESHOLD = 95;
// --- Color Logic ---
export const getStatusColor = (
value: number,
@@ -36,3 +39,19 @@ export const getStatusColor = (
}
return options.defaultColor ?? theme.status.error;
};
/**
* Gets the status color based on "used" percentage (where higher is worse).
*/
export const getUsedStatusColor = (
usedPercentage: number,
thresholds: { warning: number; critical: number },
) => {
if (usedPercentage >= thresholds.critical) {
return theme.status.error;
}
if (usedPercentage >= thresholds.warning) {
return theme.status.warning;
}
return undefined;
};
@@ -10,9 +10,46 @@ import {
formatBytes,
formatTimeAgo,
stripReferenceContent,
formatResetTime,
} from './formatters.js';
describe('formatters', () => {
describe('formatResetTime', () => {
const NOW = new Date('2025-01-01T12:00:00Z');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('should format full time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
const result = formatResetTime(resetTime);
expect(result).toMatch(/1 hour 30 minutes at \d{1,2}:\d{2} [AP]M/);
});
it('should format terse time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
expect(formatResetTime(resetTime, 'terse')).toBe('1h 30m');
expect(formatResetTime(resetTime, true)).toBe('1h 30m');
});
it('should format column time correctly', () => {
const resetTime = new Date(NOW.getTime() + 90 * 60 * 1000).toISOString(); // 1h 30m
const result = formatResetTime(resetTime, 'column');
expect(result).toMatch(/\d{1,2}:\d{2} [AP]M \(1h 30m\)/);
});
it('should handle zero or negative diff by returning empty string', () => {
const resetTime = new Date(NOW.getTime() - 1000).toISOString();
expect(formatResetTime(resetTime)).toBe('');
});
});
describe('formatBytes', () => {
it('should format bytes into KB', () => {
expect(formatBytes(12345)).toBe('12.1 KB');
+45 -13
View File
@@ -98,26 +98,58 @@ export function stripReferenceContent(text: string): string {
return text.replace(pattern, '').trim();
}
export const formatResetTime = (resetTime: string): string => {
const diff = new Date(resetTime).getTime() - Date.now();
export const formatResetTime = (
resetTime: string | undefined,
format: 'terse' | 'column' | 'full' | boolean = false,
): string => {
if (!resetTime) return '';
const resetDate = new Date(resetTime);
if (isNaN(resetDate.getTime())) return '';
const diff = resetDate.getTime() - Date.now();
if (diff <= 0) return '';
const totalMinutes = Math.ceil(diff / (1000 * 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const fmt = (val: number, unit: 'hour' | 'minute') =>
new Intl.NumberFormat('en', {
style: 'unit',
unit,
unitDisplay: 'narrow',
}).format(val);
const isTerse = format === 'terse' || format === true;
const isColumn = format === 'column';
if (hours > 0 && minutes > 0) {
return `resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')}`;
} else if (hours > 0) {
return `resets in ${fmt(hours, 'hour')}`;
if (isTerse || isColumn) {
const hoursStr = hours > 0 ? `${hours}h` : '';
const minutesStr = minutes > 0 ? `${minutes}m` : '';
const duration =
hoursStr && minutesStr
? `${hoursStr} ${minutesStr}`
: hoursStr || minutesStr;
if (isColumn) {
const timeStr = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
}).format(resetDate);
return duration ? `${timeStr} (${duration})` : timeStr;
}
return duration;
}
return `resets in ${fmt(minutes, 'minute')}`;
let duration = '';
if (hours > 0) {
duration = `${hours} hour${hours > 1 ? 's' : ''}`;
if (minutes > 0) {
duration += ` ${minutes} minute${minutes > 1 ? 's' : ''}`;
}
} else {
duration = `${minutes} minute${minutes > 1 ? 's' : ''}`;
}
const timeStr = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
timeZoneName: 'short',
}).format(resetDate);
return `${duration} at ${timeStr}`;
};
@@ -4,13 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GeneralistAgent } from './generalist-agent.js';
import { makeFakeConfig } from '../test-utils/config.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { AgentRegistry } from './registry.js';
describe('GeneralistAgent', () => {
beforeEach(() => {
vi.stubEnv('GEMINI_SYSTEM_MD', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should create a valid generalist agent definition', () => {
const config = makeFakeConfig();
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
+2 -4
View File
@@ -1597,9 +1597,7 @@ export class Config {
*
* May change over time.
*/
getExcludeTools(
toolMetadata?: Map<string, Record<string, unknown>>,
): Set<string> | undefined {
getExcludeTools(): Set<string> | undefined {
// Right now this is present for backward compatibility with settings.json exclude
const excludeToolsSet = new Set([...(this.excludeTools ?? [])]);
for (const extension of this.getExtensionLoader().getExtensions()) {
@@ -1611,7 +1609,7 @@ export class Config {
}
}
const policyExclusions = this.policyEngine.getExcludedTools(toolMetadata);
const policyExclusions = this.policyEngine.getExcludedTools();
for (const tool of policyExclusions) {
excludeToolsSet.add(tool);
}
@@ -140,29 +140,6 @@ describe('MessageBus', () => {
expect(requestHandler).toHaveBeenCalledWith(request);
});
it('should forward toolAnnotations to policyEngine.check', async () => {
const checkSpy = vi.spyOn(policyEngine, 'check').mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
const annotations = { readOnlyHint: true };
const request: ToolConfirmationRequest = {
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
toolCall: { name: 'test-tool', args: {} },
correlationId: '123',
serverName: 'test-server',
toolAnnotations: annotations,
};
await messageBus.publish(request);
expect(checkSpy).toHaveBeenCalledWith(
{ name: 'test-tool', args: {} },
'test-server',
annotations,
);
});
it('should emit other message types directly', async () => {
const successHandler = vi.fn();
messageBus.subscribe(
@@ -55,7 +55,6 @@ export class MessageBus extends EventEmitter {
const { decision } = await this.policyEngine.check(
message.toolCall,
message.serverName,
message.toolAnnotations,
);
switch (decision) {
@@ -34,10 +34,6 @@ export interface ToolConfirmationRequest {
toolCall: FunctionCall;
correlationId: string;
serverName?: string;
/**
* Optional tool annotations (e.g., readOnlyHint, destructiveHint) from MCP.
*/
toolAnnotations?: Record<string, unknown>;
/**
* Optional rich details for the confirmation UI (diffs, counts, etc.)
*/
@@ -1926,14 +1926,13 @@ describe('CoreToolScheduler Sequential Execution', () => {
isModifiableSpy.mockRestore();
});
it('should pass serverName and toolAnnotations to policy engine for DiscoveredMCPTool', async () => {
it('should pass serverName to policy engine for DiscoveredMCPTool', async () => {
const mockMcpTool = {
tool: async () => ({ functionDeclarations: [] }),
callTool: async () => [],
};
const serverName = 'test-server';
const toolName = 'test-tool';
const annotations = { readOnlyHint: true };
const mcpTool = new DiscoveredMCPTool(
mockMcpTool as unknown as CallableTool,
serverName,
@@ -1941,13 +1940,6 @@ describe('CoreToolScheduler Sequential Execution', () => {
'description',
{ type: 'object', properties: {} },
createMockMessageBus() as unknown as MessageBus,
undefined, // trust
true, // isReadOnly
undefined, // nameOverride
undefined, // cliConfig
undefined, // extensionName
undefined, // extensionId
annotations, // toolAnnotations
);
const mockToolRegistry = {
@@ -1997,7 +1989,6 @@ describe('CoreToolScheduler Sequential Execution', () => {
expect(mockPolicyEngineCheck).toHaveBeenCalledWith(
expect.objectContaining({ name: toolName }),
serverName,
annotations,
);
});
+1 -2
View File
@@ -624,11 +624,10 @@ export class CoreToolScheduler {
toolCall.tool instanceof DiscoveredMCPTool
? toolCall.tool.serverName
: undefined;
const toolAnnotations = toolCall.tool.toolAnnotations;
const { decision, rule } = await this.config
.getPolicyEngine()
.check(toolCallForPolicy, serverName, toolAnnotations);
.check(toolCallForPolicy, serverName);
if (decision === PolicyDecision.DENY) {
const { errorMessage, errorType } = getPolicyDenialError(
@@ -34,49 +34,6 @@ describe('getIdeProcessInfo', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
describe('GEMINI_CLI_IDE_PID override', () => {
it('should use GEMINI_CLI_IDE_PID and fetch command on Unix', async () => {
(os.platform as Mock).mockReturnValue('linux');
vi.stubEnv('GEMINI_CLI_IDE_PID', '12345');
mockedExec.mockResolvedValueOnce({ stdout: '0 my-ide-command' }); // getProcessInfo result
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 12345, command: 'my-ide-command' });
expect(mockedExec).toHaveBeenCalledWith(
expect.stringContaining('ps -o ppid=,command= -p 12345'),
);
});
it('should use GEMINI_CLI_IDE_PID and fetch command on Windows', async () => {
(os.platform as Mock).mockReturnValue('win32');
vi.stubEnv('GEMINI_CLI_IDE_PID', '54321');
const processes = [
{
ProcessId: 54321,
ParentProcessId: 0,
Name: 'Code.exe',
CommandLine: 'C:\\Program Files\\VSCode\\Code.exe',
},
];
mockedExec.mockResolvedValueOnce({ stdout: JSON.stringify(processes) });
const result = await getIdeProcessInfo();
expect(result).toEqual({
pid: 54321,
command: 'C:\\Program Files\\VSCode\\Code.exe',
});
expect(mockedExec).toHaveBeenCalledWith(
expect.stringContaining(
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine',
),
expect.anything(),
);
});
});
describe('on Unix', () => {
-20
View File
@@ -208,13 +208,6 @@ async function getIdeProcessInfoForWindows(): Promise<{
* to identify the main application process (e.g., the main VS Code window
* process).
*
* This function can be overridden by setting the `GEMINI_CLI_IDE_PID`
* environment variable. This is useful for launching Gemini CLI in a
* standalone terminal while still connecting to an IDE instance.
*
* If `GEMINI_CLI_IDE_PID` is set, the function uses that PID and fetches
* the command for it.
*
* If the IDE process cannot be reliably identified, it will return the
* top-level ancestor process ID and command as a fallback.
*
@@ -226,19 +219,6 @@ export async function getIdeProcessInfo(): Promise<{
}> {
const platform = os.platform();
if (process.env['GEMINI_CLI_IDE_PID']) {
const idePid = parseInt(process.env['GEMINI_CLI_IDE_PID'], 10);
if (!isNaN(idePid) && idePid > 0) {
if (platform === 'win32') {
const processMap = await getProcessTableWindows();
const proc = processMap.get(idePid);
return { pid: idePid, command: proc?.command || '' };
}
const { command } = await getProcessInfo(idePid);
return { pid: idePid, command };
}
}
if (platform === 'win32') {
return getIdeProcessInfoForWindows();
}
@@ -36,13 +36,6 @@ deny_message = "You are in Plan Mode with access to read-only tools. Execution o
# Explicitly Allow Read-Only Tools in Plan mode.
[[rule]]
mcpName = "*"
toolAnnotations = { readOnlyHint = true }
decision = "ask_user"
priority = 70
modes = ["plan"]
[[rule]]
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"]
decision = "allow"
@@ -2375,75 +2375,6 @@ describe('PolicyEngine', () => {
expect(Array.from(excluded).sort()).toEqual(expected.sort());
},
);
it('should skip annotation-based rules when no metadata is provided', () => {
engine = new PolicyEngine({
rules: [
{
toolAnnotations: { destructiveHint: true },
decision: PolicyDecision.DENY,
priority: 10,
},
],
});
const excluded = engine.getExcludedTools();
expect(Array.from(excluded)).toEqual([]);
});
it('should exclude tools matching annotation-based DENY rule when metadata is provided', () => {
engine = new PolicyEngine({
rules: [
{
toolAnnotations: { destructiveHint: true },
decision: PolicyDecision.DENY,
priority: 10,
},
],
});
const metadata = new Map<string, Record<string, unknown>>([
['dangerous_tool', { destructiveHint: true }],
['safe_tool', { readOnlyHint: true }],
]);
const excluded = engine.getExcludedTools(metadata);
expect(Array.from(excluded)).toEqual(['dangerous_tool']);
});
it('should NOT exclude tools whose annotations do not match', () => {
engine = new PolicyEngine({
rules: [
{
toolAnnotations: { destructiveHint: true },
decision: PolicyDecision.DENY,
priority: 10,
},
],
});
const metadata = new Map<string, Record<string, unknown>>([
['safe_tool', { readOnlyHint: true }],
]);
const excluded = engine.getExcludedTools(metadata);
expect(Array.from(excluded)).toEqual([]);
});
it('should exclude tools matching both toolName pattern AND annotations', () => {
engine = new PolicyEngine({
rules: [
{
toolName: 'server__*',
toolAnnotations: { destructiveHint: true },
decision: PolicyDecision.DENY,
priority: 10,
},
],
});
const metadata = new Map<string, Record<string, unknown>>([
['server__dangerous_tool', { destructiveHint: true }],
['other__dangerous_tool', { destructiveHint: true }],
['server__safe_tool', { readOnlyHint: true }],
]);
const excluded = engine.getExcludedTools(metadata);
expect(Array.from(excluded)).toEqual(['server__dangerous_tool']);
});
});
describe('YOLO mode with ask_user tool', () => {
+1 -55
View File
@@ -627,15 +627,8 @@ export class PolicyEngine {
* 1. Global rules (no argsPattern)
* 2. Priority order (higher priority wins)
* 3. Non-interactive mode (ASK_USER becomes DENY)
* 4. Annotation-based rules (when toolMetadata is provided)
*
* @param toolMetadata Optional map of tool names to their annotations.
* When provided, annotation-based rules can match tools by their metadata.
* When not provided, rules with toolAnnotations are skipped (conservative fallback).
*/
getExcludedTools(
toolMetadata?: Map<string, Record<string, unknown>>,
): Set<string> {
getExcludedTools(): Set<string> {
const excludedTools = new Set<string>();
const processedTools = new Set<string>();
let globalVerdict: PolicyDecision | undefined;
@@ -655,53 +648,6 @@ export class PolicyEngine {
}
}
// Handle annotation-based rules
if (rule.toolAnnotations) {
if (!toolMetadata) {
// Without metadata, we can't evaluate annotation rules — skip (conservative fallback)
continue;
}
// Iterate over all known tools and check if their annotations match this rule
for (const [toolName, annotations] of toolMetadata) {
if (processedTools.has(toolName)) {
continue;
}
// Check if annotations match the rule's toolAnnotations (partial match)
let annotationsMatch = true;
for (const [key, value] of Object.entries(rule.toolAnnotations)) {
if (annotations[key] !== value) {
annotationsMatch = false;
break;
}
}
if (!annotationsMatch) {
continue;
}
// Check if the tool name matches the rule's toolName pattern (if any)
if (rule.toolName) {
if (isWildcardPattern(rule.toolName)) {
if (!matchesWildcard(rule.toolName, toolName, undefined)) {
continue;
}
} else if (toolName !== rule.toolName) {
continue;
}
}
// Determine decision considering global verdict
let decision: PolicyDecision;
if (globalVerdict !== undefined) {
decision = globalVerdict;
} else {
decision = rule.decision;
}
if (decision === PolicyDecision.DENY) {
excludedTools.add(toolName);
}
processedTools.add(toolName);
}
continue;
}
// Handle Global Rules
if (!rule.toolName) {
if (globalVerdict === undefined) {
@@ -609,118 +609,6 @@ priority = 100
});
describe('Built-in Plan Mode Policy', () => {
it('should allow MCP tools with readOnlyHint annotation in Plan Mode (ASK_USER, not DENY)', async () => {
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
const tempPolicyDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'plan-annotation-test-'),
);
try {
await fs.writeFile(path.join(tempPolicyDir, 'plan.toml'), fileContent);
const getPolicyTier = () => 1; // Default tier
// 1. Load the actual Plan Mode policies
const result = await loadPoliciesFromToml(
[tempPolicyDir],
getPolicyTier,
);
expect(result.errors).toHaveLength(0);
// Verify annotation rule was loaded correctly
const annotationRule = result.rules.find(
(r) => r.toolAnnotations !== undefined,
);
expect(
annotationRule,
'Should have loaded a rule with toolAnnotations',
).toBeDefined();
expect(annotationRule!.toolName).toBe('*__*');
expect(annotationRule!.toolAnnotations).toEqual({
readOnlyHint: true,
});
expect(annotationRule!.decision).toBe(PolicyDecision.ASK_USER);
// Priority 70 in tier 1 => 1.070
expect(annotationRule!.priority).toBe(1.07);
// Verify deny rule was loaded correctly
const denyRule = result.rules.find(
(r) =>
r.decision === PolicyDecision.DENY &&
r.toolName === undefined &&
r.denyMessage?.includes('Plan Mode'),
);
expect(
denyRule,
'Should have loaded the catch-all deny rule',
).toBeDefined();
// Priority 60 in tier 1 => 1.060
expect(denyRule!.priority).toBe(1.06);
// 2. Initialize Policy Engine in Plan Mode
const engine = new PolicyEngine({
rules: result.rules,
approvalMode: ApprovalMode.PLAN,
});
// 3. MCP tool with readOnlyHint=true and serverName should get ASK_USER
const askResult = await engine.check(
{ name: 'github__list_issues' },
'github',
{ readOnlyHint: true },
);
expect(
askResult.decision,
'MCP tool with readOnlyHint=true should be ASK_USER, not DENY',
).toBe(PolicyDecision.ASK_USER);
// 4. MCP tool WITHOUT annotations should be DENIED
const denyResult = await engine.check(
{ name: 'github__create_issue' },
'github',
undefined,
);
expect(
denyResult.decision,
'MCP tool without annotations should be DENIED in Plan Mode',
).toBe(PolicyDecision.DENY);
// 5. MCP tool with readOnlyHint=false should also be DENIED
const denyResult2 = await engine.check(
{ name: 'github__delete_issue' },
'github',
{ readOnlyHint: false },
);
expect(
denyResult2.decision,
'MCP tool with readOnlyHint=false should be DENIED in Plan Mode',
).toBe(PolicyDecision.DENY);
// 6. Test with qualified tool name format (server__tool) but no separate serverName
const qualifiedResult = await engine.check(
{ name: 'github__list_repos' },
undefined,
{ readOnlyHint: true },
);
expect(
qualifiedResult.decision,
'Qualified MCP tool name with readOnlyHint=true should be ASK_USER even without separate serverName',
).toBe(PolicyDecision.ASK_USER);
// 7. Non-MCP tool (no server context) should be DENIED despite having annotations
const builtinResult = await engine.check(
{ name: 'some_random_tool' },
undefined,
{ readOnlyHint: true },
);
expect(
builtinResult.decision,
'Non-MCP tool should be DENIED even with readOnlyHint (no server context for *__* match)',
).toBe(PolicyDecision.DENY);
} finally {
await fs.rm(tempPolicyDir, { recursive: true, force: true });
}
});
it('should override default subagent rules when in Plan Mode', async () => {
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PromptProvider } from './promptProvider.js';
import type { Config } from '../config/config.js';
import {
@@ -30,6 +30,7 @@ describe('PromptProvider', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('GEMINI_SYSTEM_MD', '');
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
getAllToolNames: vi.fn().mockReturnValue([]),
@@ -54,6 +55,10 @@ describe('PromptProvider', () => {
} as unknown as Config;
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should handle multiple context filenames in the system prompt', () => {
vi.mocked(getAllGeminiMdFilenames).mockReturnValue([
DEFAULT_CONTEXT_FILENAME,
+1 -4
View File
@@ -59,11 +59,10 @@ describe('policy.ts', () => {
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
{ name: 'test-tool', args: {} },
undefined,
undefined,
);
});
it('should pass serverName and toolAnnotations for MCP tools', async () => {
it('should pass serverName for MCP tools', async () => {
const mockPolicyEngine = {
check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ALLOW }),
} as unknown as Mocked<PolicyEngine>;
@@ -74,7 +73,6 @@ describe('policy.ts', () => {
const mcpTool = Object.create(DiscoveredMCPTool.prototype);
mcpTool.serverName = 'my-server';
mcpTool._toolAnnotations = { readOnlyHint: true };
const toolCall = {
request: { name: 'mcp-tool', args: {} },
@@ -85,7 +83,6 @@ describe('policy.ts', () => {
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
{ name: 'mcp-tool', args: {} },
'my-server',
{ readOnlyHint: true },
);
});
-3
View File
@@ -54,14 +54,11 @@ export async function checkPolicy(
? toolCall.tool.serverName
: undefined;
const toolAnnotations = toolCall.tool.toolAnnotations;
const result = await config
.getPolicyEngine()
.check(
{ name: toolCall.request.name, args: toolCall.request.args },
serverName,
toolAnnotations,
);
const { decision } = result;
+11 -99
View File
@@ -23,7 +23,7 @@ import {
ResourceListChangedNotificationSchema,
ToolListChangedNotificationSchema,
} from '@modelcontextprotocol/sdk/types.js';
import type { DiscoveredMCPTool } from './mcp-tool.js';
import { ApprovalMode, PolicyDecision } from '../policy/types.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import {
@@ -392,7 +392,7 @@ describe('mcp-client', () => {
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
});
it('should register tool with readOnlyHint and preserve annotations', async () => {
it('should register tool with readOnlyHint and add policy rule', async () => {
const mockedClient = {
connect: vi.fn(),
discover: vi.fn(),
@@ -462,18 +462,17 @@ describe('mcp-client', () => {
// Verify tool registration
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
// Verify addRule is NOT called (annotation-based rules are in plan.toml now)
expect(mockPolicyEngine.addRule).not.toHaveBeenCalled();
// Verify annotations are preserved on the registered tool
const registeredTool = (
mockedToolRegistry.registerTool as ReturnType<typeof vi.fn>
).mock.calls[0][0] as DiscoveredMCPTool;
expect(registeredTool.toolAnnotations).toEqual({ readOnlyHint: true });
expect(registeredTool.isReadOnly).toBe(true);
// Verify policy rule addition
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith({
toolName: 'test-server__readOnlyTool',
decision: PolicyDecision.ASK_USER,
priority: 50,
modes: [ApprovalMode.PLAN],
source: 'MCP Annotation (readOnlyHint) - test-server',
});
});
it('should preserve undefined annotations for tool without readOnlyHint', async () => {
it('should not add policy rule for tool without readOnlyHint', async () => {
const mockedClient = {
connect: vi.fn(),
discover: vi.fn(),
@@ -542,93 +541,6 @@ describe('mcp-client', () => {
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
expect(mockPolicyEngine.addRule).not.toHaveBeenCalled();
// Verify annotations are undefined for tools without annotations
const registeredTool = (
mockedToolRegistry.registerTool as ReturnType<typeof vi.fn>
).mock.calls[0][0] as DiscoveredMCPTool;
expect(registeredTool.toolAnnotations).toBeUndefined();
});
it('should preserve full annotations object with multiple hints', async () => {
const mockedClient = {
connect: vi.fn(),
discover: vi.fn(),
disconnect: vi.fn(),
getStatus: vi.fn(),
registerCapabilities: vi.fn(),
setRequestHandler: vi.fn(),
setNotificationHandler: vi.fn(),
getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }),
listTools: vi.fn().mockResolvedValue({
tools: [
{
name: 'multiAnnotationTool',
description: 'A tool with multiple annotations',
inputSchema: { type: 'object', properties: {} },
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
},
],
}),
listPrompts: vi.fn().mockResolvedValue({ prompts: [] }),
request: vi.fn().mockResolvedValue({}),
};
vi.mocked(ClientLib.Client).mockReturnValue(
mockedClient as unknown as ClientLib.Client,
);
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
{} as SdkClientStdioLib.StdioClientTransport,
);
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue({ addRule: vi.fn() }),
} as unknown as Config;
const mockedToolRegistry = {
registerTool: vi.fn(),
sortTools: vi.fn(),
getMessageBus: vi.fn().mockReturnValue(undefined),
removeMcpToolsByServer: vi.fn(),
} as unknown as ToolRegistry;
const promptRegistry = {
registerPrompt: vi.fn(),
removePromptsByServer: vi.fn(),
} as unknown as PromptRegistry;
const resourceRegistry = {
setResourcesForServer: vi.fn(),
removeResourcesByServer: vi.fn(),
} as unknown as ResourceRegistry;
const client = new McpClient(
'test-server',
{ command: 'test-command' },
mockedToolRegistry,
promptRegistry,
resourceRegistry,
workspaceContext,
{ sanitizationConfig: EMPTY_CONFIG } as Config,
false,
'0.0.1',
);
await client.connect();
await client.discover(mockConfig);
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
const registeredTool = (
mockedToolRegistry.registerTool as ReturnType<typeof vi.fn>
).mock.calls[0][0] as DiscoveredMCPTool;
expect(registeredTool.toolAnnotations).toEqual({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
});
expect(registeredTool.isReadOnly).toBe(true);
});
it('should discover tools with $defs and $ref in schema', async () => {
+14 -4
View File
@@ -33,6 +33,7 @@ import {
PromptListChangedNotificationSchema,
ProgressNotificationSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { ApprovalMode, PolicyDecision } from '../policy/types.js';
import { parse } from 'shell-quote';
import type { Config, MCPServerConfig } from '../config/config.js';
import { AuthProviderType } from '../config/config.js';
@@ -1077,9 +1078,8 @@ export async function discoverTools(
options?.progressReporter,
);
// Extract annotations from the tool definition
const annotations = toolDef.annotations;
const isReadOnly = annotations?.readOnlyHint === true;
// Extract readOnlyHint from annotations
const isReadOnly = toolDef.annotations?.readOnlyHint === true;
const tool = new DiscoveredMCPTool(
mcpCallableTool,
@@ -1094,9 +1094,19 @@ export async function discoverTools(
cliConfig,
mcpServerConfig.extension?.name,
mcpServerConfig.extension?.id,
annotations as Record<string, unknown> | undefined,
);
// If the tool is read-only, allow it in Plan mode
if (isReadOnly) {
cliConfig.getPolicyEngine().addRule({
toolName: tool.getFullyQualifiedName(),
decision: PolicyDecision.ASK_USER,
priority: 50, // Match priority of built-in plan tools
modes: [ApprovalMode.PLAN],
source: `MCP Annotation (readOnlyHint) - ${mcpServerName}`,
});
}
discoveredTools.push(tool);
} catch (error) {
coreEvents.emitFeedback(
-9
View File
@@ -82,7 +82,6 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
private readonly cliConfig?: Config,
private readonly toolDescription?: string,
private readonly toolParameterSchema?: unknown,
toolAnnotationsData?: Record<string, unknown>,
) {
// Use composite format for policy checks: serverName__toolName
// This enables server wildcards (e.g., "google-workspace__*")
@@ -94,7 +93,6 @@ export class DiscoveredMCPToolInvocation extends BaseToolInvocation<
`${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${serverToolName}`,
displayName,
serverName,
toolAnnotationsData,
);
}
@@ -259,7 +257,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
private readonly cliConfig?: Config,
override readonly extensionName?: string,
override readonly extensionId?: string,
private readonly _toolAnnotations?: Record<string, unknown>,
) {
super(
nameOverride ?? generateValidName(serverToolName),
@@ -285,10 +282,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
return super.isReadOnly;
}
override get toolAnnotations(): Record<string, unknown> | undefined {
return this._toolAnnotations;
}
getFullyQualifiedPrefix(): string {
return `${this.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}`;
}
@@ -311,7 +304,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
this.cliConfig,
this.extensionName,
this.extensionId,
this._toolAnnotations,
);
}
@@ -332,7 +324,6 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool<
this.cliConfig,
this.description,
this.parameterSchema,
this._toolAnnotations,
);
}
}
+4 -17
View File
@@ -441,25 +441,13 @@ export class ToolRegistry {
}
}
private buildToolMetadata(): Map<string, Record<string, unknown>> {
const toolMetadata = new Map<string, Record<string, unknown>>();
for (const [name, tool] of this.allKnownTools) {
if (tool.toolAnnotations) {
toolMetadata.set(name, tool.toolAnnotations);
}
}
return toolMetadata;
}
/**
* @returns All the tools that are not excluded.
*/
private getActiveTools(): AnyDeclarativeTool[] {
const toolMetadata = this.buildToolMetadata();
const excludedTools =
this.expandExcludeToolsWithAliases(
this.config.getExcludeTools(toolMetadata),
) ?? new Set([]);
this.expandExcludeToolsWithAliases(this.config.getExcludeTools()) ??
new Set([]);
const activeTools: AnyDeclarativeTool[] = [];
for (const tool of this.allKnownTools.values()) {
if (this.isActiveTool(tool, excludedTools)) {
@@ -499,9 +487,8 @@ export class ToolRegistry {
excludeTools?: Set<string>,
): boolean {
excludeTools ??=
this.expandExcludeToolsWithAliases(
this.config.getExcludeTools(this.buildToolMetadata()),
) ?? new Set([]);
this.expandExcludeToolsWithAliases(this.config.getExcludeTools()) ??
new Set([]);
// Filter tools in Plan Mode to only allow approved read-only tools.
const isPlanMode =
-6
View File
@@ -91,7 +91,6 @@ export abstract class BaseToolInvocation<
readonly _toolName?: string,
readonly _toolDisplayName?: string,
readonly _serverName?: string,
readonly _toolAnnotations?: Record<string, unknown>,
) {}
abstract getDescription(): string;
@@ -200,7 +199,6 @@ export abstract class BaseToolInvocation<
args: this.params as Record<string, unknown>,
},
serverName: this._serverName,
toolAnnotations: this._toolAnnotations,
};
return new Promise<'ALLOW' | 'DENY' | 'ASK_USER'>((resolve) => {
@@ -374,10 +372,6 @@ export abstract class DeclarativeTool<
return READ_ONLY_KINDS.includes(this.kind);
}
get toolAnnotations(): Record<string, unknown> | undefined {
return undefined;
}
getSchema(_modelId?: string): FunctionDeclaration {
return {
name: this.name,
+1 -1
View File
@@ -32,7 +32,7 @@ execSync('node ./scripts/check-build-status.js', {
cwd: root,
});
const nodeArgs = ['--no-warnings=DEP0040'];
const nodeArgs = [];
let sandboxCommand = undefined;
try {
sandboxCommand = execSync('node scripts/sandbox_command.js', {