mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 09:40:58 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcf2317adf | |||
| 61c7c95374 | |||
| ccee6a71df | |||
| 73add0fda7 | |||
| cc5c6bd90a | |||
| d9e79331ef | |||
| 68b7714d9b | |||
| 04dece3e3c | |||
| 2a3513bcdb |
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17692,7 +17692,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18003,7 +18003,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.30.0",
|
||||
@@ -18104,7 +18104,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"dependencies": {
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
@@ -18248,7 +18248,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
@@ -18259,7 +18259,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251209.ec9a8c7a7"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-preview.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-nightly.20251209.ec9a8c7a7"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.21.0-preview.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -498,6 +498,20 @@ export async function main() {
|
||||
|
||||
// Handle --list-sessions flag
|
||||
if (config.getListSessions()) {
|
||||
// Attempt auth for summary generation (gracefully skips if not configured)
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
if (authType) {
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
} catch (e) {
|
||||
// Auth failed - continue without summary generation capability
|
||||
debugLogger.debug(
|
||||
'Auth failed for --list-sessions, summaries may not be generated:',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await listSessions(config);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
SessionEndReason,
|
||||
fireSessionStartHook,
|
||||
fireSessionEndHook,
|
||||
generateAndSaveSummary,
|
||||
generateSummary,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -312,9 +312,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
: SessionStartSource.Startup;
|
||||
await fireSessionStartHook(hookMessageBus, sessionStartSource);
|
||||
}
|
||||
|
||||
// Fire-and-forget: generate summary for previous session in background
|
||||
generateSummary(config).catch((e) => {
|
||||
debugLogger.warn('Background summary generation failed:', e);
|
||||
});
|
||||
})();
|
||||
registerCleanup(async () => {
|
||||
await generateAndSaveSummary(config);
|
||||
// Turn off mouse scroll.
|
||||
disableMouseEvents();
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Cached');
|
||||
expect(output).not.toContain('Cache Reads');
|
||||
expect(output).not.toContain('Thoughts');
|
||||
expect(output).not.toContain('Tool');
|
||||
expect(output).toMatchSnapshot();
|
||||
@@ -168,7 +168,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Cached');
|
||||
expect(output).toContain('Cache Reads');
|
||||
expect(output).toContain('Thoughts');
|
||||
expect(output).toContain('Tool');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
@@ -160,24 +160,28 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
<StatRow
|
||||
title="Total"
|
||||
values={getModelValues((m) => (
|
||||
<Text color={theme.status.warning}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{m.tokens.total.toLocaleString()}
|
||||
</Text>
|
||||
))}
|
||||
/>
|
||||
<StatRow
|
||||
title="Prompt"
|
||||
title="Input"
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
|
||||
values={getModelValues((m) => (
|
||||
<Text color={theme.text.primary}>
|
||||
{Math.max(0, m.tokens.prompt - m.tokens.cached).toLocaleString()}
|
||||
</Text>
|
||||
))}
|
||||
/>
|
||||
{hasCached && (
|
||||
<StatRow
|
||||
title="Cached"
|
||||
title="Cache Reads"
|
||||
isSubtle
|
||||
values={getModelValues((m) => {
|
||||
const cacheHitRate = calculateCacheHitRate(m);
|
||||
return (
|
||||
<Text color={theme.status.success}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
|
||||
</Text>
|
||||
);
|
||||
@@ -188,20 +192,32 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
<StatRow
|
||||
title="Thoughts"
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
|
||||
values={getModelValues((m) => (
|
||||
<Text color={theme.text.primary}>
|
||||
{m.tokens.thoughts.toLocaleString()}
|
||||
</Text>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
{hasTool && (
|
||||
<StatRow
|
||||
title="Tool"
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
|
||||
values={getModelValues((m) => (
|
||||
<Text color={theme.text.primary}>
|
||||
{m.tokens.tool.toLocaleString()}
|
||||
</Text>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
<StatRow
|
||||
title="Output"
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
|
||||
values={getModelValues((m) => (
|
||||
<Text color={theme.text.primary}>
|
||||
{m.tokens.candidates.toLocaleString()}
|
||||
</Text>
|
||||
))}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -76,8 +76,6 @@ describe('<StatsDisplay />', () => {
|
||||
|
||||
expect(output).toContain('Performance');
|
||||
expect(output).toContain('Interaction Summary');
|
||||
expect(output).not.toContain('Efficiency & Optimizations');
|
||||
expect(output).not.toContain('Model'); // The table header
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -114,8 +112,8 @@ describe('<StatsDisplay />', () => {
|
||||
|
||||
expect(output).toContain('gemini-2.5-pro');
|
||||
expect(output).toContain('gemini-2.5-flash');
|
||||
expect(output).toContain('1,000');
|
||||
expect(output).toContain('25,000');
|
||||
expect(output).toContain('15,000');
|
||||
expect(output).toContain('10,000');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -168,7 +166,6 @@ describe('<StatsDisplay />', () => {
|
||||
expect(output).toContain('Performance');
|
||||
expect(output).toContain('Interaction Summary');
|
||||
expect(output).toContain('User Agreement');
|
||||
expect(output).toContain('Savings Highlight');
|
||||
expect(output).toContain('gemini-2.5-pro');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
@@ -233,7 +230,6 @@ describe('<StatsDisplay />', () => {
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('Efficiency & Optimizations');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -443,7 +439,7 @@ describe('<StatsDisplay />', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Usage limit remaining');
|
||||
expect(output).toContain('Usage left');
|
||||
expect(output).toContain('75.0%');
|
||||
expect(output).toContain('(Resets in 1h 30m)');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
@@ -17,9 +17,24 @@ import {
|
||||
TOOL_SUCCESS_RATE_MEDIUM,
|
||||
USER_AGREEMENT_RATE_HIGH,
|
||||
USER_AGREEMENT_RATE_MEDIUM,
|
||||
CACHE_EFFICIENCY_HIGH,
|
||||
CACHE_EFFICIENCY_MEDIUM,
|
||||
} from '../utils/displayUtils.js';
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import type { RetrieveUserQuotaResponse } from '@google/gemini-cli-core';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
const VALID_GEMINI_MODELS = new Set([
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
]);
|
||||
|
||||
// A more flexible and powerful StatRow component
|
||||
interface StatRowProps {
|
||||
@@ -33,8 +48,7 @@ const StatRow: React.FC<StatRowProps> = ({ title, children }) => (
|
||||
<Box width={28}>
|
||||
<Text color={theme.text.link}>{title}</Text>
|
||||
</Box>
|
||||
{/* FIX: Wrap children in a Box that can grow to fill remaining space */}
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -50,8 +64,7 @@ const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => (
|
||||
<Box width={26}>
|
||||
<Text color={theme.text.secondary}>» {title}</Text>
|
||||
</Box>
|
||||
{/* FIX: Apply the same flexGrow fix here */}
|
||||
<Box flexGrow={1}>{children}</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -70,6 +83,57 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
</Box>
|
||||
);
|
||||
|
||||
// Logic for building the unified list of table rows
|
||||
const buildModelRows = (
|
||||
models: Record<string, ModelMetrics>,
|
||||
quotas?: RetrieveUserQuotaResponse,
|
||||
) => {
|
||||
const getBaseModelName = (name: string) => name.replace('-001', '');
|
||||
const usedModelNames = new Set(Object.keys(models).map(getBaseModelName));
|
||||
|
||||
// 1. Models with active usage
|
||||
const activeRows = Object.entries(models).map(([name, metrics]) => {
|
||||
const modelName = getBaseModelName(name);
|
||||
const cachedTokens = metrics.tokens.cached;
|
||||
const totalInputTokens = metrics.tokens.prompt;
|
||||
const uncachedTokens = Math.max(0, totalInputTokens - cachedTokens);
|
||||
return {
|
||||
key: name,
|
||||
modelName,
|
||||
requests: metrics.api.totalRequests,
|
||||
cachedTokens: cachedTokens.toLocaleString(),
|
||||
uncachedTokens: uncachedTokens.toLocaleString(),
|
||||
totalInputTokens: totalInputTokens.toLocaleString(),
|
||||
outputTokens: metrics.tokens.candidates.toLocaleString(),
|
||||
bucket: quotas?.buckets?.find((b) => b.modelId === modelName),
|
||||
isActive: true,
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Models with quota only
|
||||
const quotaRows =
|
||||
quotas?.buckets
|
||||
?.filter(
|
||||
(b) =>
|
||||
b.modelId &&
|
||||
VALID_GEMINI_MODELS.has(b.modelId) &&
|
||||
!usedModelNames.has(b.modelId),
|
||||
)
|
||||
.map((bucket) => ({
|
||||
key: bucket.modelId!,
|
||||
modelName: bucket.modelId!,
|
||||
requests: '-',
|
||||
cachedTokens: '-',
|
||||
uncachedTokens: '-',
|
||||
totalInputTokens: '-',
|
||||
outputTokens: '-',
|
||||
bucket,
|
||||
isActive: false,
|
||||
})) || [];
|
||||
|
||||
return [...activeRows, ...quotaRows];
|
||||
};
|
||||
|
||||
const formatResetTime = (resetTime: string): string => {
|
||||
const diff = new Date(resetTime).getTime() - Date.now();
|
||||
if (diff <= 0) return '';
|
||||
@@ -96,44 +160,91 @@ const formatResetTime = (resetTime: string): string => {
|
||||
|
||||
const ModelUsageTable: React.FC<{
|
||||
models: Record<string, ModelMetrics>;
|
||||
totalCachedTokens: number;
|
||||
cacheEfficiency: number;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
}> = ({ models, totalCachedTokens, cacheEfficiency, quotas }) => {
|
||||
cacheEfficiency: number;
|
||||
totalCachedTokens: number;
|
||||
}> = ({ models, quotas, cacheEfficiency, totalCachedTokens }) => {
|
||||
const rows = buildModelRows(models, quotas);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showQuotaColumn = !!quotas && rows.some((row) => !!row.bucket);
|
||||
|
||||
const nameWidth = 25;
|
||||
const requestsWidth = 8;
|
||||
const inputTokensWidth = 15;
|
||||
const requestsWidth = 7;
|
||||
const uncachedWidth = 15;
|
||||
const cachedWidth = 14;
|
||||
const outputTokensWidth = 15;
|
||||
const usageLimitWidth = quotas ? 30 : 0;
|
||||
const usageLimitWidth = showQuotaColumn ? 28 : 0;
|
||||
|
||||
const cacheEfficiencyColor = getStatusColor(cacheEfficiency, {
|
||||
green: CACHE_EFFICIENCY_HIGH,
|
||||
yellow: CACHE_EFFICIENCY_MEDIUM,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Box width={nameWidth}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
<Box alignItems="flex-end">
|
||||
<Box width={nameWidth} flexGrow={1}>
|
||||
<Text bold color={theme.text.primary} wrap="truncate-end">
|
||||
Model Usage
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={requestsWidth} justifyContent="flex-end">
|
||||
<Box
|
||||
width={requestsWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Reqs
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={inputTokensWidth} justifyContent="flex-end">
|
||||
<Text bold color={theme.text.primary}>
|
||||
Input Tokens
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={outputTokensWidth} justifyContent="flex-end">
|
||||
<Text bold color={theme.text.primary}>
|
||||
Output Tokens
|
||||
</Text>
|
||||
</Box>
|
||||
{quotas && (
|
||||
<Box width={usageLimitWidth} justifyContent="flex-end">
|
||||
{!showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={uncachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Input Tokens
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={cachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Cache Reads
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={outputTokensWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Output Tokens
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{showQuotaColumn && (
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Usage limit remaining
|
||||
Usage left
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -146,63 +257,99 @@ const ModelUsageTable: React.FC<{
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={theme.border.default}
|
||||
width={
|
||||
nameWidth +
|
||||
requestsWidth +
|
||||
inputTokensWidth +
|
||||
outputTokensWidth +
|
||||
usageLimitWidth
|
||||
}
|
||||
width="100%"
|
||||
></Box>
|
||||
|
||||
{/* Rows */}
|
||||
{Object.entries(models).map(([name, modelMetrics]) => {
|
||||
const modelName = name.replace('-001', '');
|
||||
const bucket = quotas?.buckets?.find((b) => b.modelId === modelName);
|
||||
|
||||
return (
|
||||
<Box key={name}>
|
||||
<Box width={nameWidth}>
|
||||
<Text color={theme.text.primary}>{modelName}</Text>
|
||||
</Box>
|
||||
<Box width={requestsWidth} justifyContent="flex-end">
|
||||
<Text color={theme.text.primary}>
|
||||
{modelMetrics.api.totalRequests}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={inputTokensWidth} justifyContent="flex-end">
|
||||
<Text color={theme.status.warning}>
|
||||
{modelMetrics.tokens.prompt.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={outputTokensWidth} justifyContent="flex-end">
|
||||
<Text color={theme.status.warning}>
|
||||
{modelMetrics.tokens.candidates.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={usageLimitWidth} justifyContent="flex-end">
|
||||
{bucket &&
|
||||
bucket.remainingFraction != null &&
|
||||
bucket.resetTime && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{(bucket.remainingFraction * 100).toFixed(1)}%{' '}
|
||||
{formatResetTime(bucket.resetTime)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{rows.map((row) => (
|
||||
<Box key={row.key}>
|
||||
<Box width={nameWidth} flexGrow={1}>
|
||||
<Text color={theme.text.primary} wrap="truncate-end">
|
||||
{row.modelName}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{cacheEfficiency > 0 && (
|
||||
<Box
|
||||
width={requestsWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={row.isActive ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
{row.requests}
|
||||
</Text>
|
||||
</Box>
|
||||
{!showQuotaColumn && (
|
||||
<>
|
||||
<Box
|
||||
width={uncachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
row.isActive ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{row.uncachedTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={cachedWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text color={theme.text.secondary}>{row.cachedTokens}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={outputTokensWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
row.isActive ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{row.outputTokens}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box
|
||||
width={usageLimitWidth}
|
||||
flexDirection="column"
|
||||
alignItems="flex-end"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{cacheEfficiency > 0 && !showQuotaColumn && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={theme.status.success}>Savings Highlight:</Text>{' '}
|
||||
{totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)}
|
||||
%) of input tokens were served from the cache, reducing costs.
|
||||
{totalCachedTokens.toLocaleString()} (
|
||||
<Text color={cacheEfficiencyColor}>
|
||||
{cacheEfficiency.toFixed(1)}%
|
||||
</Text>
|
||||
) of input tokens were served from the cache, reducing costs.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{models && (
|
||||
|
||||
{showQuotaColumn && (
|
||||
<>
|
||||
<Box marginTop={1} marginBottom={2}>
|
||||
<Text color={theme.text.primary}>
|
||||
@@ -266,6 +413,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
overflow="hidden"
|
||||
>
|
||||
{renderTitle()}
|
||||
<Box height={1} />
|
||||
@@ -335,15 +483,12 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
</Text>
|
||||
</SubStatRow>
|
||||
</Section>
|
||||
|
||||
{Object.keys(models).length > 0 && (
|
||||
<ModelUsageTable
|
||||
models={models}
|
||||
totalCachedTokens={computed.totalCachedTokens}
|
||||
cacheEfficiency={computed.cacheEfficiency}
|
||||
quotas={quotas}
|
||||
/>
|
||||
)}
|
||||
<ModelUsageTable
|
||||
models={models}
|
||||
quotas={quotas}
|
||||
cacheEfficiency={computed.cacheEfficiency}
|
||||
totalCachedTokens={computed.totalCachedTokens}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,8 +14,8 @@ exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 30 │
|
||||
│ ↳ Prompt 10 │
|
||||
│ ↳ Cached 5 (50.0%) │
|
||||
│ ↳ Input 5 │
|
||||
│ ↳ Cache Reads 5 (50.0%) │
|
||||
│ ↳ Thoughts 2 │
|
||||
│ ↳ Tool 1 │
|
||||
│ ↳ Output 20 │
|
||||
@@ -37,8 +37,8 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 30 15 │
|
||||
│ ↳ Prompt 10 5 │
|
||||
│ ↳ Cached 5 (50.0%) 0 (0.0%) │
|
||||
│ ↳ Input 5 5 │
|
||||
│ ↳ Cache Reads 5 (50.0%) 0 (0.0%) │
|
||||
│ ↳ Thoughts 2 0 │
|
||||
│ ↳ Tool 0 3 │
|
||||
│ ↳ Output 20 10 │
|
||||
@@ -60,8 +60,8 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 300 600 │
|
||||
│ ↳ Prompt 100 200 │
|
||||
│ ↳ Cached 50 (50.0%) 100 (50.0%) │
|
||||
│ ↳ Input 50 100 │
|
||||
│ ↳ Cache Reads 50 (50.0%) 100 (50.0%) │
|
||||
│ ↳ Thoughts 10 20 │
|
||||
│ ↳ Tool 5 10 │
|
||||
│ ↳ Output 200 400 │
|
||||
@@ -83,8 +83,8 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 999,999,999 │
|
||||
│ ↳ Prompt 987,654,321 │
|
||||
│ ↳ Cached 123,456,789 (12.5%) │
|
||||
│ ↳ Input 864,197,532 │
|
||||
│ ↳ Cache Reads 123,456,789 (12.5%) │
|
||||
│ ↳ Thoughts 111,111,111 │
|
||||
│ ↳ Tool 222,222,222 │
|
||||
│ ↳ Output 123,456,789 │
|
||||
@@ -106,7 +106,7 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 30 │
|
||||
│ ↳ Prompt 10 │
|
||||
│ ↳ Input 10 │
|
||||
│ ↳ Output 20 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
|
||||
@@ -18,17 +18,11 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 10 1,000 2,000 │
|
||||
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 10 500 500 2,000 │
|
||||
│ │
|
||||
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
│ Usage limits span all sessions and reset daily. │
|
||||
│ /auth to upgrade or switch to API key. │
|
||||
│ │
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -118,15 +118,9 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 100 │
|
||||
│ │
|
||||
│ Usage limits span all sessions and reset daily. │
|
||||
│ /auth to upgrade or switch to API key. │
|
||||
│ │
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 0 100 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -168,11 +162,9 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens Usage limit remaining │
|
||||
│ ───────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 100 75.0% (Resets in 1h 30m) │
|
||||
│ │
|
||||
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
|
||||
│ Model Usage Reqs Usage left │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 75.0% (Resets in 1h 30m) │
|
||||
│ │
|
||||
│ Usage limits span all sessions and reset daily. │
|
||||
│ /auth to upgrade or switch to API key. │
|
||||
@@ -240,19 +232,13 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 3 1,000 2,000 │
|
||||
│ gemini-2.5-flash 5 25,000 15,000 │
|
||||
│ Model Usage 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. │
|
||||
│ │
|
||||
│ Usage limits span all sessions and reset daily. │
|
||||
│ /auth to upgrade or switch to API key. │
|
||||
│ │
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -274,18 +260,12 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
|
||||
│ » Tool Time: 123ms (55.2%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 100 │
|
||||
│ Model Usage 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. │
|
||||
│ │
|
||||
│ Usage limits span all sessions and reset daily. │
|
||||
│ /auth to upgrade or switch to API key. │
|
||||
│ │
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
return {
|
||||
...actual,
|
||||
ChatRecordingService: vi.fn(),
|
||||
generateSummary: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ChatRecordingService, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
ChatRecordingService,
|
||||
generateSummary,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
SessionSelector,
|
||||
@@ -12,6 +16,9 @@ import {
|
||||
} from './sessionUtils.js';
|
||||
|
||||
export async function listSessions(config: Config): Promise<void> {
|
||||
// Generate summary for most recent session if needed
|
||||
await generateSummary(config);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('IdeClient', () => {
|
||||
delete process.env['GEMINI_CLI_IDE_SERVER_PORT'];
|
||||
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_COMMAND'];
|
||||
delete process.env['GEMINI_CLI_IDE_SERVER_STDIO_ARGS'];
|
||||
delete process.env['GEMINI_CLI_IDE_AUTH_TOKEN'];
|
||||
|
||||
// Mock dependencies
|
||||
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
|
||||
@@ -923,5 +924,35 @@ describe('IdeClient', () => {
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
});
|
||||
|
||||
it('should connect with an auth token from environment variable if config file is missing', async () => {
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValue(
|
||||
new Error('File not found'),
|
||||
);
|
||||
(
|
||||
vi.mocked(fs.promises.readdir) as Mock<
|
||||
(path: fs.PathLike) => Promise<string[]>
|
||||
>
|
||||
).mockResolvedValue([]);
|
||||
process.env['GEMINI_CLI_IDE_SERVER_PORT'] = '9090';
|
||||
process.env['GEMINI_CLI_IDE_AUTH_TOKEN'] = 'env-auth-token';
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.connect();
|
||||
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
new URL('http://127.0.0.1:9090/mcp'),
|
||||
expect.objectContaining({
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: 'Bearer env-auth-token',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
IDEConnectionStatus.Connected,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,9 +151,10 @@ export class IdeClient {
|
||||
this.setState(IDEConnectionStatus.Connecting);
|
||||
|
||||
this.connectionConfig = await this.getConnectionConfigFromFile();
|
||||
if (this.connectionConfig?.authToken) {
|
||||
this.authToken = this.connectionConfig.authToken;
|
||||
}
|
||||
this.authToken =
|
||||
this.connectionConfig?.authToken ??
|
||||
process.env['GEMINI_CLI_IDE_AUTH_TOKEN'];
|
||||
|
||||
const workspacePath =
|
||||
this.connectionConfig?.workspacePath ??
|
||||
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
|
||||
|
||||
@@ -54,3 +54,8 @@ priority = 50
|
||||
toolName = "google_web_search"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { generateAndSaveSummary } from './sessionSummaryUtils.js';
|
||||
import { generateSummary, getPreviousSession } from './sessionSummaryUtils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ChatRecordingService } from './chatRecordingService.js';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('node:fs/promises');
|
||||
const mockReaddir = fs.readdir as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Mock the SessionSummaryService module
|
||||
vi.mock('./sessionSummaryService.js', () => ({
|
||||
@@ -23,10 +27,24 @@ vi.mock('../core/baseLlmClient.js', () => ({
|
||||
BaseLlmClient: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper to create a session with N user messages
|
||||
function createSessionWithUserMessages(
|
||||
count: number,
|
||||
options: { summary?: string; sessionId?: string } = {},
|
||||
) {
|
||||
return JSON.stringify({
|
||||
sessionId: options.sessionId ?? 'session-id',
|
||||
summary: options.summary,
|
||||
messages: Array.from({ length: count }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
type: 'user',
|
||||
content: [{ text: `Message ${i + 1}` }],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe('sessionSummaryUtils', () => {
|
||||
let mockConfig: Config;
|
||||
let mockChatRecordingService: ChatRecordingService;
|
||||
let mockGeminiClient: GeminiClient;
|
||||
let mockContentGenerator: ContentGenerator;
|
||||
let mockGenerateSummary: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -36,23 +54,12 @@ describe('sessionSummaryUtils', () => {
|
||||
// Setup mock content generator
|
||||
mockContentGenerator = {} as ContentGenerator;
|
||||
|
||||
// Setup mock chat recording service
|
||||
mockChatRecordingService = {
|
||||
getConversation: vi.fn(),
|
||||
saveSummary: vi.fn(),
|
||||
} as unknown as ChatRecordingService;
|
||||
|
||||
// Setup mock gemini client
|
||||
mockGeminiClient = {
|
||||
getChatRecordingService: vi
|
||||
.fn()
|
||||
.mockReturnValue(mockChatRecordingService),
|
||||
} as unknown as GeminiClient;
|
||||
|
||||
// Setup mock config
|
||||
mockConfig = {
|
||||
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
// Setup mock generateSummary function
|
||||
@@ -73,320 +80,138 @@ describe('sessionSummaryUtils', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should generate and save summary successfully', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'How do I add dark mode?' }],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2025-12-03T00:01:00Z',
|
||||
type: 'gemini' as const,
|
||||
content: [{ text: 'To add dark mode...' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
describe('getPreviousSession', () => {
|
||||
it('should return null if chats directory does not exist', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).toHaveBeenCalledWith({
|
||||
messages: mockConversation.messages,
|
||||
});
|
||||
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledWith(
|
||||
'Add dark mode to the app',
|
||||
it('should return null if no session files exist', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if most recent session already has summary', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
createSessionWithUserMessages(5, { summary: 'Existing summary' }),
|
||||
);
|
||||
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if most recent session has 1 or fewer user messages', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
createSessionWithUserMessages(1),
|
||||
);
|
||||
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return path if most recent session has more than 1 user message and no summary', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
createSessionWithUserMessages(2),
|
||||
);
|
||||
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(result).toBe(
|
||||
path.join(
|
||||
'/tmp/project',
|
||||
'chats',
|
||||
'session-2024-01-01T10-00-abc12345.json',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip if no chat recording service is available', async () => {
|
||||
(
|
||||
mockGeminiClient.getChatRecordingService as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(undefined);
|
||||
it('should select most recently created session by filename', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue([
|
||||
'session-2024-01-01T10-00-older000.json',
|
||||
'session-2024-01-02T10-00-newer000.json',
|
||||
]);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
createSessionWithUserMessages(2),
|
||||
);
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(mockGeminiClient.getChatRecordingService).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).not.toHaveBeenCalled();
|
||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
||||
expect(result).toBe(
|
||||
path.join(
|
||||
'/tmp/project',
|
||||
'chats',
|
||||
'session-2024-01-02T10-00-newer000.json',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip if no conversation exists', async () => {
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(null);
|
||||
it('should return null if most recent session file is corrupted', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||
vi.mocked(fs.readFile).mockResolvedValue('invalid json');
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
const result = await getPreviousSession(mockConfig);
|
||||
|
||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).not.toHaveBeenCalled();
|
||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if summary already exists', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
summary: 'Existing summary',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
|
||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).not.toHaveBeenCalled();
|
||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if no messages present', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: [],
|
||||
};
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
|
||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).not.toHaveBeenCalled();
|
||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle generateSummary failure gracefully', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
mockGenerateSummary.mockResolvedValue(null);
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
|
||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should catch and log errors without throwing', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
mockGenerateSummary.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
// Should not throw
|
||||
await expect(generateAndSaveSummary(mockConfig)).resolves.not.toThrow();
|
||||
|
||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mock Verification Tests', () => {
|
||||
it('should call getConversation() once', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
describe('generateSummary', () => {
|
||||
it('should not throw if getPreviousSession returns null', async () => {
|
||||
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
|
||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledWith();
|
||||
await expect(generateSummary(mockConfig)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should call generateSummary() with correct messages', async () => {
|
||||
const mockMessages = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'How do I add dark mode?' }],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2025-12-03T00:01:00Z',
|
||||
type: 'gemini' as const,
|
||||
content: [{ text: 'To add dark mode...' }],
|
||||
},
|
||||
];
|
||||
it('should generate and save summary for session needing one', async () => {
|
||||
const sessionPath = path.join(
|
||||
'/tmp/project',
|
||||
'chats',
|
||||
'session-2024-01-01T10-00-abc12345.json',
|
||||
);
|
||||
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: mockMessages,
|
||||
};
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
createSessionWithUserMessages(2),
|
||||
);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
await generateSummary(mockConfig);
|
||||
|
||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerateSummary).toHaveBeenCalledWith({
|
||||
messages: mockMessages,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call saveSummary() with generated summary', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
mockGenerateSummary.mockResolvedValue('Test summary');
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
|
||||
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledWith(
|
||||
'Test summary',
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
sessionPath,
|
||||
expect.stringContaining('Add dark mode to the app'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call saveSummary() if generation fails', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
it('should handle errors gracefully without throwing', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
createSessionWithUserMessages(2),
|
||||
);
|
||||
mockGenerateSummary.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
mockGenerateSummary.mockResolvedValue(null);
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
|
||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call saveSummary() if generateSummary throws', async () => {
|
||||
const mockConversation = {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2025-12-03T00:00:00Z',
|
||||
lastUpdated: '2025-12-03T00:10:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2025-12-03T00:00:00Z',
|
||||
type: 'user' as const,
|
||||
content: [{ text: 'Hello' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(
|
||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(mockConversation);
|
||||
mockGenerateSummary.mockRejectedValue(new Error('Generation failed'));
|
||||
|
||||
await generateAndSaveSummary(mockConfig);
|
||||
|
||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
||||
await expect(generateSummary(mockConfig)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,62 +8,172 @@ import type { Config } from '../config/config.js';
|
||||
import { SessionSummaryService } from './sessionSummaryService.js';
|
||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
SESSION_FILE_PREFIX,
|
||||
type ConversationRecord,
|
||||
} from './chatRecordingService.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const MIN_MESSAGES_FOR_SUMMARY = 1;
|
||||
|
||||
/**
|
||||
* Generates and saves a summary for the current session.
|
||||
* This is called during session cleanup and is non-blocking - errors are logged but don't prevent exit.
|
||||
* Generates and saves a summary for a session file.
|
||||
*/
|
||||
export async function generateAndSaveSummary(config: Config): Promise<void> {
|
||||
async function generateAndSaveSummary(
|
||||
config: Config,
|
||||
sessionPath: string,
|
||||
): Promise<void> {
|
||||
// Read session file
|
||||
const content = await fs.readFile(sessionPath, 'utf-8');
|
||||
const conversation: ConversationRecord = JSON.parse(content);
|
||||
|
||||
// Skip if summary already exists
|
||||
if (conversation.summary) {
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Summary already exists for ${sessionPath}, skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no messages
|
||||
if (conversation.messages.length === 0) {
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] No messages to summarize in ${sessionPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create summary service
|
||||
const contentGenerator = config.getContentGenerator();
|
||||
if (!contentGenerator) {
|
||||
debugLogger.debug(
|
||||
'[SessionSummary] Content generator not available, skipping summary generation',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, config);
|
||||
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||
|
||||
// Generate summary
|
||||
const summary = await summaryService.generateSummary({
|
||||
messages: conversation.messages,
|
||||
});
|
||||
|
||||
if (!summary) {
|
||||
debugLogger.warn(
|
||||
`[SessionSummary] Failed to generate summary for ${sessionPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-read the file before writing to handle race conditions
|
||||
const freshContent = await fs.readFile(sessionPath, 'utf-8');
|
||||
const freshConversation: ConversationRecord = JSON.parse(freshContent);
|
||||
|
||||
// Check if summary was added by another process
|
||||
if (freshConversation.summary) {
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Summary was added by another process for ${sessionPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add summary and write back
|
||||
freshConversation.summary = summary;
|
||||
freshConversation.lastUpdated = new Date().toISOString();
|
||||
await fs.writeFile(sessionPath, JSON.stringify(freshConversation, null, 2));
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Saved summary for ${sessionPath}: "${summary}"`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the most recently created session that needs a summary.
|
||||
* Returns the path if it needs a summary, null otherwise.
|
||||
*/
|
||||
export async function getPreviousSession(
|
||||
config: Config,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// Get the chat recording service from config
|
||||
const chatRecordingService = config
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
if (!chatRecordingService) {
|
||||
debugLogger.debug('[SessionSummary] No chat recording service available');
|
||||
return;
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
|
||||
// Check if chats directory exists
|
||||
try {
|
||||
await fs.access(chatsDir);
|
||||
} catch {
|
||||
debugLogger.debug('[SessionSummary] No chats directory found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the current conversation
|
||||
const conversation = chatRecordingService.getConversation();
|
||||
if (!conversation) {
|
||||
debugLogger.debug('[SessionSummary] No conversation to summarize');
|
||||
return;
|
||||
// List session files
|
||||
const allFiles = await fs.readdir(chatsDir);
|
||||
const sessionFiles = allFiles.filter(
|
||||
(f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'),
|
||||
);
|
||||
|
||||
if (sessionFiles.length === 0) {
|
||||
debugLogger.debug('[SessionSummary] No session files found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip if summary already exists (e.g., resumed session)
|
||||
if (conversation.summary) {
|
||||
debugLogger.debug('[SessionSummary] Summary already exists, skipping');
|
||||
return;
|
||||
// Sort by filename descending (most recently created first)
|
||||
// Filename format: session-YYYY-MM-DDTHH-MM-XXXXXXXX.json
|
||||
sessionFiles.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
// Check the most recently created session
|
||||
const mostRecentFile = sessionFiles[0];
|
||||
const filePath = path.join(chatsDir, mostRecentFile);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const conversation: ConversationRecord = JSON.parse(content);
|
||||
|
||||
if (conversation.summary) {
|
||||
debugLogger.debug(
|
||||
'[SessionSummary] Most recent session already has summary',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only generate summaries for sessions with more than 1 user message
|
||||
const userMessageCount = conversation.messages.filter(
|
||||
(m) => m.type === 'user',
|
||||
).length;
|
||||
if (userMessageCount <= MIN_MESSAGES_FOR_SUMMARY) {
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Most recent session has ${userMessageCount} user message(s), skipping (need more than ${MIN_MESSAGES_FOR_SUMMARY})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
} catch {
|
||||
debugLogger.debug('[SessionSummary] Could not read most recent session');
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`[SessionSummary] Error finding previous session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if no messages
|
||||
if (conversation.messages.length === 0) {
|
||||
debugLogger.debug('[SessionSummary] No messages to summarize');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create summary service
|
||||
const contentGenerator = config.getContentGenerator();
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, config);
|
||||
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||
|
||||
// Generate summary
|
||||
const summary = await summaryService.generateSummary({
|
||||
messages: conversation.messages,
|
||||
});
|
||||
|
||||
// Save summary if generated successfully
|
||||
if (summary) {
|
||||
chatRecordingService.saveSummary(summary);
|
||||
debugLogger.debug(`[SessionSummary] Saved summary: "${summary}"`);
|
||||
} else {
|
||||
debugLogger.warn('[SessionSummary] Failed to generate summary');
|
||||
/**
|
||||
* Generates summary for the previous session if it lacks one.
|
||||
* This is designed to be called fire-and-forget on startup.
|
||||
*/
|
||||
export async function generateSummary(config: Config): Promise<void> {
|
||||
try {
|
||||
const sessionPath = await getPreviousSession(config);
|
||||
if (sessionPath) {
|
||||
await generateAndSaveSummary(config, sessionPath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't throw - we want graceful degradation
|
||||
debugLogger.warn(
|
||||
`[SessionSummary] Error in generateAndSaveSummary: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`[SessionSummary] Error generating summary: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.21.0-nightly.20251209.ec9a8c7a7",
|
||||
"version": "0.21.0-preview.4",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user