mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-31 20:21:01 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1817ce8bad | |||
| 2717cacb8c |
+6
-22
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
#!/usr/bin/env -S node --no-warnings=DEP0040
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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} />,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ───────────────────────────
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -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/';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user