mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-13 03:20:33 -07:00
test(cli): fix flaky QuotaDisplay snapshot and env leakage in StatusDisplay
This commit is contained in:
@@ -878,6 +878,7 @@ export async function loadCliConfig(
|
||||
agents: refreshedSettings.merged.agents,
|
||||
};
|
||||
},
|
||||
enableConseca: settings.security?.enableConseca,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -352,6 +352,38 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should correctly match tool annotations', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
|
||||
// Add a manual rule with annotations to the config
|
||||
config.rules = config.rules || [];
|
||||
config.rules.push({
|
||||
toolAnnotations: { readOnlyHint: true },
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// A tool with readOnlyHint=true should be ALLOWED
|
||||
const roCall = { name: 'some_tool', args: {} };
|
||||
const roMeta = { readOnlyHint: true };
|
||||
expect((await engine.check(roCall, undefined, roMeta)).decision).toBe(
|
||||
PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
// A tool without the hint (or with false) should follow default decision (ASK_USER)
|
||||
const rwMeta = { readOnlyHint: false };
|
||||
expect((await engine.check(roCall, undefined, rwMeta)).decision).toBe(
|
||||
PolicyDecision.ASK_USER,
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(['write_file', 'replace'])(
|
||||
'Plan Mode policy for %s',
|
||||
(toolName) => {
|
||||
|
||||
@@ -142,4 +142,48 @@ describe('resolveWorkspacePolicyState', () => {
|
||||
expect.stringContaining('Automatically accepting and loading'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not return workspace policies if cwd is the home directory', async () => {
|
||||
const policiesDir = path.join(tempDir, '.gemini', 'policies');
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
// Run from HOME directory (tempDir is mocked as HOME in beforeEach)
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: tempDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
|
||||
const policiesDir = path.join(tempDir, '.gemini', 'policies');
|
||||
fs.mkdirSync(policiesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
|
||||
|
||||
// Create a symlink to the home directory
|
||||
const symlinkDir = path.join(
|
||||
os.tmpdir(),
|
||||
`gemini-cli-symlink-${Date.now()}`,
|
||||
);
|
||||
fs.symlinkSync(tempDir, symlinkDir, 'dir');
|
||||
|
||||
try {
|
||||
// Run from symlink to HOME directory
|
||||
const result = await resolveWorkspacePolicyState({
|
||||
cwd: symlinkDir,
|
||||
trustedFolder: true,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
expect(result.workspacePoliciesDir).toBeUndefined();
|
||||
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
|
||||
} finally {
|
||||
// Clean up symlink
|
||||
fs.unlinkSync(symlinkDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,9 +67,15 @@ export async function resolveWorkspacePolicyState(options: {
|
||||
| undefined;
|
||||
|
||||
if (trustedFolder) {
|
||||
const potentialWorkspacePoliciesDir = new Storage(
|
||||
cwd,
|
||||
).getWorkspacePoliciesDir();
|
||||
const storage = new Storage(cwd);
|
||||
|
||||
// If we are in the home directory (or rather, our target Gemini dir is the global one),
|
||||
// don't treat it as a workspace to avoid loading global policies twice.
|
||||
if (storage.isWorkspaceHomeDir()) {
|
||||
return { workspacePoliciesDir: undefined };
|
||||
}
|
||||
|
||||
const potentialWorkspacePoliciesDir = storage.getWorkspacePoliciesDir();
|
||||
const integrityManager = new PolicyIntegrityManager();
|
||||
const integrityResult = await integrityManager.checkIntegrity(
|
||||
'workspace',
|
||||
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -126,6 +127,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const os = await import('node:os');
|
||||
const pathMod = await import('node:path');
|
||||
const fsMod = await import('node:fs');
|
||||
|
||||
// Helper to resolve paths using the test's mocked environment
|
||||
const testResolve = (p: string | undefined) => {
|
||||
if (!p) return '';
|
||||
try {
|
||||
// Use the mocked fs.realpathSync if available, otherwise fallback
|
||||
return fsMod.realpathSync(pathMod.resolve(p));
|
||||
} catch {
|
||||
return pathMod.resolve(p);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a smarter mock for isWorkspaceHomeDir
|
||||
vi.spyOn(actual.Storage.prototype, 'isWorkspaceHomeDir').mockImplementation(
|
||||
function (this: Storage) {
|
||||
const target = testResolve(pathMod.dirname(this.getGeminiDir()));
|
||||
// Pick up the mocked home directory specifically from the 'os' mock
|
||||
const home = testResolve(os.homedir());
|
||||
return actual.normalizePath(target) === actual.normalizePath(home);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: mockCoreEvents,
|
||||
@@ -1491,20 +1516,29 @@ describe('Settings Loading and Merging', () => {
|
||||
return pStr;
|
||||
});
|
||||
|
||||
// Force the storage check to return true for this specific test
|
||||
const isWorkspaceHomeDirSpy = vi
|
||||
.spyOn(Storage.prototype, 'isWorkspaceHomeDir')
|
||||
.mockReturnValue(true);
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: string) =>
|
||||
// Only return true for workspace settings path to see if it gets loaded
|
||||
p === mockWorkspaceSettingsPath,
|
||||
);
|
||||
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
try {
|
||||
const settings = loadSettings(mockSymlinkDir);
|
||||
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
// Verify that even though the file exists, it was NOT loaded because realpath matched home
|
||||
expect(fs.readFileSync).not.toHaveBeenCalledWith(
|
||||
mockWorkspaceSettingsPath,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
} finally {
|
||||
isWorkspaceHomeDirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -637,24 +637,8 @@ export function loadSettings(
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
const systemDefaultsPath = getSystemDefaultsPath();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
try {
|
||||
// fs.realpathSync gets the "true" path, resolving any symlinks
|
||||
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
|
||||
} catch (_e) {
|
||||
// This is okay. The path might not exist yet, and that's a valid state.
|
||||
}
|
||||
|
||||
// We expect homedir to always exist and be resolvable.
|
||||
const realHomeDir = fs.realpathSync(resolvedHomeDir);
|
||||
|
||||
const workspaceSettingsPath = new Storage(
|
||||
workspaceDir,
|
||||
).getWorkspaceSettingsPath();
|
||||
const storage = new Storage(workspaceDir);
|
||||
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
|
||||
|
||||
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
||||
try {
|
||||
@@ -712,7 +696,7 @@ export function loadSettings(
|
||||
settings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
if (realWorkspaceDir !== realHomeDir) {
|
||||
if (!storage.isWorkspaceHomeDir()) {
|
||||
workspaceResult = load(workspaceSettingsPath);
|
||||
}
|
||||
|
||||
@@ -800,11 +784,11 @@ export function loadSettings(
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
path: realWorkspaceDir === realHomeDir ? '' : workspaceSettingsPath,
|
||||
path: storage.isWorkspaceHomeDir() ? '' : workspaceSettingsPath,
|
||||
settings: workspaceSettings,
|
||||
originalSettings: workspaceOriginalSettings,
|
||||
rawJson: workspaceResult.rawJson,
|
||||
readOnly: realWorkspaceDir === realHomeDir,
|
||||
readOnly: storage.isWorkspaceHomeDir(),
|
||||
},
|
||||
isTrusted,
|
||||
settingsErrors,
|
||||
|
||||
@@ -297,6 +297,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Retry on "exception TypeError: fetch failed sending request" errors.',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxAttempts: {
|
||||
type: 'number',
|
||||
label: 'Max Chat Model Attempts',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 10,
|
||||
description:
|
||||
'Maximum number of attempts for requests to the main chat model. Cannot exceed 10.',
|
||||
showInDialog: true,
|
||||
},
|
||||
debugKeystrokeLogging: {
|
||||
type: 'boolean',
|
||||
label: 'Debug Keystroke Logging',
|
||||
@@ -1483,6 +1493,16 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
enableConseca: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Context-Aware Security',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable the context-aware security checker. This feature uses an LLM to dynamically generate and enforce security policies for tool use based on your prompt, providing an additional layer of protection against unintended actions.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('<Footer />', () => {
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('15%');
|
||||
expect(lastFrame()).toContain('85%');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -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,10 @@ 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)})` : ''
|
||||
}`}
|
||||
</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)})`
|
||||
: ''
|
||||
}`}
|
||||
</Text>
|
||||
{showDetails && (
|
||||
<>
|
||||
|
||||
@@ -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% used');
|
||||
expect(output).toContain('Limit resets in');
|
||||
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% used');
|
||||
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% used');
|
||||
expect(output).toContain('Limit resets in');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -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 {
|
||||
@@ -168,7 +171,26 @@ const ModelUsageTable: React.FC<{
|
||||
const uncachedWidth = 15;
|
||||
const cachedWidth = 14;
|
||||
const outputTokensWidth = 15;
|
||||
const usageLimitWidth = showQuotaColumn ? 28 : 0;
|
||||
const usageLimitWidth = showQuotaColumn ? 85 : 0;
|
||||
|
||||
const renderProgressBar = (usedFraction: number, color: string) => {
|
||||
const 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 = totalSteps - filledSteps;
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color={color}>{'▬'.repeat(filledSteps)}</Text>
|
||||
<Text color={theme.border.default}>{'▬'.repeat(emptySteps)}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
|
||||
green: CACHE_EFFICIENCY_HIGH,
|
||||
@@ -183,19 +205,21 @@ const ModelUsageTable: React.FC<{
|
||||
: uncachedWidth + cachedWidth + outputTokensWidth);
|
||||
|
||||
const isAuto = currentModel && isAutoModel(currentModel);
|
||||
const modelUsageTitle = isAuto
|
||||
? `${getDisplayString(currentModel)} Usage`
|
||||
: `Model Usage`;
|
||||
const modelUsageTitle = isAuto ? (
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
<Text bold>Model Usage:</Text> {getDisplayString(currentModel)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text bold color={theme.text.primary} wrap="truncate-end">
|
||||
Model Usage
|
||||
</Text>
|
||||
);
|
||||
|
||||
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 width={totalWidth}>{modelUsageTitle}</Box>
|
||||
</Box>
|
||||
|
||||
{isAuto &&
|
||||
@@ -270,10 +294,11 @@ const ModelUsageTable: React.FC<{
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={4}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Usage remaining
|
||||
Model usage
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -355,16 +380,62 @@ const ModelUsageTable: React.FC<{
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
alignItems="flex-start"
|
||||
paddingLeft={4}
|
||||
>
|
||||
{row.bucket &&
|
||||
row.bucket.remainingFraction != null &&
|
||||
row.bucket.resetTime && (
|
||||
<Text color={theme.text.secondary} wrap="truncate-end">
|
||||
{(row.bucket.remainingFraction * 100).toFixed(1)}%{' '}
|
||||
{formatResetTime(row.bucket.resetTime)}
|
||||
</Text>
|
||||
)}
|
||||
{row.bucket && row.bucket.remainingFraction != null && (
|
||||
<Box flexDirection="row">
|
||||
{(() => {
|
||||
const actualUsedFraction = 1 - row.bucket.remainingFraction;
|
||||
// If we have session activity but 0% server usage, show 0.1% as a hint.
|
||||
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)}% used`
|
||||
: `${usedPercentage.toFixed(0)}% used`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderProgressBar(effectiveUsedFraction, statusColor)}
|
||||
<Box marginLeft={1}>
|
||||
<Text wrap="truncate-end">
|
||||
{row.bucket.remainingFraction === 0 ? (
|
||||
<Text color={theme.status.error}>
|
||||
Limit reached
|
||||
{row.bucket.resetTime &&
|
||||
`, resets in ${formatResetTime(row.bucket.resetTime, true)}`}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text color={statusColor}>{percentageText}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{row.bucket.resetTime &&
|
||||
formatResetTime(row.bucket.resetTime)
|
||||
? ` (Limit resets in ${formatResetTime(row.bucket.resetTime)})`
|
||||
: ''}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</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 1 hour at 1:29 PM PST)
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Plan Directory undefined │
|
||||
│ The directory where planning artifacts are stored. If not specified, defaults t… │
|
||||
│ │
|
||||
│ Max Chat Model Attempts 10 │
|
||||
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Keep chat history undefined │
|
||||
│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
|
||||
@@ -162,16 +162,16 @@ exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ auto Usage │
|
||||
│ 65% usage remaining │
|
||||
│ Model Usage: auto │
|
||||
│ 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 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ gemini-2.5-pro - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 90% used │
|
||||
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 30% used │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -194,9 +194,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ Model Usage │
|
||||
│ Model Reqs Usage remaining │
|
||||
│ ──────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-flash - 50.0% resets in 2h │
|
||||
│ Model Reqs Model usage │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ gemini-2.5-flash - ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 50% used (Limit resets in 2 hours at 6:00 AM│
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
@@ -219,9 +219,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
|
||||
│ » 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 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────────│
|
||||
│ gemini-2.5-pro 1 ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 25% used (Limit resets in 1 hour 30 minutes │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
|
||||
@@ -58,7 +58,10 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
borderColor,
|
||||
|
||||
borderDimColor,
|
||||
|
||||
isExpandable,
|
||||
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const {
|
||||
activePtyId: activeShellPtyId,
|
||||
@@ -129,6 +132,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
status={status}
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
|
||||
<FocusHint
|
||||
|
||||
@@ -520,4 +520,77 @@ describe('ToolConfirmationMessage', () => {
|
||||
expect(output).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show MCP tool details expand hint for MCP confirmations', async () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool',
|
||||
serverName: 'test-server',
|
||||
toolName: 'test-tool',
|
||||
toolDisplayName: 'Test Tool',
|
||||
toolArgs: {
|
||||
url: 'https://www.google.co.jp',
|
||||
},
|
||||
toolDescription: 'Navigates browser to a URL.',
|
||||
toolParameterSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Destination URL',
|
||||
},
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MCP Tool Details:');
|
||||
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
|
||||
expect(output).not.toContain('https://www.google.co.jp');
|
||||
expect(output).not.toContain('Navigates browser to a URL.');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should omit empty MCP invocation arguments from details', async () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'mcp',
|
||||
title: 'Confirm MCP Tool',
|
||||
serverName: 'test-server',
|
||||
toolName: 'test-tool',
|
||||
toolDisplayName: 'Test Tool',
|
||||
toolArgs: {},
|
||||
toolDescription: 'No arguments required.',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<ToolConfirmationMessage
|
||||
callId="test-call-id"
|
||||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MCP Tool Details:');
|
||||
expect(output).toContain('(press Ctrl+O to expand MCP tool details)');
|
||||
expect(output).not.toContain('Invocation Arguments:');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
@@ -29,6 +29,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import {
|
||||
REDIRECTION_WARNING_NOTE_LABEL,
|
||||
REDIRECTION_WARNING_NOTE_TEXT,
|
||||
@@ -64,6 +65,17 @@ export const ToolConfirmationMessage: React.FC<
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { confirm, isDiffingEnabled } = useToolActions();
|
||||
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
|
||||
callId: string;
|
||||
expanded: boolean;
|
||||
}>({
|
||||
callId,
|
||||
expanded: false,
|
||||
});
|
||||
const isMcpToolDetailsExpanded =
|
||||
mcpDetailsExpansionState.callId === callId
|
||||
? mcpDetailsExpansionState.expanded
|
||||
: false;
|
||||
|
||||
const settings = useSettings();
|
||||
const allowPermanentApproval =
|
||||
@@ -86,9 +98,81 @@ export const ToolConfirmationMessage: React.FC<
|
||||
[confirm, callId],
|
||||
);
|
||||
|
||||
const mcpToolDetailsText = useMemo(() => {
|
||||
if (confirmationDetails.type !== 'mcp') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detailsLines: string[] = [];
|
||||
const hasNonEmptyToolArgs =
|
||||
confirmationDetails.toolArgs !== undefined &&
|
||||
!(
|
||||
typeof confirmationDetails.toolArgs === 'object' &&
|
||||
confirmationDetails.toolArgs !== null &&
|
||||
Object.keys(confirmationDetails.toolArgs).length === 0
|
||||
);
|
||||
if (hasNonEmptyToolArgs) {
|
||||
let argsText: string;
|
||||
try {
|
||||
argsText = stripUnsafeCharacters(
|
||||
JSON.stringify(confirmationDetails.toolArgs, null, 2),
|
||||
);
|
||||
} catch {
|
||||
argsText = '[unserializable arguments]';
|
||||
}
|
||||
detailsLines.push('Invocation Arguments:');
|
||||
detailsLines.push(argsText);
|
||||
}
|
||||
|
||||
const description = confirmationDetails.toolDescription?.trim();
|
||||
if (description) {
|
||||
if (detailsLines.length > 0) {
|
||||
detailsLines.push('');
|
||||
}
|
||||
detailsLines.push('Description:');
|
||||
detailsLines.push(stripUnsafeCharacters(description));
|
||||
}
|
||||
|
||||
if (confirmationDetails.toolParameterSchema !== undefined) {
|
||||
let schemaText: string;
|
||||
try {
|
||||
schemaText = stripUnsafeCharacters(
|
||||
JSON.stringify(confirmationDetails.toolParameterSchema, null, 2),
|
||||
);
|
||||
} catch {
|
||||
schemaText = '[unserializable schema]';
|
||||
}
|
||||
if (detailsLines.length > 0) {
|
||||
detailsLines.push('');
|
||||
}
|
||||
detailsLines.push('Input Schema:');
|
||||
detailsLines.push(schemaText);
|
||||
}
|
||||
|
||||
if (detailsLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return detailsLines.join('\n');
|
||||
}, [confirmationDetails]);
|
||||
|
||||
const hasMcpToolDetails = !!mcpToolDetailsText;
|
||||
const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!isFocused) return false;
|
||||
if (
|
||||
confirmationDetails.type === 'mcp' &&
|
||||
hasMcpToolDetails &&
|
||||
keyMatchers[Command.SHOW_MORE_LINES](key)
|
||||
) {
|
||||
setMcpDetailsExpansionState({
|
||||
callId,
|
||||
expanded: !isMcpToolDetailsExpanded,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return true;
|
||||
@@ -100,7 +184,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
{ isActive: isFocused, priority: true },
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
@@ -504,12 +588,31 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
<>
|
||||
<Text color={theme.text.link}>
|
||||
MCP Server: {sanitizeForDisplay(mcpProps.serverName)}
|
||||
</Text>
|
||||
<Text color={theme.text.link}>
|
||||
Tool: {sanitizeForDisplay(mcpProps.toolName)}
|
||||
</Text>
|
||||
</>
|
||||
{hasMcpToolDetails && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>MCP Tool Details:</Text>
|
||||
{isMcpToolDetailsExpanded ? (
|
||||
<>
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to collapse MCP tool details)
|
||||
</Text>
|
||||
<Text color={theme.text.link}>{mcpToolDetailsText}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>
|
||||
(press {expandDetailsHintKey} to expand MCP tool details)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -522,8 +625,17 @@ export const ToolConfirmationMessage: React.FC<
|
||||
terminalWidth,
|
||||
handleConfirm,
|
||||
deceptiveUrlWarningText,
|
||||
isMcpToolDetailsExpanded,
|
||||
hasMcpToolDetails,
|
||||
mcpToolDetailsText,
|
||||
expandDetailsHintKey,
|
||||
]);
|
||||
|
||||
const bodyOverflowDirection: 'top' | 'bottom' =
|
||||
confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded
|
||||
? 'bottom'
|
||||
: 'top';
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (confirmationDetails.isModifying) {
|
||||
return (
|
||||
@@ -559,7 +671,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
<MaxSizedBox
|
||||
maxHeight={availableBodyContentHeight()}
|
||||
maxWidth={terminalWidth}
|
||||
overflowDirection="top"
|
||||
overflowDirection={bodyOverflowDirection}
|
||||
>
|
||||
{bodyContent}
|
||||
</MaxSizedBox>
|
||||
|
||||
@@ -57,6 +57,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
config,
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
@@ -93,6 +94,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
emphasis={emphasis}
|
||||
progressMessage={progressMessage}
|
||||
progressPercent={progressPercent}
|
||||
originalRequestName={originalRequestName}
|
||||
/>
|
||||
<FocusHint
|
||||
shouldShowFocusHint={shouldShowFocusHint}
|
||||
|
||||
@@ -189,6 +189,7 @@ type ToolInfoProps = {
|
||||
emphasis: TextEmphasis;
|
||||
progressMessage?: string;
|
||||
progressPercent?: number;
|
||||
originalRequestName?: string;
|
||||
};
|
||||
|
||||
export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
@@ -198,6 +199,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
emphasis,
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
originalRequestName,
|
||||
}) => {
|
||||
const status = mapCoreStatusToDisplayStatus(coreStatus);
|
||||
const nameColor = React.useMemo<string>(() => {
|
||||
@@ -242,6 +244,12 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>
|
||||
{originalRequestName && originalRequestName !== name && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(redirection from {originalRequestName})
|
||||
</Text>
|
||||
)}
|
||||
{!isCompletedAskUser && (
|
||||
<>
|
||||
{' '}
|
||||
|
||||
@@ -275,5 +275,20 @@ describe('toolMapping', () => {
|
||||
expect(result.tools[0].resultDisplay).toBeUndefined();
|
||||
expect(result.tools[0].status).toBe(CoreToolCallStatus.Scheduled);
|
||||
});
|
||||
|
||||
it('propagates originalRequestName correctly', () => {
|
||||
const toolCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
...mockRequest,
|
||||
originalRequestName: 'original_tool',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: mockInvocation,
|
||||
};
|
||||
|
||||
const result = mapToDisplay(toolCall);
|
||||
expect(result.tools[0].originalRequestName).toBe('original_tool');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ export function mapToDisplay(
|
||||
progressMessage,
|
||||
progressPercent,
|
||||
approvalMode: call.approvalMode,
|
||||
originalRequestName: call.request.originalRequestName,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Scheduler,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
type ExecutingToolCall,
|
||||
type CompletedToolCall,
|
||||
type ToolCallsUpdateMessage,
|
||||
type AnyDeclarativeTool,
|
||||
@@ -110,7 +111,7 @@ describe('useToolScheduler', () => {
|
||||
tool: createMockTool(),
|
||||
invocation: createMockInvocation(),
|
||||
liveOutput: 'Loading...',
|
||||
};
|
||||
} as ExecutingToolCall;
|
||||
|
||||
act(() => {
|
||||
void mockMessageBus.publish({
|
||||
@@ -405,4 +406,62 @@ describe('useToolScheduler', () => {
|
||||
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
|
||||
).toBe('subagent-1');
|
||||
});
|
||||
|
||||
it('adapts success/error status to executing when a tail call is present', () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() =>
|
||||
useToolScheduler(
|
||||
vi.fn().mockResolvedValue(undefined),
|
||||
mockConfig,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const mockToolCall = {
|
||||
status: CoreToolCallStatus.Success as const,
|
||||
request: {
|
||||
callId: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'p1',
|
||||
},
|
||||
tool: createMockTool(),
|
||||
invocation: createMockInvocation(),
|
||||
response: {
|
||||
callId: 'call-1',
|
||||
resultDisplay: 'OK',
|
||||
responseParts: [],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
tailToolCallRequest: {
|
||||
name: 'tail_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: '123',
|
||||
},
|
||||
};
|
||||
|
||||
act(() => {
|
||||
void mockMessageBus.publish({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [mockToolCall],
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
} as ToolCallsUpdateMessage);
|
||||
});
|
||||
|
||||
const [toolCalls, , , , , lastOutputTime] = result.current;
|
||||
|
||||
// Check if status has been adapted to 'executing'
|
||||
expect(toolCalls[0].status).toBe(CoreToolCallStatus.Executing);
|
||||
|
||||
// Check if lastOutputTime was updated due to the transitional state
|
||||
expect(lastOutputTime).toBeGreaterThan(startTime);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Scheduler,
|
||||
type EditorType,
|
||||
type ToolCallsUpdateMessage,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
|
||||
|
||||
@@ -115,7 +116,16 @@ export function useToolScheduler(
|
||||
useEffect(() => {
|
||||
const handler = (event: ToolCallsUpdateMessage) => {
|
||||
// Update output timer for UI spinners (Side Effect)
|
||||
if (event.toolCalls.some((tc) => tc.status === 'executing')) {
|
||||
const hasExecuting = event.toolCalls.some(
|
||||
(tc) =>
|
||||
tc.status === CoreToolCallStatus.Executing ||
|
||||
((tc.status === CoreToolCallStatus.Success ||
|
||||
tc.status === CoreToolCallStatus.Error) &&
|
||||
'tailToolCallRequest' in tc &&
|
||||
tc.tailToolCallRequest != null),
|
||||
);
|
||||
|
||||
if (hasExecuting) {
|
||||
setLastToolOutputTime(Date.now());
|
||||
}
|
||||
|
||||
@@ -238,9 +248,23 @@ function adaptToolCalls(
|
||||
const prev = prevMap.get(coreCall.request.callId);
|
||||
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
|
||||
|
||||
let status = coreCall.status;
|
||||
// If a tool call has completed but scheduled a tail call, it is in a transitional
|
||||
// state. Force the UI to render it as "executing".
|
||||
if (
|
||||
(status === CoreToolCallStatus.Success ||
|
||||
status === CoreToolCallStatus.Error) &&
|
||||
'tailToolCallRequest' in coreCall &&
|
||||
coreCall.tailToolCallRequest != null
|
||||
) {
|
||||
status = CoreToolCallStatus.Executing;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
...coreCall,
|
||||
status,
|
||||
responseSubmittedToGemini,
|
||||
};
|
||||
} as TrackedToolCall;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -352,7 +352,6 @@ describe('keyMatchers', () => {
|
||||
createKey('l', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Shell commands
|
||||
{
|
||||
command: Command.REVERSE_SEARCH,
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface IndividualToolCallDisplay {
|
||||
approvalMode?: ApprovalMode;
|
||||
progressMessage?: string;
|
||||
progressPercent?: number;
|
||||
originalRequestName?: string;
|
||||
}
|
||||
|
||||
export interface CompressionProps {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -98,26 +98,44 @@ 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,
|
||||
terse = 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);
|
||||
|
||||
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 (terse) {
|
||||
const hoursStr = hours > 0 ? `${hours}hr` : '';
|
||||
const minutesStr = minutes > 0 ? `${minutes}m` : '';
|
||||
return hoursStr && minutesStr
|
||||
? `${hoursStr} ${minutesStr}`
|
||||
: hoursStr || minutesStr;
|
||||
}
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
@@ -22,13 +22,82 @@ import WebSocket from 'ws';
|
||||
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
|
||||
const MAX_BUFFER_SIZE = 100;
|
||||
|
||||
/** Type guard: Array.isArray doesn't narrow readonly arrays in TS 5.8 */
|
||||
function isHeaderRecord(
|
||||
h: http.OutgoingHttpHeaders | readonly string[],
|
||||
): h is http.OutgoingHttpHeaders {
|
||||
return !Array.isArray(h);
|
||||
}
|
||||
|
||||
function isRequestOptions(value: unknown): value is http.RequestOptions {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!(value instanceof URL) &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isIncomingMessageCallback(
|
||||
value: unknown,
|
||||
): value is (res: http.IncomingMessage) => void {
|
||||
return typeof value === 'function';
|
||||
}
|
||||
|
||||
type HttpRequestArgs =
|
||||
| []
|
||||
| [
|
||||
url: string | URL | http.RequestOptions,
|
||||
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
|
||||
callback?: (res: http.IncomingMessage) => void,
|
||||
];
|
||||
|
||||
function callHttpRequest(
|
||||
originalFn: typeof http.request,
|
||||
args: HttpRequestArgs,
|
||||
): http.ClientRequest {
|
||||
if (args.length === 0) {
|
||||
return originalFn({});
|
||||
}
|
||||
if (args.length === 1) {
|
||||
const first = args[0];
|
||||
if (typeof first === 'string' || first instanceof URL) {
|
||||
return originalFn(first);
|
||||
}
|
||||
if (isRequestOptions(first)) {
|
||||
return originalFn(first);
|
||||
}
|
||||
return originalFn({});
|
||||
}
|
||||
if (args.length === 2) {
|
||||
const first = args[0];
|
||||
const second = args[1];
|
||||
if (typeof first === 'string' || first instanceof URL) {
|
||||
if (isIncomingMessageCallback(second)) {
|
||||
return originalFn(first, second);
|
||||
}
|
||||
if (isRequestOptions(second)) {
|
||||
return originalFn(first, second);
|
||||
}
|
||||
}
|
||||
if (isRequestOptions(first) && isIncomingMessageCallback(second)) {
|
||||
return originalFn(first, second);
|
||||
}
|
||||
}
|
||||
if (args.length === 3) {
|
||||
const first = args[0];
|
||||
const second = args[1];
|
||||
const third = args[2];
|
||||
if (
|
||||
(typeof first === 'string' || first instanceof URL) &&
|
||||
isRequestOptions(second) &&
|
||||
isIncomingMessageCallback(third)
|
||||
) {
|
||||
return originalFn(first, second, third);
|
||||
}
|
||||
}
|
||||
return originalFn({});
|
||||
}
|
||||
|
||||
export interface NetworkLog {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
@@ -364,7 +433,7 @@ export class ActivityLogger extends EventEmitter {
|
||||
|
||||
const wrapRequest = (
|
||||
originalFn: typeof http.request,
|
||||
args: unknown[],
|
||||
args: HttpRequestArgs,
|
||||
protocol: string,
|
||||
) => {
|
||||
const firstArg = args[0];
|
||||
@@ -373,8 +442,10 @@ export class ActivityLogger extends EventEmitter {
|
||||
options = firstArg;
|
||||
} else if (firstArg instanceof URL) {
|
||||
options = firstArg;
|
||||
} else if (firstArg && typeof firstArg === 'object') {
|
||||
options = isRequestOptions(firstArg) ? firstArg : {};
|
||||
} else {
|
||||
options = (firstArg ?? {}) as http.RequestOptions;
|
||||
options = {};
|
||||
}
|
||||
|
||||
let url = '';
|
||||
@@ -393,9 +464,9 @@ export class ActivityLogger extends EventEmitter {
|
||||
`${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`;
|
||||
}
|
||||
|
||||
if (url.includes('127.0.0.1') || url.includes('localhost'))
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return originalFn.apply(http, args as any);
|
||||
if (url.includes('127.0.0.1') || url.includes('localhost')) {
|
||||
return callHttpRequest(originalFn, args);
|
||||
}
|
||||
|
||||
const rawHeaders =
|
||||
typeof options === 'object' &&
|
||||
@@ -410,24 +481,23 @@ export class ActivityLogger extends EventEmitter {
|
||||
|
||||
if (headers[ACTIVITY_ID_HEADER]) {
|
||||
delete headers[ACTIVITY_ID_HEADER];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return originalFn.apply(http, args as any);
|
||||
return callHttpRequest(originalFn, args);
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
this.requestStartTimes.set(id, Date.now());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const req = originalFn.apply(http, args as any);
|
||||
const req = callHttpRequest(originalFn, args);
|
||||
const requestChunks: Buffer[] = [];
|
||||
|
||||
const oldWrite = req.write;
|
||||
const oldEnd = req.end;
|
||||
|
||||
req.write = function (chunk: unknown, ...etc: unknown[]) {
|
||||
req.write = function (chunk: string | Uint8Array, ...etc: unknown[]) {
|
||||
if (chunk) {
|
||||
const encoding =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
|
||||
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
|
||||
? etc[0]
|
||||
: undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
@@ -438,19 +508,21 @@ export class ActivityLogger extends EventEmitter {
|
||||
),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return oldWrite.apply(this, [chunk, ...etc] as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
|
||||
return (oldWrite as any).apply(this, [chunk, ...etc]);
|
||||
};
|
||||
|
||||
req.end = function (
|
||||
this: http.ClientRequest,
|
||||
chunk: unknown,
|
||||
chunkOrCb?: string | Uint8Array | (() => void),
|
||||
...etc: unknown[]
|
||||
) {
|
||||
if (chunk && typeof chunk !== 'function') {
|
||||
const chunk = typeof chunkOrCb === 'function' ? undefined : chunkOrCb;
|
||||
if (chunk) {
|
||||
const encoding =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
|
||||
typeof etc[0] === 'string' && Buffer.isEncoding(etc[0])
|
||||
? etc[0]
|
||||
: undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
@@ -473,7 +545,7 @@ export class ActivityLogger extends EventEmitter {
|
||||
pending: true,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
|
||||
return (oldEnd as any).apply(this, [chunk, ...etc]);
|
||||
return (oldEnd as any).apply(this, [chunkOrCb, ...etc]);
|
||||
};
|
||||
|
||||
req.on('response', (res: http.IncomingMessage) => {
|
||||
@@ -545,12 +617,44 @@ export class ActivityLogger extends EventEmitter {
|
||||
return req;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(http as any).request = (...args: unknown[]) =>
|
||||
wrapRequest(originalRequest, args, 'http:');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(https as any).request = (...args: unknown[]) =>
|
||||
wrapRequest(originalHttpsRequest as typeof http.request, args, 'https:');
|
||||
Object.defineProperty(http, 'request', {
|
||||
value: (
|
||||
url: string | URL | http.RequestOptions,
|
||||
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
|
||||
callback?: (res: http.IncomingMessage) => void,
|
||||
): http.ClientRequest => {
|
||||
const args: HttpRequestArgs =
|
||||
callback !== undefined
|
||||
? [url, options, callback]
|
||||
: options !== undefined
|
||||
? [url, options]
|
||||
: [url];
|
||||
return wrapRequest(originalRequest, args, 'http:');
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(https, 'request', {
|
||||
value: (
|
||||
url: string | URL | http.RequestOptions,
|
||||
options?: http.RequestOptions | ((res: http.IncomingMessage) => void),
|
||||
callback?: (res: http.IncomingMessage) => void,
|
||||
): http.ClientRequest => {
|
||||
const args: HttpRequestArgs =
|
||||
callback !== undefined
|
||||
? [url, options, callback]
|
||||
: options !== undefined
|
||||
? [url, options]
|
||||
: [url];
|
||||
return wrapRequest(
|
||||
originalHttpsRequest as typeof http.request,
|
||||
args,
|
||||
'https:',
|
||||
);
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
logConsole(payload: ConsoleLogPayload) {
|
||||
|
||||
Reference in New Issue
Block a user