├── icon.png ├── images ├── demo1.png ├── demo2.png ├── demo3.png ├── issue1.png ├── issue2.png ├── demo2-en.png └── install.png ├── tsconfig.json ├── .vscodeignore ├── .gitignore ├── package.nls.zh-cn.json ├── package.nls.json ├── src ├── i18n │ ├── types.ts │ ├── zh-cn.ts │ ├── en.ts │ └── localizationService.ts ├── types.ts ├── configService.ts ├── portDetectionService.ts ├── platformDetector.ts ├── versionInfo.ts ├── devTools.ts ├── windowsProcessDetector.ts ├── unixProcessDetector.ts ├── processPortDetector.ts ├── extension.ts ├── statusBar.ts └── quotaService.ts ├── LICENSE ├── README.md ├── package.json └── README.en.md /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/AntigravityQuotaWatcher/main/icon.png -------------------------------------------------------------------------------- /images/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/AntigravityQuotaWatcher/main/images/demo1.png -------------------------------------------------------------------------------- /images/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/AntigravityQuotaWatcher/main/images/demo2.png -------------------------------------------------------------------------------- /images/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/AntigravityQuotaWatcher/main/images/demo3.png -------------------------------------------------------------------------------- /images/issue1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/AntigravityQuotaWatcher/main/images/issue1.png -------------------------------------------------------------------------------- /images/issue2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/AntigravityQuotaWatcher/main/images/issue2.png -------------------------------------------------------------------------------- /images/demo2-en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/AntigravityQuotaWatcher/main/images/demo2-en.png -------------------------------------------------------------------------------- /images/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuilenren/AntigravityQuotaWatcher/main/images/install.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": [ 7 | "ES2020" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "resolveJsonModule": true 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | ".vscode-test" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | # 源代码(只打包编译后的代码) 2 | src/** 3 | **/*.ts 4 | 5 | # 开发工具配置 6 | .vscode/** 7 | .vscode-test/** 8 | .gitignore 9 | .yarnrc 10 | .eslintrc.json 11 | **/.eslintrc.json 12 | tsconfig.json 13 | **/tsconfig.json 14 | 15 | # 文档和说明 16 | vsc-extension-quickstart.md 17 | DESIGN.md 18 | *.md 19 | !README.md 20 | 21 | # 图片资源(托管在 GitHub,不需要打包) 22 | images/** 23 | *.png 24 | *.jpg 25 | *.gif 26 | !icon.png 27 | 28 | # 调试文件 29 | **/*.map 30 | 31 | # 依赖(会被打包工具处理) 32 | node_modules/** 33 | 34 | # 构建脚本 35 | build.ps1 36 | *.sh 37 | 38 | # 已打包的扩展 39 | *.vsix 40 | 41 | # 临时文件 42 | *.log 43 | .DS_Store 44 | Thumbs.db 45 | 46 | # 其他开发文件 47 | .claude/** 48 | .git/** 49 | .gitattributes 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 依赖 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | pnpm-debug.log* 7 | 8 | # 构建输出 9 | out/ 10 | dist/ 11 | build/ 12 | *.vsix 13 | *.zip 14 | *.rar 15 | 16 | # 测试 17 | .vscode-test/ 18 | coverage/ 19 | *.test.js 20 | *.spec.js 21 | 22 | # 日志 23 | *.log 24 | logs/ 25 | 26 | # 环境变量 27 | .env 28 | .env.local 29 | .env.*.local 30 | 31 | # 编辑器和 IDE 32 | .vscode/ 33 | .idea/ 34 | *.swp 35 | *.swo 36 | *~ 37 | .project 38 | .classpath 39 | .settings/ 40 | 41 | # 系统文件 42 | .DS_Store 43 | Thumbs.db 44 | Desktop.ini 45 | $RECYCLE.BIN/ 46 | 47 | # 临时文件 48 | *.tmp 49 | *.temp 50 | .cache/ 51 | 52 | # 调试文件 53 | *.map 54 | 55 | # 其他 56 | .claude/ 57 | 58 | CLAUDE.md 59 | AGENT.md 60 | .agent/workflows/package_extension.md 61 | -------------------------------------------------------------------------------- /package.nls.zh-cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "command.showQuota": "Antigravity: 显示配额详情", 3 | "command.refreshQuota": "Antigravity: 刷新配额", 4 | "command.detectPort": "Antigravity: 重新检测端口", 5 | "config.enabled": "启用自动监控", 6 | "config.pollingInterval": "轮询间隔(秒)", 7 | "config.displayStyle": "状态栏显示样式", 8 | "config.apiMethod": "API 调用方式: GET_USER_STATUS (获取完整配额) 或 COMMAND_MODEL_CONFIG (兼容模式)", 9 | "config.warningThreshold": "警告阈值 (%)", 10 | "config.criticalThreshold": "临界阈值 (%)", 11 | "config.showPlanName": "在状态栏显示套餐级别(官方暂未启用此字段)", 12 | "config.showGeminiPro": "在状态栏显示 Gemini Pro (G Pro) 额度", 13 | "config.showGeminiFlash": "在状态栏显示 Gemini Flash (G Flash) 额度", 14 | "config.showPromptCredits": "显示 Prompt Credits(官方暂未启用此字段)", 15 | "config.forcePowerShell": "使用 PowerShell 模式检测进程【仅 Windows 系统可用,插件重启生效】", 16 | "config.language": "Language / 语言" 17 | } -------------------------------------------------------------------------------- /package.nls.json: -------------------------------------------------------------------------------- 1 | { 2 | "command.showQuota": "Antigravity: Show Quota Details", 3 | "command.refreshQuota": "Antigravity: Refresh Quota", 4 | "command.detectPort": "Antigravity: Re-detect Port", 5 | "config.enabled": "Enable auto monitoring", 6 | "config.pollingInterval": "Polling interval (seconds)", 7 | "config.displayStyle": "Status bar display style", 8 | "config.apiMethod": "API Method: GET_USER_STATUS (Full) or COMMAND_MODEL_CONFIG (Lite)", 9 | "config.warningThreshold": "Warning threshold (%)", 10 | "config.criticalThreshold": "Critical threshold (%)", 11 | "config.showPlanName": "Show plan name in status bar (not officially enabled yet)", 12 | "config.showGeminiPro": "Show Gemini Pro (G Pro) quota in status bar", 13 | "config.showGeminiFlash": "Show Gemini Flash (G Flash) quota in status bar", 14 | "config.showPromptCredits": "Show Prompt Credits (not officially enabled yet)", 15 | "config.forcePowerShell": "Use PowerShell mode for process detection [Windows only, requires extension restart]", 16 | "config.language": "Language / 语言" 17 | } -------------------------------------------------------------------------------- /src/i18n/types.ts: -------------------------------------------------------------------------------- 1 | export type TranslationKey = 2 | // Status Bar 3 | | 'status.initializing' 4 | | 'status.detecting' 5 | | 'status.fetching' 6 | | 'status.retrying' 7 | | 'status.error' 8 | | 'status.refreshing' 9 | 10 | // Tooltip 11 | | 'tooltip.title' 12 | | 'tooltip.credits' 13 | | 'tooltip.available' 14 | | 'tooltip.remaining' 15 | | 'tooltip.depleted' 16 | | 'tooltip.resetTime' 17 | | 'tooltip.model' 18 | | 'tooltip.status' 19 | | 'tooltip.error' 20 | | 'tooltip.clickToRetry' 21 | 22 | // Notifications (vscode.window.show*Message) 23 | | 'notify.unableToDetectProcess' 24 | | 'notify.retry' 25 | | 'notify.cancel' 26 | | 'notify.refreshingQuota' 27 | | 'notify.detectionSuccess' 28 | | 'notify.unableToDetectPort' 29 | | 'notify.unableToDetectPortHint1' 30 | | 'notify.unableToDetectPortHint2' 31 | | 'notify.portDetectionFailed' 32 | | 'notify.configUpdated' 33 | | 'notify.portCommandRequired' 34 | | 'notify.portCommandRequiredDarwin'; 35 | 36 | export interface TranslationMap { 37 | [key: string]: string; 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Antigravity Quota Watcher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/i18n/zh-cn.ts: -------------------------------------------------------------------------------- 1 | import { TranslationMap } from './types'; 2 | 3 | export const zh_cn: TranslationMap = { 4 | // 状态栏 5 | 'status.initializing': '⏳ 初始化中...', 6 | 'status.detecting': '🔍 检测端口中...', 7 | 'status.fetching': '$(sync~spin) 获取配额中...', 8 | 'status.retrying': '$(sync~spin) 重试中 ({current}/{max})...', 9 | 'status.error': '$(error) Antigravity Quota Watcher: 错误', 10 | 'status.refreshing': '$(sync~spin) 刷新中...', 11 | 12 | // hover 提示框 13 | 'tooltip.title': '**Antigravity 模型配额**', 14 | 'tooltip.credits': '💳 **提示词额度**', 15 | 'tooltip.available': '可用', 16 | 'tooltip.remaining': '剩余', 17 | 'tooltip.depleted': '⚠️ **已耗尽**', 18 | 'tooltip.resetTime': '重置时间', 19 | 'tooltip.model': '模型', 20 | 'tooltip.status': '剩余', 21 | 'tooltip.error': '获取配额信息时出错。', 22 | 'tooltip.clickToRetry': '点击重试', 23 | 24 | // 通知弹窗 (vscode.window.show*Message) 25 | 'notify.unableToDetectProcess': 'Antigravity Quota Watcher: 无法检测到 Antigravity 进程。', 26 | 'notify.retry': '重试', 27 | 'notify.cancel': '取消', 28 | 'notify.refreshingQuota': '🔄 正在刷新配额...', 29 | 'notify.detectionSuccess': '✅ 检测成功!端口: {port}', 30 | 'notify.unableToDetectPort': '❌ 无法检测到有效端口。请确保:', 31 | 'notify.unableToDetectPortHint1': '1. 已登录 Google 账户', 32 | 'notify.unableToDetectPortHint2': '2. 系统有权限运行检测命令', 33 | 'notify.portDetectionFailed': '❌ 端口检测失败: {error}', 34 | 'notify.configUpdated': 'Antigravity Quota Watcher 配置已更新', 35 | 'notify.portCommandRequired': '端口检测需要 lsof、ss 或 netstat。请安装其中之一', 36 | 'notify.portCommandRequiredDarwin': '端口检测需要 lsof 或 netstat。请安装其中之一' 37 | }; 38 | -------------------------------------------------------------------------------- /src/i18n/en.ts: -------------------------------------------------------------------------------- 1 | import { TranslationMap } from './types'; 2 | 3 | export const en: TranslationMap = { 4 | // Status Bar 5 | 'status.initializing': '⏳ Initializing...', 6 | 'status.detecting': '🔍 Detecting port...', 7 | 'status.fetching': '$(sync~spin) Fetching quota...', 8 | 'status.retrying': '$(sync~spin) Retrying ({current}/{max})...', 9 | 'status.error': '$(error) Antigravity Quota Watcher: Error', 10 | 'status.refreshing': '$(sync~spin) Refreshing...', 11 | 12 | // Tooltip 13 | 'tooltip.title': '**Antigravity Model Quota**', // Markdown bold 14 | 'tooltip.credits': '💳 **Prompt Credits**', 15 | 'tooltip.available': 'Available', 16 | 'tooltip.remaining': 'Remaining', 17 | 'tooltip.depleted': '⚠️ **Depleted**', 18 | 'tooltip.resetTime': 'Reset', 19 | 'tooltip.model': 'Model', 20 | 'tooltip.status': 'Status', 21 | 'tooltip.error': 'Error fetching quota information.', 22 | 'tooltip.clickToRetry': 'Click to retry', 23 | 24 | // Notifications (vscode.window.show*Message) 25 | 'notify.unableToDetectProcess': 'Antigravity Quota Watcher: Unable to detect the Antigravity process.', 26 | 'notify.retry': 'Retry', 27 | 'notify.cancel': 'Cancel', 28 | 'notify.refreshingQuota': '🔄 Refreshing quota...', 29 | 'notify.detectionSuccess': '✅ Detection successful! Port: {port}', 30 | 'notify.unableToDetectPort': '❌ Unable to detect a valid port. Please ensure:', 31 | 'notify.unableToDetectPortHint1': '1. Your Google account is signed in', 32 | 'notify.unableToDetectPortHint2': '2. The system has permission to run the detection commands', 33 | 'notify.portDetectionFailed': '❌ Port detection failed: {error}', 34 | 'notify.configUpdated': 'Antigravity Quota Watcher config updated', 35 | 'notify.portCommandRequired': 'Port detection requires lsof, ss, or netstat. Please install one of them', 36 | 'notify.portCommandRequiredDarwin': 'Port detection requires lsof or netstat. Please install one of them' 37 | }; 38 | -------------------------------------------------------------------------------- /src/i18n/localizationService.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { TranslationKey, TranslationMap } from './types'; 3 | import { en } from './en'; 4 | import { zh_cn } from './zh-cn'; 5 | 6 | export type Language = 'auto' | 'en' | 'zh-cn'; 7 | 8 | export class LocalizationService { 9 | private static instance: LocalizationService; 10 | private currentLocale: TranslationMap = en; 11 | private language: Language = 'auto'; 12 | 13 | private constructor() { 14 | this.updateLocale(); 15 | } 16 | 17 | public static getInstance(): LocalizationService { 18 | if (!LocalizationService.instance) { 19 | LocalizationService.instance = new LocalizationService(); 20 | } 21 | return LocalizationService.instance; 22 | } 23 | 24 | public setLanguage(lang: Language) { 25 | this.language = lang; 26 | this.updateLocale(); 27 | } 28 | 29 | public getLanguage(): Language { 30 | return this.language; 31 | } 32 | 33 | private updateLocale() { 34 | if (this.language === 'auto') { 35 | const vscodeLang = vscode.env.language; 36 | // vscode.env.language returns 'en', 'zh-cn', 'zh-tw', etc. 37 | if (vscodeLang.toLowerCase().startsWith('zh')) { 38 | this.currentLocale = zh_cn; 39 | } else { 40 | this.currentLocale = en; 41 | } 42 | } else if (this.language === 'zh-cn') { 43 | this.currentLocale = zh_cn; 44 | } else { 45 | this.currentLocale = en; 46 | } 47 | } 48 | 49 | public t(key: TranslationKey, params?: { [key: string]: string | number }): string { 50 | let text = this.currentLocale[key] || en[key] || key; 51 | 52 | if (params) { 53 | Object.keys(params).forEach(param => { 54 | text = text.replace(`{${param}}`, String(params[param])); 55 | }); 56 | } 57 | 58 | return text; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Antigravity Quota Watcher - type definitions 3 | */ 4 | 5 | export interface ModelConfig { 6 | label: string; 7 | modelOrAlias: { 8 | model: string; 9 | }; 10 | quotaInfo?: { 11 | remainingFraction?: number; 12 | resetTime: string; 13 | }; 14 | supportsImages?: boolean; 15 | isRecommended?: boolean; 16 | allowedTiers?: string[]; 17 | } 18 | 19 | export interface UserStatusResponse { 20 | userStatus: { 21 | name: string; 22 | email: string; 23 | planStatus?: { 24 | planInfo: { 25 | teamsTier: string; 26 | planName: string; 27 | monthlyPromptCredits: number; 28 | monthlyFlowCredits: number; 29 | }; 30 | availablePromptCredits: number; 31 | availableFlowCredits: number; 32 | }; 33 | cascadeModelConfigData?: { 34 | clientModelConfigs: ModelConfig[]; 35 | }; 36 | }; 37 | } 38 | 39 | export interface PromptCreditsInfo { 40 | available: number; 41 | monthly: number; 42 | usedPercentage: number; 43 | remainingPercentage: number; 44 | } 45 | 46 | export interface ModelQuotaInfo { 47 | label: string; 48 | modelId: string; 49 | remainingFraction?: number; 50 | remainingPercentage?: number; 51 | isExhausted: boolean; 52 | resetTime: Date; 53 | timeUntilReset: number; 54 | timeUntilResetFormatted: string; 55 | } 56 | 57 | export interface QuotaSnapshot { 58 | timestamp: Date; 59 | promptCredits?: PromptCreditsInfo; 60 | models: ModelQuotaInfo[]; 61 | planName?: string; 62 | } 63 | 64 | export enum QuotaLevel { 65 | Normal = 'normal', 66 | Warning = 'warning', 67 | Critical = 'critical', 68 | Depleted = 'depleted' 69 | } 70 | 71 | export type ApiMethodPreference = 'COMMAND_MODEL_CONFIG' | 'GET_USER_STATUS'; 72 | 73 | export interface Config { 74 | enabled: boolean; 75 | pollingInterval: number; 76 | warningThreshold: number; 77 | criticalThreshold: number; 78 | apiMethod: ApiMethodPreference; 79 | showPromptCredits: boolean; 80 | showPlanName: boolean; 81 | showGeminiPro: boolean; 82 | showGeminiFlash: boolean; 83 | displayStyle: 'percentage' | 'progressBar' | 'dots'; 84 | language: 'auto' | 'en' | 'zh-cn'; 85 | } 86 | -------------------------------------------------------------------------------- /src/configService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 配置管理服务 3 | */ 4 | 5 | import * as vscode from 'vscode'; 6 | import { Config } from './types'; 7 | 8 | export class ConfigService { 9 | private readonly configKey = 'antigravityQuotaWatcher'; 10 | 11 | /** 12 | * 获取完整配置 13 | */ 14 | getConfig(): Config { 15 | const config = vscode.workspace.getConfiguration(this.configKey); 16 | return { 17 | enabled: config.get('enabled', true), 18 | pollingInterval: Math.max(10, config.get('pollingInterval', 60)) * 1000, 19 | warningThreshold: config.get('warningThreshold', 50), 20 | criticalThreshold: config.get('criticalThreshold', 30), 21 | apiMethod: (config.get('apiMethod', 'GET_USER_STATUS') as Config['apiMethod']), 22 | showPromptCredits: config.get('showPromptCredits', false), 23 | showPlanName: config.get('showPlanName', false), 24 | showGeminiPro: config.get('showGeminiPro', true), 25 | showGeminiFlash: config.get('showGeminiFlash', true), 26 | displayStyle: (config.get('displayStyle', 'progressBar') as Config['displayStyle']), 27 | language: (config.get('language', 'auto') as Config['language']) 28 | }; 29 | } 30 | 31 | /** 32 | * 获取轮询间隔 33 | */ 34 | getPollingInterval(): number { 35 | return this.getConfig().pollingInterval; 36 | } 37 | 38 | /** 39 | * 获取预警阈值 40 | */ 41 | getWarningThreshold(): number { 42 | return this.getConfig().warningThreshold; 43 | } 44 | 45 | /** 46 | * 获取临界阈值 47 | */ 48 | getCriticalThreshold(): number { 49 | return this.getConfig().criticalThreshold; 50 | } 51 | 52 | /** 53 | * 获取接口选择 54 | */ 55 | getApiMethod(): Config['apiMethod'] { 56 | return this.getConfig().apiMethod; 57 | } 58 | 59 | /** 60 | * 是否启用 61 | */ 62 | isEnabled(): boolean { 63 | return this.getConfig().enabled; 64 | } 65 | 66 | /** 67 | * 监听配置变更 68 | */ 69 | onConfigChange(callback: (config: Config) => void): vscode.Disposable { 70 | return vscode.workspace.onDidChangeConfiguration(event => { 71 | if (event.affectsConfiguration(this.configKey)) { 72 | callback(this.getConfig()); 73 | } 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/portDetectionService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Port detection service 3 | * Only retrieves ports and CSRF Token from process args. 4 | */ 5 | 6 | import * as vscode from 'vscode'; 7 | import { ProcessPortDetector, AntigravityProcessInfo } from './processPortDetector'; 8 | 9 | export interface PortDetectionResult { 10 | /** HTTPS port used by Connect/CommandModelConfigs */ 11 | port: number; 12 | connectPort: number; 13 | /** HTTP port from extension_server_port (fallback) */ 14 | httpPort: number; 15 | csrfToken: string; 16 | source: 'process'; 17 | confidence: 'high'; 18 | } 19 | 20 | export class PortDetectionService { 21 | private processDetector: ProcessPortDetector; 22 | private context: vscode.ExtensionContext; 23 | 24 | constructor(context: vscode.ExtensionContext) { 25 | this.context = context; 26 | this.processDetector = new ProcessPortDetector(); 27 | } 28 | 29 | /** 30 | * Single detection method - read from process arguments. 31 | */ 32 | async detectPort(_configuredPort?: number): Promise { 33 | // Get port and CSRF Token from process args 34 | const processInfo: AntigravityProcessInfo | null = await this.processDetector.detectProcessInfo(); 35 | 36 | if (!processInfo) { 37 | console.error('[PortDetectionService] Failed to get port and CSRF Token from process.'); 38 | console.error('[PortDetectionService] Ensure language_server_windows_x64.exe is running.'); 39 | return null; 40 | } 41 | 42 | console.log(`[PortDetectionService] Detected Connect port (HTTPS): ${processInfo.connectPort}`); 43 | console.log(`[PortDetectionService] Detected extension port (HTTP): ${processInfo.extensionPort}`); 44 | console.log(`[PortDetectionService] Detected CSRF Token: ${processInfo.csrfToken.substring(0, 8)}...`); 45 | 46 | return { 47 | // keep compatibility: port is the primary connect port 48 | port: processInfo.connectPort, 49 | connectPort: processInfo.connectPort, 50 | httpPort: processInfo.extensionPort, 51 | csrfToken: processInfo.csrfToken, 52 | source: 'process', 53 | confidence: 'high' 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/platformDetector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Platform detection and strategy selection. 3 | * Provides platform-specific implementations for process detection. 4 | */ 5 | 6 | import * as vscode from 'vscode'; 7 | import { WindowsProcessDetector } from './windowsProcessDetector'; 8 | import { UnixProcessDetector } from './unixProcessDetector'; 9 | 10 | /** 11 | * Platform-specific strategy interface for process detection. 12 | */ 13 | export interface IPlatformStrategy { 14 | /** 15 | * Get the command to list processes with their command line arguments. 16 | * @param processName Name of the process to search for 17 | * @returns Shell command string 18 | */ 19 | getProcessListCommand(processName: string): string; 20 | 21 | /** 22 | * Parse the output of process list command to extract process info. 23 | * @param stdout Output from the process list command 24 | * @returns Parsed process info or null if not found 25 | */ 26 | parseProcessInfo(stdout: string): { 27 | pid: number; 28 | extensionPort: number; 29 | csrfToken: string; 30 | } | null; 31 | 32 | /** 33 | * Ensure port detection commands are available on the system. 34 | * Should check for required commands and throw an error with user-friendly message if none are available. 35 | * @throws Error if no port detection command is available 36 | */ 37 | ensurePortCommandAvailable(): Promise; 38 | 39 | /** 40 | * Get the command to list ports listened by a specific process. 41 | * @param pid Process ID 42 | * @returns Shell command string 43 | */ 44 | getPortListCommand(pid: number): string; 45 | 46 | /** 47 | * Parse the output of port list command to extract listening ports. 48 | * @param stdout Output from the port list command 49 | * @returns Array of port numbers 50 | */ 51 | parseListeningPorts(stdout: string): number[]; 52 | 53 | /** 54 | * Get platform-specific error messages. 55 | */ 56 | getErrorMessages(): { 57 | processNotFound: string; 58 | commandNotAvailable: string; 59 | requirements: string[]; 60 | }; 61 | } 62 | 63 | /** 64 | * Platform detector that selects the appropriate strategy based on the current OS. 65 | */ 66 | export class PlatformDetector { 67 | private platform: NodeJS.Platform; 68 | 69 | constructor() { 70 | this.platform = process.platform; 71 | } 72 | 73 | /** 74 | * Get the name of the language server process for the current platform. 75 | */ 76 | getProcessName(): string { 77 | switch (this.platform) { 78 | case 'win32': 79 | return 'language_server_windows_x64.exe'; 80 | case 'darwin': 81 | return 'language_server_macos'; 82 | case 'linux': 83 | return 'language_server_linux'; 84 | default: 85 | throw new Error(`Unsupported platform: ${this.platform}`); 86 | } 87 | } 88 | 89 | /** 90 | * Get the platform-specific detection strategy. 91 | */ 92 | getStrategy(): IPlatformStrategy { 93 | switch (this.platform) { 94 | case 'win32': 95 | const windowsDetector = new WindowsProcessDetector(); 96 | 97 | // 读取用户配置,检查是否强制使用 PowerShell 模式 98 | const config = vscode.workspace.getConfiguration('antigravityQuotaWatcher'); 99 | const forcePowerShell = config.get('forcePowerShell', true); 100 | 101 | // 根据配置设置模式 102 | windowsDetector.setUsePowerShell(forcePowerShell); 103 | console.log(`[PlatformDetector] Configuration: forcePowerShell=${forcePowerShell}, using ${forcePowerShell ? 'PowerShell' : 'WMIC'} mode`); 104 | 105 | return windowsDetector; 106 | case 'darwin': 107 | case 'linux': 108 | return new UnixProcessDetector(this.platform); 109 | default: 110 | throw new Error(`Unsupported platform: ${this.platform}`); 111 | } 112 | } 113 | 114 | /** 115 | * Get the current platform name for display. 116 | */ 117 | getPlatformName(): string { 118 | switch (this.platform) { 119 | case 'win32': 120 | return 'Windows'; 121 | case 'darwin': 122 | return 'macOS'; 123 | case 'linux': 124 | return 'Linux'; 125 | default: 126 | return this.platform; 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/versionInfo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Version information service for Antigravity Quota Watcher. 3 | * Provides access to IDE version, extension version, and other version-related info. 4 | */ 5 | 6 | import * as vscode from 'vscode'; 7 | import * as fs from 'fs'; 8 | import * as path from 'path'; 9 | 10 | export interface VersionInfo { 11 | /** Extension version from package.json */ 12 | extensionVersion: string; 13 | /** IDE name (e.g., "Antigravity", "Visual Studio Code") */ 14 | ideName: string; 15 | /** IDE version (e.g., "1.11.2" for Antigravity) */ 16 | ideVersion: string; 17 | /** VS Code OSS version (e.g., "1.104.0") */ 18 | vscodeOssVersion: string; 19 | /** Operating system (e.g., "windows", "darwin", "linux") */ 20 | os: string; 21 | } 22 | 23 | class VersionInfoService { 24 | private static instance: VersionInfoService; 25 | private versionInfo: VersionInfo | null = null; 26 | 27 | private constructor() { } 28 | 29 | static getInstance(): VersionInfoService { 30 | if (!VersionInfoService.instance) { 31 | VersionInfoService.instance = new VersionInfoService(); 32 | } 33 | return VersionInfoService.instance; 34 | } 35 | 36 | /** 37 | * Initialize version info with extension context. 38 | * Must be called once during extension activation. 39 | */ 40 | initialize(context: vscode.ExtensionContext): void { 41 | const extensionVersion = context.extension.packageJSON.version || 'unknown'; 42 | const ideName = vscode.env.appName || 'unknown'; 43 | const vscodeOssVersion = vscode.version || 'unknown'; 44 | 45 | // Read IDE version from product.json 46 | let ideVersion = 'unknown'; 47 | try { 48 | const productJsonPath = path.join(vscode.env.appRoot, 'product.json'); 49 | if (fs.existsSync(productJsonPath)) { 50 | const productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8')); 51 | ideVersion = productJson.ideVersion || productJson.version || 'unknown'; 52 | } 53 | } catch (e) { 54 | console.warn('[VersionInfo] Failed to read product.json:', e); 55 | } 56 | 57 | // Detect OS 58 | let os = 'unknown'; 59 | switch (process.platform) { 60 | case 'win32': 61 | os = 'windows'; 62 | break; 63 | case 'darwin': 64 | os = 'darwin'; 65 | break; 66 | case 'linux': 67 | os = 'linux'; 68 | break; 69 | default: 70 | os = process.platform; 71 | } 72 | 73 | this.versionInfo = { 74 | extensionVersion, 75 | ideName, 76 | ideVersion, 77 | vscodeOssVersion, 78 | os, 79 | }; 80 | 81 | console.log(`[VersionInfo] Initialized: ${this.getFullVersionString()}`); 82 | } 83 | 84 | /** 85 | * Get version info. Throws if not initialized. 86 | */ 87 | getVersionInfo(): VersionInfo { 88 | if (!this.versionInfo) { 89 | throw new Error('VersionInfoService not initialized. Call initialize() first.'); 90 | } 91 | return this.versionInfo; 92 | } 93 | 94 | /** 95 | * Get IDE version string (e.g., "1.11.2"). 96 | * Returns "unknown" if not initialized. 97 | */ 98 | getIdeVersion(): string { 99 | return this.versionInfo?.ideVersion || 'unknown'; 100 | } 101 | 102 | /** 103 | * Get IDE name (e.g., "Antigravity"). 104 | */ 105 | getIdeName(): string { 106 | return this.versionInfo?.ideName || 'unknown'; 107 | } 108 | 109 | /** 110 | * Get extension version string (e.g., "0.7.6"). 111 | */ 112 | getExtensionVersion(): string { 113 | return this.versionInfo?.extensionVersion || 'unknown'; 114 | } 115 | 116 | /** 117 | * Get OS string for API requests (e.g., "windows"). 118 | */ 119 | getOs(): string { 120 | return this.versionInfo?.os || 'unknown'; 121 | } 122 | 123 | /** 124 | * Get a formatted version string for logging. 125 | */ 126 | getFullVersionString(): string { 127 | const info = this.versionInfo; 128 | if (!info) { 129 | return 'VersionInfo not initialized'; 130 | } 131 | return `Extension v${info.extensionVersion} on ${info.ideName} v${info.ideVersion} (VSCode OSS v${info.vscodeOssVersion})`; 132 | } 133 | } 134 | 135 | // Export singleton instance 136 | export const versionInfo = VersionInfoService.getInstance(); 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Antigravity Quota Watcher 2 | 3 | #### Choose Your Language: 简体中文 | [English](./README.en.md) 4 | 5 | > [!NOTE] 6 | > 本插件为非官方工具,与 Antigravity 没有任何关联。 7 | > 本插件依赖于 Antigravity 语言服务器的内部实现细节,相关机制可能会随时变动。 8 | 9 | **一个在Antigravity状态栏实时显示AI模型配额剩余情况的插件。** 10 | 11 | ## 演示 12 | 13 | 14 | 15 | 19 | 23 | 27 | 28 |
16 | 状态栏显示

17 | 状态栏显示 18 |
20 | 配额详情

21 | 配额详情 22 |
24 | 配置页面

25 | 配置页面 26 |
29 | 30 | ## 系统要求 31 | 32 | ![Windows](https://img.shields.io/badge/Windows--amd64-支持-brightgreen?logo=microsoftwindows&logoColor=white) 33 | ![macOS](https://img.shields.io/badge/macOS-支持-brightgreen?logo=apple&logoColor=white) 34 | ![Linux](https://img.shields.io/badge/Linux-支持-brightgreen?logo=linux&logoColor=white) 35 | ![Windows ARM](https://img.shields.io/badge/Windows--arm64-不支持-red?logo=microsoftwindows&logoColor=white) 36 | 37 | ## 使用方法 38 | 39 | [下载插件](https://github.com/wusimpl/AntigravityQuotaWatcher/releases/latest),然后安装插件,重启 Antigravity 40 | 41 | ![Installation](https://raw.githubusercontent.com/wusimpl/AntigravityQuotaWatcher/main/images/install.png) 42 | 43 | > [!NOTE] 44 | > Linux系统平台须知:请确保系统支持以下三种命令之一:`lsof`、`netstat`、`ss`。如果没有,请安装后再重启脚本。 45 | 46 | ## 提交Issue 47 | 48 | 请在提交issue时附上日志文件或者日志截图 49 | 50 | 日志导出方法: 51 | ![步骤页面1](https://raw.githubusercontent.com/wusimpl/AntigravityQuotaWatcher/main/images/issue1.png) 52 | ![步骤页面2](https://raw.githubusercontent.com/wusimpl/AntigravityQuotaWatcher/main/images/issue2.png) 53 | 54 | 55 | ## 功能特点 56 | 57 | - **实时监控**:自动检测并定时轮询配额使用情况 58 | - **状态栏显示**:在 VS Code 底部状态栏显示当前配额 59 | - **智能预警**:配额不足时自动变色提醒 60 | - **自动检测**:无需手动配置,自动检测 Antigravity 服务端口和认证信息 61 | 62 | ## 配置选项 63 | 64 | 打开 VS Code 设置(`文件` > `首选项` > `设置`),搜索 `Antigravity Quota Watcher`: 65 | 66 | ### 启用自动监控 67 | - **默认值**:`true` 68 | - **说明**:是否启用配额监控 69 | 70 | ### 轮询间隔 71 | - **默认值**:`60`(秒) 72 | - **说明**:配额数据刷新频率,建议设置为 30-60 秒 73 | 74 | ### 警告阈值 75 | - **默认值**:`50`(百分比) 76 | - **说明**:配额低于此百分比时状态栏显示黄色警告符号(🟡) 77 | 78 | ### 临界阈值 79 | - **默认值**:`30`(百分比) 80 | - **说明**:配额低于此百分比时状态栏显示红色错误符号(🔴) 81 | 82 | ### 状态栏显示样式 83 | - **默认值**:`progressBar` 84 | - **选项**: 85 | - `progressBar`:显示进度条( `████░░░░`) 86 | - `percentage`:显示百分比( `80%`) 87 | - `dots`:显示圆点( `●●●○○`) 88 | - **说明**:选择状态栏的显示风格 89 | 90 | ### API 方法选择 91 | - **说明**: 92 | - `GET_USER_STATUS`:获取完整配额信息(默认方法) 93 | - `COMMAND_MODEL_CONFIG`:兼容模式,信息量较少 94 | 95 | ### PowerShell 模式(仅 Windows 系统可用) 96 | - **默认值**:`true`,如果false,则使用wmic检测进程 97 | - **说明**:使用 PowerShell 模式检测进程 98 | - **适用场景**:如果在 Windows 系统上遇到端口检测错误,可以尝试切换此选项。插件重启生效。 99 | 100 | ### 显示 Gemini Pro (G Pro) 额度 101 | - **默认值**:`true` 102 | - **说明**:是否在状态栏显示 Gemini Pro 的额度信息 103 | 104 | ### 显示 Gemini Flash (G Flash) 额度 105 | - **默认值**:`true` 106 | - **说明**:是否在状态栏显示 Gemini Flash 的额度信息 107 | 108 | ### 语言设置 109 | - **默认值**:`auto` 110 | - **选项**: 111 | - `auto`:自动跟随 VS Code 语言设置 112 | - `en`:英语 113 | - `zh-cn`:简体中文 114 | - **说明**:设置状态栏语言,默认自动跟随 VS Code 语言 115 | > 如果要更改配置设置页面的显示语言,需要将antigravity的语言设置为中文 116 | 117 | 118 | ### 命令面板 119 | 120 | 按 `Ctrl+Shift+P`(Windows)或 `Cmd+Shift+P`(Mac)打开命令面板,输入以下命令: 121 | 122 | - **Antigravity: 刷新配额** - 手动刷新配额数据 123 | - **Antigravity: 重新检测端口** - 重新检测 Antigravity 服务端口 124 | 125 | 126 | ## 状态栏说明 127 | 128 | 状态栏显示格式: 129 | 130 | ### 1. 进度条模式 131 | 显示格式:`🟢 Pro-L ████████ | 🔴 Claude ██░░░░░░` 132 | 直观展示剩余配额的比例。 133 | 134 | ### 2. 百分比模式(默认) 135 | 显示格式:`🟢 Pro-L: 80% | 🔴 Claude: 25%` 136 | 直接显示剩余配额的百分比数值。 137 | 138 | ### 3. 圆点模式 139 | 显示格式:`🟢 Pro-L ●●●●○ | 🔴 Claude ●●○○○` 140 | 使用圆点直观表示剩余配额比例,更加简洁美观。 141 | 142 | ### 状态指示符号 143 | 144 | 每个模型前的圆点符号表示当前配额状态: 145 | 146 | - **🟢 绿色**:剩余配额 ≥ 50%(充足) 147 | - **🟡 黄色**:剩余配额 30%-50%(中等) 148 | - **🔴 红色**:剩余配额 < 30%(不足) 149 | - **⚫ 黑色**:配额已耗尽(0%) 150 | 151 | 您可以在设置中自定义 `warningThreshold`(警告阈值)和 `criticalThreshold`(临界阈值)来调整状态符号的显示级别。 152 | 153 | ### 模型配额详情 154 | 155 | 鼠标移动到状态栏会显示所有模型的剩余配额与下次重置时间。点击状态栏可以立即刷新配额信息。 156 | 157 | ## 注意事项 158 | 159 | - 首次启动会延迟 8 秒开始监控,避免频繁请求 160 | - 如果状态栏显示错误,可使用"重新检测端口"命令修复 161 | - **Windows 用户**:如果遇到端口检测错误,可以在设置中切换 `forcePowerShell` 选项。 162 | 163 | [![Star History Chart](https://api.star-history.com/svg?repos=wusimpl/AntigravityQuotaWatcher&type=date&legend=top-left)](https://www.star-history.com/#wusimpl/AntigravityQuotaWatcher&type=date&legend=top-left) 164 | 165 | ## 项目使用约定 166 | 167 | 本项目基于 MIT 协议开源,使用此项目时请遵守开源协议。 168 | 除此外,希望你在使用代码时已经了解以下额外说明: 169 | 170 | 1. 打包、二次分发 **请保留代码出处**:[https://github.com/wusimpl/AntigravityQuotaWatcher](https://github.com/wusimpl/AntigravityQuotaWatcher) 171 | 2. 请不要用于商业用途,合法合规使用代码 172 | 3. 如果开源协议变更,将在此 Github 仓库更新,不另行通知。 173 | 174 | ## 许可证 175 | 176 | MIT License 177 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "antigravity-quota-watcher", 3 | "displayName": "Antigravity Quota Watcher", 4 | "description": "实时监控 Antigravity的模型使用配额", 5 | "version": "0.8.0", 6 | "publisher": "antigravity-tools", 7 | "icon": "icon.png", 8 | "engines": { 9 | "vscode": "^1.85.0" 10 | }, 11 | "categories": [ 12 | "Other" 13 | ], 14 | "activationEvents": [ 15 | "onStartupFinished" 16 | ], 17 | "main": "./out/extension.js", 18 | "contributes": { 19 | "commands": [ 20 | { 21 | "command": "antigravity-quota-watcher.showQuota", 22 | "title": "%command.showQuota%" 23 | }, 24 | { 25 | "command": "antigravity-quota-watcher.refreshQuota", 26 | "title": "%command.refreshQuota%" 27 | }, 28 | { 29 | "command": "antigravity-quota-watcher.detectPort", 30 | "title": "%command.detectPort%" 31 | }, 32 | { 33 | "command": "antigravity-quota-watcher.dev.previewNotifications", 34 | "title": "Antigravity Dev: 预览通知弹窗" 35 | }, 36 | { 37 | "command": "antigravity-quota-watcher.dev.previewStatusBar", 38 | "title": "Antigravity Dev: 预览状态栏文本" 39 | }, 40 | { 41 | "command": "antigravity-quota-watcher.dev.previewTooltip", 42 | "title": "Antigravity Dev: 预览 Tooltip" 43 | } 44 | ], 45 | "configuration": { 46 | "title": "Antigravity Quota Watcher", 47 | "properties": { 48 | "antigravityQuotaWatcher.enabled": { 49 | "type": "boolean", 50 | "default": true, 51 | "description": "%config.enabled%", 52 | "order": 10 53 | }, 54 | "antigravityQuotaWatcher.pollingInterval": { 55 | "type": "number", 56 | "default": 60, 57 | "description": "%config.pollingInterval%", 58 | "order": 20 59 | }, 60 | "antigravityQuotaWatcher.displayStyle": { 61 | "type": "string", 62 | "default": "percentage", 63 | "enum": [ 64 | "percentage", 65 | "progressBar", 66 | "dots" 67 | ], 68 | "enumDescriptions": [ 69 | "Show percentage / 显示百分比 (e.g. 🟢 Claude: 85%)", 70 | "Show progress bar / 显示方块进度条 (e.g. 🟢 Claude ███████░)", 71 | "Show dots / 显示圆点进度条 (e.g. 🟢 Claude ●●●●○)" 72 | ], 73 | "description": "%config.displayStyle%", 74 | "order": 30 75 | }, 76 | "antigravityQuotaWatcher.apiMethod": { 77 | "type": "string", 78 | "default": "GET_USER_STATUS", 79 | "enum": [ 80 | "GET_USER_STATUS", 81 | "COMMAND_MODEL_CONFIG" 82 | ], 83 | "description": "%config.apiMethod%", 84 | "order": 40 85 | }, 86 | "antigravityQuotaWatcher.warningThreshold": { 87 | "type": "number", 88 | "default": 50, 89 | "description": "%config.warningThreshold%", 90 | "order": 50 91 | }, 92 | "antigravityQuotaWatcher.criticalThreshold": { 93 | "type": "number", 94 | "default": 30, 95 | "description": "%config.criticalThreshold%", 96 | "order": 60 97 | }, 98 | "antigravityQuotaWatcher.showPromptCredits": { 99 | "type": "boolean", 100 | "default": false, 101 | "description": "%config.showPromptCredits%", 102 | "order": 70 103 | }, 104 | "antigravityQuotaWatcher.showPlanName": { 105 | "type": "boolean", 106 | "default": false, 107 | "description": "%config.showPlanName%", 108 | "order": 72 109 | }, 110 | "antigravityQuotaWatcher.showGeminiPro": { 111 | "type": "boolean", 112 | "default": true, 113 | "description": "%config.showGeminiPro%", 114 | "order": 73 115 | }, 116 | "antigravityQuotaWatcher.showGeminiFlash": { 117 | "type": "boolean", 118 | "default": true, 119 | "description": "%config.showGeminiFlash%", 120 | "order": 74 121 | }, 122 | "antigravityQuotaWatcher.forcePowerShell": { 123 | "type": "boolean", 124 | "default": true, 125 | "description": "%config.forcePowerShell%", 126 | "order": 75 127 | }, 128 | "antigravityQuotaWatcher.language": { 129 | "type": "string", 130 | "default": "auto", 131 | "enum": [ 132 | "auto", 133 | "en", 134 | "zh-cn" 135 | ], 136 | "enumDescriptions": [ 137 | "Auto (Based on VS Code language) / 自动 (跟随 VS Code 语言)", 138 | "English", 139 | "Chinese (Simplified) / 简体中文" 140 | ], 141 | "description": "%config.language%", 142 | "order": 80 143 | } 144 | } 145 | } 146 | }, 147 | "scripts": { 148 | "vscode:prepublish": "npm run compile", 149 | "compile": "tsc -p ./", 150 | "watch": "tsc -watch -p ./", 151 | "pretest": "npm run compile && npm run lint", 152 | "lint": "eslint src --ext ts", 153 | "test": "node ./out/test/runTest.js", 154 | "package": "npx vsce package" 155 | }, 156 | "devDependencies": { 157 | "@types/vscode": "^1.85.0", 158 | "@types/node": "^20.0.0", 159 | "@typescript-eslint/eslint-plugin": "^6.0.0", 160 | "@typescript-eslint/parser": "^6.0.0", 161 | "eslint": "^8.0.0", 162 | "typescript": "^5.3.0" 163 | }, 164 | "dependencies": { 165 | "https": "^1.0.0" 166 | }, 167 | "repository": { 168 | "type": "git", 169 | "url": "https://github.com/wusimpl/AntigravityQuotaWatcher.git" 170 | }, 171 | "keywords": [ 172 | "antigravity", 173 | "quota", 174 | "monitor", 175 | "ai" 176 | ], 177 | "author": "", 178 | "license": "MIT" 179 | } -------------------------------------------------------------------------------- /src/devTools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 开发工具 - 用于预览和测试 UI 元素 3 | * 仅在开发模式下使用 4 | */ 5 | 6 | import * as vscode from 'vscode'; 7 | import { LocalizationService } from './i18n/localizationService'; 8 | import { TranslationKey } from './i18n/types'; 9 | 10 | /** 11 | * 注册开发预览命令 12 | * 仅在开发/测试模式下注册,生产环境跳过 13 | */ 14 | export function registerDevCommands(context: vscode.ExtensionContext) { 15 | // 生产环境不注册开发命令 16 | if (context.extensionMode === vscode.ExtensionMode.Production) { 17 | return; 18 | } 19 | 20 | console.log('[DevTools] Registering dev commands (non-production mode)'); 21 | const locService = LocalizationService.getInstance(); 22 | 23 | // 命令:预览所有通知弹窗 24 | const previewNotificationsCommand = vscode.commands.registerCommand( 25 | 'antigravity-quota-watcher.dev.previewNotifications', 26 | async () => { 27 | const notifyKeys: { key: TranslationKey; type: 'info' | 'warning' | 'error' }[] = [ 28 | { key: 'notify.unableToDetectProcess', type: 'warning' }, 29 | { key: 'notify.refreshingQuota', type: 'info' }, 30 | { key: 'notify.detectionSuccess', type: 'info' }, 31 | { key: 'notify.unableToDetectPort', type: 'error' }, 32 | { key: 'notify.portDetectionFailed', type: 'error' }, 33 | { key: 'notify.configUpdated', type: 'info' }, 34 | { key: 'notify.portCommandRequired', type: 'error' }, 35 | ]; 36 | 37 | // 构建 QuickPick 选项 38 | const items: vscode.QuickPickItem[] = [ 39 | { label: '$(play-all) 播放全部通知', description: '依次显示所有通知' }, 40 | { label: '', kind: vscode.QuickPickItemKind.Separator }, 41 | ...notifyKeys.map(n => ({ 42 | label: getTypeIcon(n.type) + ' ' + n.key, 43 | description: locService.t(n.key, { port: '12345', error: '示例错误' }).substring(0, 50) 44 | })) 45 | ]; 46 | 47 | const selected = await vscode.window.showQuickPick(items, { 48 | title: '🔧 开发工具:预览通知弹窗', 49 | placeHolder: '选择要预览的通知,或播放全部' 50 | }); 51 | 52 | if (!selected) return; 53 | 54 | if (selected.label.includes('播放全部')) { 55 | // 依次显示所有通知 56 | for (const n of notifyKeys) { 57 | const msg = locService.t(n.key, { port: '12345', error: '示例错误' }); 58 | const choice = await showNotification(n.type, `[${n.key}]\n${msg}`, ['下一个', '停止']); 59 | if (choice === '停止') break; 60 | } 61 | vscode.window.showInformationMessage('✅ 通知预览完成'); 62 | } else { 63 | // 显示单个通知 64 | const keyMatch = selected.label.match(/notify\.\w+/); 65 | if (keyMatch) { 66 | const key = keyMatch[0] as TranslationKey; 67 | const notifyItem = notifyKeys.find(n => n.key === key); 68 | if (notifyItem) { 69 | const msg = locService.t(notifyItem.key, { port: '12345', error: '示例错误' }); 70 | await showNotification(notifyItem.type, `[${key}]\n${msg}`); 71 | } 72 | } 73 | } 74 | } 75 | ); 76 | 77 | // 命令:预览状态栏文本 78 | const previewStatusBarCommand = vscode.commands.registerCommand( 79 | 'antigravity-quota-watcher.dev.previewStatusBar', 80 | async () => { 81 | const statusKeys: TranslationKey[] = [ 82 | 'status.initializing', 83 | 'status.detecting', 84 | 'status.fetching', 85 | 'status.retrying', 86 | 'status.error', 87 | 'status.refreshing', 88 | ]; 89 | 90 | const items: vscode.QuickPickItem[] = statusKeys.map(key => ({ 91 | label: key, 92 | description: locService.t(key, { current: '1', max: '3' }) 93 | })); 94 | 95 | await vscode.window.showQuickPick(items, { 96 | title: '🔧 开发工具:状态栏文本预览', 97 | placeHolder: '查看状态栏文本(仅预览,不会修改实际状态栏)' 98 | }); 99 | } 100 | ); 101 | 102 | // 命令:预览 Tooltip 内容 103 | const previewTooltipCommand = vscode.commands.registerCommand( 104 | 'antigravity-quota-watcher.dev.previewTooltip', 105 | async () => { 106 | const tooltipKeys: TranslationKey[] = [ 107 | 'tooltip.title', 108 | 'tooltip.credits', 109 | 'tooltip.available', 110 | 'tooltip.remaining', 111 | 'tooltip.depleted', 112 | 'tooltip.resetTime', 113 | 'tooltip.model', 114 | 'tooltip.status', 115 | 'tooltip.error', 116 | 'tooltip.clickToRetry', 117 | ]; 118 | 119 | // 构建完整的 tooltip 预览 120 | let tooltipPreview = '=== Tooltip 内容预览 ===\n\n'; 121 | for (const key of tooltipKeys) { 122 | tooltipPreview += `${key}:\n ${locService.t(key)}\n\n`; 123 | } 124 | 125 | // 用 OutputChannel 显示完整预览 126 | const channel = vscode.window.createOutputChannel('Antigravity Dev Preview'); 127 | channel.clear(); 128 | channel.appendLine(tooltipPreview); 129 | channel.show(); 130 | } 131 | ); 132 | 133 | context.subscriptions.push( 134 | previewNotificationsCommand, 135 | previewStatusBarCommand, 136 | previewTooltipCommand 137 | ); 138 | } 139 | 140 | function getTypeIcon(type: 'info' | 'warning' | 'error'): string { 141 | switch (type) { 142 | case 'info': return '$(info)'; 143 | case 'warning': return '$(warning)'; 144 | case 'error': return '$(error)'; 145 | } 146 | } 147 | 148 | async function showNotification( 149 | type: 'info' | 'warning' | 'error', 150 | message: string, 151 | buttons?: string[] 152 | ): Promise { 153 | switch (type) { 154 | case 'info': 155 | return vscode.window.showInformationMessage(message, ...(buttons || [])); 156 | case 'warning': 157 | return vscode.window.showWarningMessage(message, ...(buttons || [])); 158 | case 'error': 159 | return vscode.window.showErrorMessage(message, ...(buttons || [])); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # Antigravity Quota Watcher 2 | 3 | > [!NOTE] 4 | > This plugin is an unofficial tool and has no affiliation with Antigravity. 5 | > This plugin relies on internal implementation details of the Antigravity language server, which may change at any time. 6 | 7 | 8 | **A plugin that displays AI model quota status in real-time in the Antigravity status bar.** 9 | 10 | ## Demo 11 | 12 | 13 | 14 | 18 | 22 | 26 | 27 |
15 | Status Bar Display

16 | Status Bar Display 17 |
19 | Quota Details

20 | Quota Details 21 |
23 | Config Page

24 | Config Page 25 |
28 | 29 | ## System Requirements 30 | 31 | ![Windows](https://img.shields.io/badge/Windows--amd64-supported-brightgreen?logo=microsoftwindows&logoColor=white) 32 | ![macOS](https://img.shields.io/badge/macOS-supported-brightgreen?logo=apple&logoColor=white) 33 | ![Linux](https://img.shields.io/badge/Linux-supported-brightgreen?logo=linux&logoColor=white) 34 | ![Windows ARM](https://img.shields.io/badge/Windows--arm64-not%20supported-red?logo=microsoftwindows&logoColor=white) 35 | 36 | ## Installation 37 | 38 | [Download the extension](https://github.com/wusimpl/AntigravityQuotaWatcher/releases/latest), install it, and restart Antigravity. 39 | 40 | ![Installation](https://raw.githubusercontent.com/wusimpl/AntigravityQuotaWatcher/main/images/install.png) 41 | 42 | 43 | > [!NOTE] 44 | > For Linux Distribution System, please make sure it supports one of these commands:`lsof`、`netstat`、`ss`. 45 | 46 | ## Submitting Issues 47 | 48 | Please attach log files or log screenshots when submitting issues. 49 | 50 | How to export logs: 51 | ![Step 1](https://raw.githubusercontent.com/wusimpl/AntigravityQuotaWatcher/main/images/issue1.png) 52 | ![Step 2](https://raw.githubusercontent.com/wusimpl/AntigravityQuotaWatcher/main/images/issue2.png) 53 | 54 | 55 | ## Features 56 | 57 | - **Real-time Monitoring**: Automatically detects and polls quota usage at regular intervals 58 | - **Status Bar Display**: Shows current quota in the VS Code bottom status bar 59 | - **Smart Alerts**: Automatically changes color when quota is low 60 | - **Auto Detection**: No manual configuration needed, automatically detects Antigravity service port and authentication information 61 | 62 | ## Configuration Options 63 | 64 | Open VS Code settings (`File` > `Preferences` > `Settings`), and search for `Antigravity Quota Watcher`: 65 | 66 | ### Enable Auto Monitoring 67 | - **Default**: `true` 68 | - **Description**: Whether to enable quota monitoring 69 | 70 | ### Polling Interval 71 | - **Default**: `60` (seconds) 72 | - **Description**: Quota data refresh frequency, recommended to set between 30-60 seconds 73 | 74 | ### Warning Threshold 75 | - **Default**: `50` (percentage) 76 | - **Description**: When quota falls below this percentage, the status bar displays a yellow warning symbol (🟡) 77 | 78 | ### Critical Threshold 79 | - **Default**: `30` (percentage) 80 | - **Description**: When quota falls below this percentage, the status bar displays a red error symbol (🔴) 81 | 82 | ### Status Bar Display Style 83 | - **Default**: `progressBar` 84 | - **Options**: 85 | - `progressBar`: Display progress bar (`████░░░░`) 86 | - `percentage`: Display percentage (`80%`) 87 | - `dots`: Display dots (`●●●○○`) 88 | - **Description**: Choose the status bar display style 89 | 90 | ### API Method Selection 91 | - **Description**: 92 | - `GET_USER_STATUS`: Get complete quota information (default method) 93 | - `COMMAND_MODEL_CONFIG`: Compatibility mode, less information 94 | 95 | ### PowerShell Mode (Windows only) 96 | - **Default**: `true`, if false, uses wmic to detect processes 97 | - **Description**: Use PowerShell mode to detect processes 98 | - **Use Case**: If you encounter port detection errors on Windows, try toggling this option. Requires plugin restart to take effect. 99 | 100 | ### Show Gemini Pro (G Pro) Quota 101 | - **Default**: `true` 102 | - **Description**: Whether to display Gemini Pro quota in the status bar 103 | 104 | ### Show Gemini Flash (G Flash) Quota 105 | - **Default**: `true` 106 | - **Description**: Whether to display Gemini Flash quota in the status bar 107 | 108 | ### Language Settings 109 | - **Default**: `auto` 110 | - **Options**: 111 | - `auto`: Automatically follow VS Code language settings 112 | - `en`: English 113 | - `zh-cn`: Simplified Chinese 114 | - **Description**: Set status bar language, defaults to automatically follow VS Code language 115 | > To change the configuration settings page display language, you need to set Antigravity's language to Chinese 116 | 117 | 118 | ### Command Palette 119 | 120 | Press `Ctrl+Shift+P` (Windows) or `Cmd+Shift+P` (Mac) to open the command palette, and enter the following commands: 121 | 122 | - **Antigravity: Refresh Quota** - Manually refresh quota data 123 | - **Antigravity: Re-detect Port** - Re-detect Antigravity service port 124 | 125 | 126 | ## Status Bar Explanation 127 | 128 | Status bar display format: 129 | 130 | ### 1. Progress Bar Mode 131 | Display format: `🟢 Pro-L ████████ | 🔴 Claude ██░░░░░░` 132 | Visually shows the proportion of remaining quota. 133 | 134 | ### 2. Percentage Mode (Default) 135 | Display format: `🟢 Pro-L: 80% | 🔴 Claude: 25%` 136 | Directly displays the percentage value of remaining quota. 137 | 138 | ### 3. Dots Mode 139 | Display format: `🟢 Pro-L ●●●●○ | 🔴 Claude ●●○○○` 140 | Uses dots to visually represent remaining quota proportion, more concise and elegant. 141 | 142 | ### Status Indicator Symbols 143 | 144 | The dot symbol before each model indicates the current quota status: 145 | 146 | - **🟢 Green**: Remaining quota ≥ 50% (sufficient) 147 | - **🟡 Yellow**: Remaining quota 30%-50% (moderate) 148 | - **🔴 Red**: Remaining quota < 30% (insufficient) 149 | - **⚫ Black**: Quota exhausted (0%) 150 | 151 | You can customize `warningThreshold` and `criticalThreshold` in settings to adjust the display level of status symbols. 152 | 153 | ### Model Quota Details 154 | 155 | Hover over the status bar to see remaining quota and next reset time for all models. Click the status bar to immediately refresh quota information. 156 | 157 | ## Notes 158 | 159 | - First startup will delay 8 seconds before starting monitoring to avoid frequent requests 160 | - If the status bar shows an error, use the "Re-detect Port" command to fix it 161 | - **Windows Users**: If you encounter port detection errors, you can toggle the `forcePowerShell` option in settings. 162 | 163 | 164 | [![Star History Chart](https://api.star-history.com/svg?repos=wusimpl/AntigravityQuotaWatcher&type=Date)](https://star-history.com/#wusimpl/AntigravityQuotaWatcher&Date) 165 | 166 | ## Usage Agreement 167 | 168 | This project is open-sourced under the MIT License. Please comply with the open-source license when using this project. 169 | In addition, we hope you are aware of the following additional notes when using the code: 170 | 171 | 1. When packaging or redistributing, **please retain the source attribution**: [https://github.com/wusimpl/AntigravityQuotaWatcher](https://github.com/wusimpl/AntigravityQuotaWatcher) 172 | 2. Please do not use for commercial purposes. Use the code legally and compliantly. 173 | 3. If the open-source license changes, it will be updated in this GitHub repository without separate notice. 174 | 175 | ## License 176 | 177 | MIT License 178 | -------------------------------------------------------------------------------- /src/windowsProcessDetector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Windows-specific process detection implementation. 3 | * Uses wmic (fallback to PowerShell if unavailable) and netstat commands. 4 | */ 5 | 6 | import { IPlatformStrategy } from './platformDetector'; 7 | 8 | export class WindowsProcessDetector implements IPlatformStrategy { 9 | private usePowerShell: boolean = true; 10 | 11 | /** 12 | * 设置是否使用 PowerShell 模式 13 | * 当 WMIC 不可用时(Windows 10 21H1+ / Windows 11),自动降级到 PowerShell 14 | */ 15 | setUsePowerShell(value: boolean): void { 16 | this.usePowerShell = value; 17 | } 18 | 19 | /** 20 | * 获取是否使用 PowerShell 模式 21 | */ 22 | isUsingPowerShell(): boolean { 23 | return this.usePowerShell; 24 | } 25 | 26 | /** 27 | * Get command to list Windows processes. 28 | * 优先使用 wmic,如果不可用则使用 PowerShell 29 | */ 30 | getProcessListCommand(processName: string): string { 31 | if (this.usePowerShell) { 32 | // PowerShell 命令:使用 Get-CimInstance 获取进程信息并输出 JSON 33 | return `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='${processName}'\\" | Select-Object ProcessId,CommandLine | ConvertTo-Json"`; 34 | } else { 35 | // WMIC 命令(传统方式) 36 | return `wmic process where "name='${processName}'" get ProcessId,CommandLine /format:list`; 37 | } 38 | } 39 | 40 | /** 41 | * 判断命令行是否属于 Antigravity 进程 42 | * 通过 --app_data_dir antigravity 或路径中包含 antigravity 来识别 43 | */ 44 | private isAntigravityProcess(commandLine: string): boolean { 45 | const lowerCmd = commandLine.toLowerCase(); 46 | // 检查 --app_data_dir antigravity 参数 47 | if (/--app_data_dir\s+antigravity\b/i.test(commandLine)) { 48 | return true; 49 | } 50 | // 检查路径中是否包含 antigravity 51 | if (lowerCmd.includes('\\antigravity\\') || lowerCmd.includes('/antigravity/')) { 52 | return true; 53 | } 54 | return false; 55 | } 56 | 57 | /** 58 | * Parse process output to extract process information. 59 | * 支持 WMIC 和 PowerShell 两种输出格式 60 | * 61 | * WMIC 格式: 62 | * CommandLine=...--extension_server_port=1234 --csrf_token=abc123... 63 | * ProcessId=5678 64 | * 65 | * PowerShell JSON 格式: 66 | * {"ProcessId":5678,"CommandLine":"...--extension_server_port=1234 --csrf_token=abc123..."} 67 | * 或数组: [{"ProcessId":5678,"CommandLine":"..."}] 68 | */ 69 | parseProcessInfo(stdout: string): { 70 | pid: number; 71 | extensionPort: number; 72 | csrfToken: string; 73 | } | null { 74 | // 尝试解析 PowerShell JSON 输出 75 | if (this.usePowerShell || stdout.trim().startsWith('{') || stdout.trim().startsWith('[')) { 76 | try { 77 | let data = JSON.parse(stdout.trim()); 78 | // 如果是数组,筛选出 Antigravity 进程 79 | if (Array.isArray(data)) { 80 | if (data.length === 0) { 81 | return null; 82 | } 83 | const totalCount = data.length; 84 | // 过滤出 Antigravity 进程 85 | const antigravityProcesses = data.filter((item: any) => 86 | item.CommandLine && this.isAntigravityProcess(item.CommandLine) 87 | ); 88 | console.log(`[WindowsProcessDetector] Found ${totalCount} language_server process(es), ${antigravityProcesses.length} belong to Antigravity`); 89 | if (antigravityProcesses.length === 0) { 90 | console.log('[WindowsProcessDetector] No Antigravity process found, skipping non-Antigravity processes'); 91 | return null; 92 | } 93 | if (totalCount > 1) { 94 | console.log(`[WindowsProcessDetector] Selected Antigravity process PID: ${antigravityProcesses[0].ProcessId}`); 95 | } 96 | data = antigravityProcesses[0]; 97 | } else { 98 | // 单个对象时也要检查是否是 Antigravity 进程 99 | if (!data.CommandLine || !this.isAntigravityProcess(data.CommandLine)) { 100 | console.log('[WindowsProcessDetector] Single process found but not Antigravity, skipping'); 101 | return null; 102 | } 103 | console.log(`[WindowsProcessDetector] Found 1 Antigravity process, PID: ${data.ProcessId}`); 104 | } 105 | 106 | const commandLine = data.CommandLine || ''; 107 | const pid = data.ProcessId; 108 | 109 | if (!pid) { 110 | return null; 111 | } 112 | 113 | const portMatch = commandLine.match(/--extension_server_port[=\s]+(\d+)/); 114 | const tokenMatch = commandLine.match(/--csrf_token[=\s]+([a-f0-9\-]+)/i); 115 | 116 | if (!tokenMatch || !tokenMatch[1]) { 117 | return null; 118 | } 119 | 120 | const extensionPort = portMatch && portMatch[1] ? parseInt(portMatch[1], 10) : 0; 121 | const csrfToken = tokenMatch[1]; 122 | 123 | return { pid, extensionPort, csrfToken }; 124 | } catch (e) { 125 | // JSON 解析失败,继续尝试 WMIC 格式 126 | } 127 | } 128 | 129 | // 解析 WMIC 输出格式 130 | // WMIC 输出格式为多个进程块,每个块包含 CommandLine= 和 ProcessId= 行 131 | // 需要按进程分组处理,避免混淆不同进程的参数 132 | const blocks = stdout.split(/\n\s*\n/).filter(block => block.trim().length > 0); 133 | 134 | const candidates: Array<{ pid: number; extensionPort: number; csrfToken: string }> = []; 135 | 136 | for (const block of blocks) { 137 | const pidMatch = block.match(/ProcessId=(\d+)/); 138 | const commandLineMatch = block.match(/CommandLine=(.+)/); 139 | 140 | if (!pidMatch || !commandLineMatch) { 141 | continue; 142 | } 143 | 144 | const commandLine = commandLineMatch[1].trim(); 145 | 146 | // 检查是否是 Antigravity 进程 147 | if (!this.isAntigravityProcess(commandLine)) { 148 | continue; 149 | } 150 | 151 | const portMatch = commandLine.match(/--extension_server_port[=\s]+(\d+)/); 152 | const tokenMatch = commandLine.match(/--csrf_token[=\s]+([a-f0-9\-]+)/i); 153 | 154 | if (!tokenMatch || !tokenMatch[1]) { 155 | continue; 156 | } 157 | 158 | const pid = parseInt(pidMatch[1], 10); 159 | const extensionPort = portMatch && portMatch[1] ? parseInt(portMatch[1], 10) : 0; 160 | const csrfToken = tokenMatch[1]; 161 | 162 | candidates.push({ pid, extensionPort, csrfToken }); 163 | } 164 | 165 | if (candidates.length === 0) { 166 | console.log('[WindowsProcessDetector] WMIC: No Antigravity process found'); 167 | return null; 168 | } 169 | 170 | console.log(`[WindowsProcessDetector] WMIC: Found ${candidates.length} Antigravity process(es), using PID: ${candidates[0].pid}`); 171 | return candidates[0]; 172 | } 173 | 174 | /** 175 | * Ensure port detection commands are available. 176 | * On Windows, netstat is always available as a system command. 177 | */ 178 | async ensurePortCommandAvailable(): Promise { 179 | // netstat is a built-in Windows command, always available 180 | return; 181 | } 182 | 183 | /** 184 | * Get command to list ports for a specific process using netstat. 185 | */ 186 | getPortListCommand(pid: number): string { 187 | return `netstat -ano | findstr "${pid}" | findstr "LISTENING"`; 188 | } 189 | 190 | /** 191 | * Parse netstat output to extract listening ports. 192 | * Expected formats: 193 | * TCP 127.0.0.1:2873 0.0.0.0:0 LISTENING 4412 194 | * TCP 0.0.0.0:2873 0.0.0.0:0 LISTENING 4412 195 | * TCP [::1]:2873 [::]:0 LISTENING 4412 196 | * TCP [::]:2873 [::]:0 LISTENING 4412 197 | * TCP 127.0.0.1:2873 *:* LISTENING 4412 198 | */ 199 | parseListeningPorts(stdout: string): number[] { 200 | // Match IPv4: 127.0.0.1:port, 0.0.0.0:port 201 | // Match IPv6: [::1]:port, [::]:port 202 | // Foreign address can be: 0.0.0.0:0, *:*, [::]:0, etc. 203 | const portRegex = /(?:127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d+)\s+\S+\s+LISTENING/gi; 204 | const ports: number[] = []; 205 | let match; 206 | 207 | while ((match = portRegex.exec(stdout)) !== null) { 208 | const port = parseInt(match[1], 10); 209 | if (!ports.includes(port)) { 210 | ports.push(port); 211 | } 212 | } 213 | 214 | return ports.sort((a, b) => a - b); 215 | } 216 | 217 | /** 218 | * Get Windows-specific error messages. 219 | */ 220 | getErrorMessages(): { 221 | processNotFound: string; 222 | commandNotAvailable: string; 223 | requirements: string[]; 224 | } { 225 | return { 226 | processNotFound: 'language_server process not found', 227 | commandNotAvailable: this.usePowerShell 228 | ? 'PowerShell command failed; please check system permissions' 229 | : 'wmic/PowerShell command unavailable; please check the system environment', 230 | requirements: [ 231 | 'Antigravity is running', 232 | 'language_server_windows_x64.exe process is running', 233 | this.usePowerShell 234 | ? 'The system has permission to run PowerShell and netstat commands' 235 | : 'The system has permission to run wmic/PowerShell and netstat commands (auto-fallback supported)' 236 | ] 237 | }; 238 | } 239 | } 240 | 241 | -------------------------------------------------------------------------------- /src/unixProcessDetector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unix-based (macOS/Linux) process detection implementation. 3 | * Uses ps and lsof/ss/netstat commands. 4 | */ 5 | 6 | declare const process: any; 7 | import { exec } from 'child_process'; 8 | import { promisify } from 'util'; 9 | import * as vscode from 'vscode'; 10 | import { IPlatformStrategy } from './platformDetector'; 11 | import { LocalizationService } from './i18n/localizationService'; 12 | 13 | const execAsync = promisify(exec); 14 | 15 | export class UnixProcessDetector implements IPlatformStrategy { 16 | private platform: NodeJS.Platform; 17 | /** Available port detection command: 'lsof', 'ss', or 'netstat' */ 18 | private availablePortCommand: 'lsof' | 'ss' | 'netstat' | null = null; 19 | 20 | constructor(platform: NodeJS.Platform) { 21 | this.platform = platform; 22 | } 23 | 24 | /** 25 | * Check if a command exists on the system using 'which'. 26 | */ 27 | private async commandExists(command: string): Promise { 28 | try { 29 | await execAsync(`which ${command}`, { timeout: 3000 }); 30 | return true; 31 | } catch { 32 | return false; 33 | } 34 | } 35 | 36 | /** 37 | * Ensure at least one port detection command is available. 38 | * Checks lsof, ss, netstat in order of preference. 39 | * @throws Error if no command is available 40 | */ 41 | async ensurePortCommandAvailable(): Promise { 42 | // Already checked 43 | if (this.availablePortCommand) { 44 | return; 45 | } 46 | 47 | const commands = ['lsof', 'ss', 'netstat'] as const; 48 | const available: string[] = []; 49 | 50 | for (const cmd of commands) { 51 | if (await this.commandExists(cmd)) { 52 | available.push(cmd); 53 | if (!this.availablePortCommand) { 54 | this.availablePortCommand = cmd; 55 | } 56 | } 57 | } 58 | 59 | console.log(`[UnixProcessDetector] Port command check: available=[${available.join(', ') || 'none'}], using=${this.availablePortCommand || 'none'}`); 60 | 61 | if (!this.availablePortCommand) { 62 | const localizationService = LocalizationService.getInstance(); 63 | const message = this.platform === 'darwin' 64 | ? localizationService.t('notify.portCommandRequiredDarwin') 65 | : localizationService.t('notify.portCommandRequired'); 66 | 67 | vscode.window.showErrorMessage(message, { modal: false }); 68 | throw new Error('No port detection command available (lsof/ss/netstat)'); 69 | } 70 | } 71 | 72 | /** 73 | * 判断命令行是否属于 Antigravity 进程 74 | * 通过 --app_data_dir antigravity 或路径中包含 antigravity 来识别 75 | */ 76 | private isAntigravityProcess(commandLine: string): boolean { 77 | const lowerCmd = commandLine.toLowerCase(); 78 | // 检查 --app_data_dir antigravity 参数 79 | if (/--app_data_dir\s+antigravity\b/i.test(commandLine)) { 80 | return true; 81 | } 82 | // 检查路径中是否包含 antigravity 83 | if (lowerCmd.includes('/antigravity/') || lowerCmd.includes('\\antigravity\\')) { 84 | return true; 85 | } 86 | return false; 87 | } 88 | 89 | /** 90 | * Get command to list Unix processes using ps and grep. 91 | */ 92 | getProcessListCommand(processName: string): string { 93 | // Use ps -ww -eo pid,ppid,args to get PID, PPID and full command line 94 | // -ww: unlimited width (avoid truncation) 95 | // -e: select all processes 96 | // -o: user-defined format 97 | return `ps -ww -eo pid,ppid,args | grep "${processName}" | grep -v grep`; 98 | } 99 | 100 | parseProcessInfo(stdout: string): { 101 | pid: number; 102 | extensionPort: number; 103 | csrfToken: string; 104 | } | null { 105 | if (!stdout || stdout.trim().length === 0) { 106 | return null; 107 | } 108 | 109 | const lines = stdout.trim().split('\n'); 110 | const currentPid = process.pid; 111 | const candidates: Array<{ pid: number; ppid: number; extensionPort: number; csrfToken: string }> = []; 112 | 113 | for (const line of lines) { 114 | // Format: PID PPID COMMAND... 115 | const parts = line.trim().split(/\s+/); 116 | if (parts.length < 3) { 117 | continue; 118 | } 119 | 120 | const pid = parseInt(parts[0], 10); 121 | const ppid = parseInt(parts[1], 10); 122 | 123 | // Reconstruct command line (it might contain spaces) 124 | const cmd = parts.slice(2).join(' '); 125 | 126 | if (isNaN(pid) || isNaN(ppid)) { 127 | continue; 128 | } 129 | 130 | const portMatch = cmd.match(/--extension_server_port[=\s]+(\d+)/); 131 | const tokenMatch = cmd.match(/--csrf_token[=\s]+([a-f0-9\-]+)/i); 132 | 133 | // 必须同时满足:有 csrf_token 且是 Antigravity 进程 134 | if (tokenMatch && tokenMatch[1] && this.isAntigravityProcess(cmd)) { 135 | const extensionPort = portMatch && portMatch[1] ? parseInt(portMatch[1], 10) : 0; 136 | const csrfToken = tokenMatch[1]; 137 | candidates.push({ pid, ppid, extensionPort, csrfToken }); 138 | } 139 | } 140 | 141 | if (candidates.length === 0) { 142 | return null; 143 | } 144 | 145 | // 1. Prefer the process that is a direct child of the current process (extension host) 146 | const child = candidates.find(c => c.ppid === currentPid); 147 | if (child) { 148 | return child; 149 | } 150 | 151 | // 2. Fallback: return the first candidate found (legacy behavior) 152 | // This handles cases where the process hierarchy might be different (e.g. intermediate shell) 153 | return candidates[0]; 154 | } 155 | 156 | /** 157 | * Get command to list ports for a specific process. 158 | * Uses the available command detected by ensurePortCommandAvailable(). 159 | */ 160 | getPortListCommand(pid: number): string { 161 | switch (this.availablePortCommand) { 162 | case 'lsof': 163 | // lsof: -P no port name resolution, -a AND conditions, -n no hostname resolution 164 | return `lsof -Pan -p ${pid} -i`; 165 | case 'ss': 166 | // ss: -t TCP, -l listening, -n numeric, -p show process 167 | return `ss -tlnp 2>/dev/null | grep "pid=${pid},"`; 168 | case 'netstat': 169 | return `netstat -tulpn 2>/dev/null | grep ${pid}`; 170 | default: 171 | // Fallback chain if ensurePortCommandAvailable() wasn't called 172 | return `lsof -Pan -p ${pid} -i 2>/dev/null || ss -tlnp 2>/dev/null | grep "pid=${pid}," || netstat -tulpn 2>/dev/null | grep ${pid}`; 173 | } 174 | } 175 | 176 | /** 177 | * Parse lsof/ss/netstat output to extract listening ports. 178 | * 179 | * lsof format: 180 | * language_ 1234 user 10u IPv4 0x... 0t0 TCP 127.0.0.1:2873 (LISTEN) 181 | * 182 | * ss format (Linux): 183 | * LISTEN 0 128 127.0.0.1:2873 0.0.0.0:* users:(("language_server",pid=1234,fd=10)) 184 | * 185 | * netstat format (Linux): 186 | * tcp 0 0 127.0.0.1:2873 0.0.0.0:* LISTEN 1234/language_server 187 | */ 188 | parseListeningPorts(stdout: string): number[] { 189 | const ports: number[] = []; 190 | 191 | if (!stdout || stdout.trim().length === 0) { 192 | return ports; 193 | } 194 | 195 | const lines = stdout.trim().split('\n'); 196 | 197 | for (const line of lines) { 198 | // Try lsof format: 127.0.0.1:PORT (LISTEN) 199 | const lsofMatch = line.match(/127\.0\.0\.1:(\d+).*\(LISTEN\)/); 200 | if (lsofMatch && lsofMatch[1]) { 201 | const port = parseInt(lsofMatch[1], 10); 202 | if (!ports.includes(port)) { 203 | ports.push(port); 204 | } 205 | continue; 206 | } 207 | 208 | // Try ss format: 127.0.0.1:PORT or *:PORT in LISTEN state 209 | // ss output: LISTEN 0 128 127.0.0.1:2873 0.0.0.0:* 210 | const ssMatch = line.match(/LISTEN\s+\d+\s+\d+\s+(?:127\.0\.0\.1|\*):(\d+)/); 211 | if (ssMatch && ssMatch[1]) { 212 | const port = parseInt(ssMatch[1], 10); 213 | if (!ports.includes(port)) { 214 | ports.push(port); 215 | } 216 | continue; 217 | } 218 | 219 | // Try netstat format: 127.0.0.1:PORT ... LISTEN 220 | const netstatMatch = line.match(/127\.0\.0\.1:(\d+).*LISTEN/); 221 | if (netstatMatch && netstatMatch[1]) { 222 | const port = parseInt(netstatMatch[1], 10); 223 | if (!ports.includes(port)) { 224 | ports.push(port); 225 | } 226 | continue; 227 | } 228 | 229 | // Also try localhost format (for lsof/netstat) 230 | const localhostMatch = line.match(/localhost:(\d+).*\(LISTEN\)|localhost:(\d+).*LISTEN/); 231 | if (localhostMatch) { 232 | const port = parseInt(localhostMatch[1] || localhostMatch[2], 10); 233 | if (!ports.includes(port)) { 234 | ports.push(port); 235 | } 236 | } 237 | } 238 | 239 | return ports.sort((a, b) => a - b); 240 | } 241 | 242 | /** 243 | * Get Unix-specific error messages. 244 | */ 245 | getErrorMessages(): { 246 | processNotFound: string; 247 | commandNotAvailable: string; 248 | requirements: string[]; 249 | } { 250 | const processName = this.platform === 'darwin' 251 | ? 'language_server_macos' 252 | : 'language_server_linux'; 253 | 254 | return { 255 | processNotFound: 'language_server process not found', 256 | commandNotAvailable: 'ps/lsof commands are unavailable; please check the system environment', 257 | requirements: [ 258 | 'Antigravity is running', 259 | `${processName} process is running`, 260 | 'The system has permission to execute ps and lsof commands' 261 | ] 262 | }; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/processPortDetector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Process-based port detector. 3 | * Reads Antigravity Language Server command line args to extract ports and CSRF token. 4 | * Uses platform-specific strategies for cross-platform support. 5 | */ 6 | 7 | import { exec } from 'child_process'; 8 | import { promisify } from 'util'; 9 | import * as https from 'https'; 10 | import { PlatformDetector, IPlatformStrategy } from './platformDetector'; 11 | import { versionInfo } from './versionInfo'; 12 | 13 | const execAsync = promisify(exec); 14 | 15 | export interface AntigravityProcessInfo { 16 | /** HTTP port from --extension_server_port */ 17 | extensionPort: number; 18 | /** HTTPS port for Connect/CommandModelConfigs (detected via testing) */ 19 | connectPort: number; 20 | csrfToken: string; 21 | } 22 | 23 | export class ProcessPortDetector { 24 | private platformDetector: PlatformDetector; 25 | private platformStrategy: IPlatformStrategy; 26 | private processName: string; 27 | 28 | constructor() { 29 | this.platformDetector = new PlatformDetector(); 30 | this.platformStrategy = this.platformDetector.getStrategy(); 31 | this.processName = this.platformDetector.getProcessName(); 32 | } 33 | 34 | /** 35 | * Detect credentials (ports + CSRF token) from the running process. 36 | * @param maxRetries Maximum number of retry attempts (default: 3) 37 | * @param retryDelay Delay between retries in milliseconds (default: 2000) 38 | */ 39 | async detectProcessInfo(maxRetries: number = 3, retryDelay: number = 2000): Promise { 40 | const platformName = this.platformDetector.getPlatformName(); 41 | const errorMessages = this.platformStrategy.getErrorMessages(); 42 | 43 | // 在 Windows 平台显示当前使用的检测模式 44 | if (platformName === 'Windows') { 45 | const windowsStrategy = this.platformStrategy as any; 46 | const mode = windowsStrategy.isUsingPowerShell?.() ? 'PowerShell' : 'WMIC'; 47 | console.log(`[PortDetector] Windows detection mode: ${mode}`); 48 | } 49 | 50 | for (let attempt = 1; attempt <= maxRetries; attempt++) { 51 | try { 52 | console.log(`[PortDetector] Attempting to detect Antigravity process (${platformName}, try ${attempt}/${maxRetries})...`); 53 | 54 | // Fetch full command line for the language server process using platform-specific command 55 | const command = this.platformStrategy.getProcessListCommand(this.processName); 56 | console.log(`[PortDetector] Running process list command: ${command}`); 57 | const { stdout } = await execAsync(command, { timeout: 15000 }); 58 | const preview = stdout.trim().split('\n').slice(0, 3).join('\n'); 59 | console.log(`[PortDetector] Process command output preview:\n${preview || '(empty)'}`); 60 | 61 | // Parse process info using platform-specific parser 62 | const processInfo = this.platformStrategy.parseProcessInfo(stdout); 63 | 64 | if (!processInfo) { 65 | console.warn(`[PortDetector] Attempt ${attempt}: ${errorMessages.processNotFound}`); 66 | throw new Error(errorMessages.processNotFound); 67 | } 68 | 69 | const { pid, extensionPort, csrfToken } = processInfo; 70 | 71 | console.log('[PortDetector] Found process info:'); 72 | console.log(`[PortDetector] PID: ${pid}`); 73 | console.log(`[PortDetector] extension_server_port: ${extensionPort || '(not found)'}`); 74 | console.log(`[PortDetector] CSRF Token: ${csrfToken ? '[present]' : '[missing]'}`); 75 | 76 | // 获取该进程监听的所有端口 77 | console.log(`[PortDetector] Fetching listening ports for PID ${pid}...`); 78 | const listeningPorts = await this.getProcessListeningPorts(pid); 79 | 80 | if (listeningPorts.length === 0) { 81 | console.warn(`[PortDetector] Attempt ${attempt}: process is not listening on any ports`); 82 | throw new Error('Process is not listening on any ports'); 83 | } 84 | 85 | console.log(`[PortDetector] Found ${listeningPorts.length} listening ports: ${listeningPorts.join(', ')}`); 86 | 87 | // 逐个测试端口,找到能响应 API 的端口 88 | console.log('[PortDetector] Testing port connectivity...'); 89 | const connectPort = await this.findWorkingPort(listeningPorts, csrfToken); 90 | 91 | if (!connectPort) { 92 | console.warn(`[PortDetector] Attempt ${attempt}: all port tests failed`); 93 | throw new Error('Unable to find a working API port'); 94 | } 95 | 96 | console.log(`[PortDetector] Attempt ${attempt} succeeded`); 97 | console.log(`[PortDetector] API port (HTTPS): ${connectPort}`); 98 | console.log(`[PortDetector] Detection summary: extension_port=${extensionPort}, connect_port=${connectPort}`); 99 | 100 | return { extensionPort, connectPort, csrfToken }; 101 | 102 | } catch (error: any) { 103 | const errorMsg = error?.message || String(error); 104 | console.error(`[PortDetector] Attempt ${attempt} failed:`, errorMsg); 105 | if (error?.stack) { 106 | console.error('[PortDetector] Stack:', error.stack); 107 | } 108 | 109 | // 提供更具体的错误提示 110 | if (errorMsg.includes('timeout')) { 111 | console.error('[PortDetector] Reason: command execution timed out; the system may be under heavy load'); 112 | } else if (errorMsg.includes('not found') || errorMsg.includes('not recognized') || errorMsg.includes('不是内部或外部命令')) { 113 | console.error(`[PortDetector] Reason: ${errorMessages.commandNotAvailable}`); 114 | 115 | // Windows 平台特殊处理:WMIC 降级到 PowerShell 116 | if (this.platformDetector.getPlatformName() === 'Windows') { 117 | const windowsStrategy = this.platformStrategy as any; 118 | if (windowsStrategy.setUsePowerShell && !windowsStrategy.isUsingPowerShell()) { 119 | console.warn('[PortDetector] WMIC command is unavailable (Windows 10 21H1+/Windows 11 deprecated WMIC)'); 120 | console.log('[PortDetector] Switching to PowerShell mode and retrying...'); 121 | windowsStrategy.setUsePowerShell(true); 122 | 123 | // 不消耗重试次数,直接重试当前尝试 124 | attempt--; 125 | continue; 126 | } 127 | } 128 | } 129 | } 130 | 131 | // 如果还有重试机会,等待后重试 132 | if (attempt < maxRetries) { 133 | console.log(`[PortDetector] Waiting ${retryDelay}ms before retrying...`); 134 | await new Promise(resolve => setTimeout(resolve, retryDelay)); 135 | } 136 | } 137 | 138 | console.error(`[PortDetector] All ${maxRetries} attempts failed`); 139 | console.error('[PortDetector] Please ensure:'); 140 | errorMessages.requirements.forEach((req, index) => { 141 | console.error(`[PortDetector] ${index + 1}. ${req}`); 142 | }); 143 | 144 | return null; 145 | } 146 | 147 | /** 148 | * 获取进程监听的所有端口 149 | */ 150 | private async getProcessListeningPorts(pid: number): Promise { 151 | try { 152 | // Ensure port detection command is available before running 153 | await this.platformStrategy.ensurePortCommandAvailable(); 154 | 155 | const command = this.platformStrategy.getPortListCommand(pid); 156 | console.log(`[PortDetector] Running port list command for PID ${pid}: ${command}`); 157 | const { stdout } = await execAsync(command, { timeout: 3000 }); 158 | console.log(`[PortDetector] Port list output preview:\n${stdout.trim().split('\n').slice(0, 5).join('\n') || '(empty)'}`); 159 | 160 | // Parse ports using platform-specific parser 161 | const ports = this.platformStrategy.parseListeningPorts(stdout); 162 | console.log(`[PortDetector] Parsed listening ports: ${ports.length > 0 ? ports.join(', ') : '(none)'}`); 163 | return ports; 164 | } catch (error) { 165 | console.error('Failed to fetch listening ports:', error); 166 | return []; 167 | } 168 | } 169 | 170 | /** 171 | * 测试端口列表,找到第一个能响应 API 的端口 172 | */ 173 | private async findWorkingPort(ports: number[], csrfToken: string): Promise { 174 | console.log(`[PortDetector] Candidate ports for testing: ${ports.join(', ') || '(none)'}`); 175 | for (const port of ports) { 176 | console.log(`[PortDetector] Testing port ${port}...`); 177 | const isWorking = await this.testPortConnectivity(port, csrfToken); 178 | if (isWorking) { 179 | console.log(`[PortDetector] Port ${port} test succeeded`); 180 | return port; 181 | } else { 182 | console.log(`[PortDetector] Port ${port} test failed`); 183 | } 184 | } 185 | return null; 186 | } 187 | 188 | /** 189 | * 测试端口是否能响应 API 请求 190 | * 使用 GetUnleashData 端点,因为它不需要用户登录即可访问 191 | */ 192 | private async testPortConnectivity(port: number, csrfToken: string): Promise { 193 | return new Promise((resolve) => { 194 | const requestBody = JSON.stringify({ 195 | context: { 196 | properties: { 197 | devMode: "false", 198 | extensionVersion: versionInfo.getExtensionVersion(), 199 | hasAnthropicModelAccess: "true", 200 | ide: "antigravity", 201 | ideVersion: versionInfo.getIdeVersion(), 202 | installationId: "test-detection", 203 | language: "UNSPECIFIED", 204 | os: versionInfo.getOs(), 205 | requestedModelId: "MODEL_UNSPECIFIED" 206 | } 207 | } 208 | }); 209 | 210 | const options = { 211 | hostname: '127.0.0.1', 212 | port: port, 213 | path: '/exa.language_server_pb.LanguageServerService/GetUnleashData', 214 | method: 'POST', 215 | headers: { 216 | 'Content-Type': 'application/json', 217 | 'Content-Length': Buffer.byteLength(requestBody), 218 | 'Connect-Protocol-Version': '1', 219 | 'X-Codeium-Csrf-Token': csrfToken 220 | }, 221 | rejectUnauthorized: false, 222 | timeout: 2000 223 | }; 224 | 225 | console.log(`[PortDetector] Sending GetUnleashData probe to port ${port}`); 226 | const req = https.request(options, (res) => { 227 | const success = res.statusCode === 200; 228 | console.log(`[PortDetector] Port ${port} responded with status ${res.statusCode}`); 229 | res.resume(); 230 | resolve(success); 231 | }); 232 | 233 | req.on('error', (err) => { 234 | console.warn(`[PortDetector] Port ${port} connectivity error: ${err.message}`); 235 | resolve(false); 236 | }); 237 | 238 | req.on('timeout', () => { 239 | console.warn(`[PortDetector] Port ${port} probe timed out`); 240 | req.destroy(); 241 | resolve(false); 242 | }); 243 | 244 | req.write(requestBody); 245 | req.end(); 246 | }); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Antigravity Quota Watcher - main extension file 3 | */ 4 | 5 | import * as vscode from 'vscode'; 6 | import { QuotaService, QuotaApiMethod } from './quotaService'; 7 | import { StatusBarService } from './statusBar'; 8 | import { ConfigService } from './configService'; 9 | import { PortDetectionService, PortDetectionResult } from './portDetectionService'; 10 | import { Config, QuotaSnapshot } from './types'; 11 | import { LocalizationService } from './i18n/localizationService'; 12 | import { versionInfo } from './versionInfo'; 13 | import { registerDevCommands } from './devTools'; 14 | 15 | let quotaService: QuotaService | undefined; 16 | let statusBarService: StatusBarService | undefined; 17 | let configService: ConfigService | undefined; 18 | let portDetectionService: PortDetectionService | undefined; 19 | let configChangeTimer: NodeJS.Timeout | undefined; // 配置变更防抖定时器 20 | 21 | /** 22 | * Called when the extension is activated 23 | */ 24 | export async function activate(context: vscode.ExtensionContext) { 25 | // Initialize and print version info 26 | versionInfo.initialize(context); 27 | console.log(`=== Antigravity Quota Watcher v${versionInfo.getExtensionVersion()} ===`); 28 | console.log(`Running on: ${versionInfo.getIdeName()} v${versionInfo.getIdeVersion()}`); 29 | 30 | // Init services 31 | configService = new ConfigService(); 32 | let config = configService.getConfig(); 33 | 34 | // Initialize localization 35 | const localizationService = LocalizationService.getInstance(); 36 | localizationService.setLanguage(config.language); 37 | 38 | // console.log('[Extension] Loaded config:', config); 39 | 40 | portDetectionService = new PortDetectionService(context); 41 | 42 | // Init status bar 43 | statusBarService = new StatusBarService( 44 | config.warningThreshold, 45 | config.criticalThreshold, 46 | config.showPromptCredits, 47 | config.showPlanName, 48 | config.showGeminiPro, 49 | config.showGeminiFlash, 50 | config.displayStyle 51 | ); 52 | // 显示检测状态 53 | statusBarService.showDetecting(); 54 | 55 | // Auto detect port and csrf token 56 | let detectedPort: number | null = null; 57 | let detectedCsrfToken: string | null = null; 58 | let detectionResult: PortDetectionResult | null = null; 59 | 60 | try { 61 | console.log('[Extension] Starting initial port detection'); 62 | const result = await portDetectionService.detectPort(); 63 | if (result) { 64 | detectionResult = result; 65 | detectedPort = result.port; 66 | detectedCsrfToken = result.csrfToken; 67 | console.log('[Extension] Initial port detection success:', detectionResult); 68 | } 69 | } catch (error) { 70 | console.error('❌ Port/CSRF detection failed', error); 71 | if (error instanceof Error && error.stack) { 72 | console.error('Stack:', error.stack); 73 | } 74 | } 75 | 76 | // Ensure port and CSRF token are available 77 | if (!detectedPort || !detectedCsrfToken) { 78 | console.error('Missing port or CSRF Token, extension cannot start'); 79 | console.error('Please ensure Antigravity language server is running'); 80 | statusBarService.showError('Port/CSRF Detection failed, Please try restart.'); 81 | statusBarService.show(); 82 | 83 | // 显示用户提示,提供重试选项 84 | vscode.window.showWarningMessage( 85 | localizationService.t('notify.unableToDetectProcess'), 86 | localizationService.t('notify.retry'), 87 | localizationService.t('notify.cancel') 88 | ).then(action => { 89 | if (action === localizationService.t('notify.retry')) { 90 | vscode.commands.executeCommand('antigravity-quota-watcher.detectPort'); 91 | } 92 | }); 93 | } else { 94 | // 显示初始化状态 95 | statusBarService.showInitializing(); 96 | 97 | // Init quota service 98 | quotaService = new QuotaService(detectedPort, undefined, detectionResult?.httpPort); 99 | // Set ports for HTTPS + HTTP fallback 100 | quotaService.setPorts(detectionResult?.connectPort ?? detectedPort, detectionResult?.httpPort); 101 | // Choose endpoint based on config 102 | quotaService.setApiMethod(config.apiMethod === 'COMMAND_MODEL_CONFIG' 103 | ? QuotaApiMethod.COMMAND_MODEL_CONFIG 104 | : QuotaApiMethod.GET_USER_STATUS); 105 | 106 | // Register quota update callback 107 | quotaService.onQuotaUpdate((snapshot: QuotaSnapshot) => { 108 | statusBarService?.updateDisplay(snapshot); 109 | }); 110 | 111 | // Register error callback (silent, only update status bar) 112 | quotaService.onError((error: Error) => { 113 | console.error('Quota fetch failed:', error); 114 | statusBarService?.showError(`Connection failed: ${error.message}`); 115 | }); 116 | 117 | // Register status callback 118 | quotaService.onStatus((status: 'fetching' | 'retrying', retryCount?: number) => { 119 | if (status === 'fetching') { 120 | statusBarService?.showFetching(); 121 | } else if (status === 'retrying' && retryCount !== undefined) { 122 | statusBarService?.showRetrying(retryCount, 3); // MAX_RETRY_COUNT = 3 123 | } 124 | }); 125 | 126 | // If enabled, start polling after a short delay 127 | if (config.enabled) { 128 | console.log('Starting quota polling after delay...'); 129 | 130 | // 显示准备获取配额的状态 131 | statusBarService.showFetching(); 132 | 133 | setTimeout(() => { 134 | quotaService?.setAuthInfo(undefined, detectedCsrfToken); 135 | quotaService?.startPolling(config.pollingInterval); 136 | }, 8000); 137 | 138 | statusBarService.show(); 139 | } 140 | } 141 | 142 | // Command: show quota details (placeholder) 143 | const showQuotaCommand = vscode.commands.registerCommand( 144 | 'antigravity-quota-watcher.showQuota', 145 | () => { 146 | // TODO: implement quota detail panel 147 | } 148 | ); 149 | 150 | // Command: quick refresh quota (for success state) 151 | const quickRefreshQuotaCommand = vscode.commands.registerCommand( 152 | 'antigravity-quota-watcher.quickRefreshQuota', 153 | async () => { 154 | console.log('[Extension] quickRefreshQuota command invoked'); 155 | if (!quotaService) { 156 | // quotaService 未初始化,自动委托给 detectPort 命令进行重新检测 157 | console.log('[Extension] quotaService not initialized, delegating to detectPort command'); 158 | await vscode.commands.executeCommand('antigravity-quota-watcher.detectPort'); 159 | return; 160 | } 161 | 162 | console.log('User triggered quick quota refresh'); 163 | // 显示刷新中状态(旋转图标) 164 | statusBarService?.showQuickRefreshing(); 165 | // 立即刷新一次,不中断轮询 166 | await quotaService.quickRefresh(); 167 | } 168 | ); 169 | 170 | // Command: refresh quota 171 | const refreshQuotaCommand = vscode.commands.registerCommand( 172 | 'antigravity-quota-watcher.refreshQuota', 173 | async () => { 174 | console.log('[Extension] refreshQuota command invoked'); 175 | if (!quotaService) { 176 | // quotaService 未初始化,自动委托给 detectPort 命令进行重新检测 177 | console.log('[Extension] quotaService not initialized, delegating to detectPort command'); 178 | await vscode.commands.executeCommand('antigravity-quota-watcher.detectPort'); 179 | return; 180 | } 181 | 182 | vscode.window.showInformationMessage(localizationService.t('notify.refreshingQuota')); 183 | config = configService!.getConfig(); 184 | statusBarService?.setWarningThreshold(config.warningThreshold); 185 | statusBarService?.setCriticalThreshold(config.criticalThreshold); 186 | statusBarService?.setShowPromptCredits(config.showPromptCredits); 187 | statusBarService?.setShowPlanName(config.showPlanName); 188 | statusBarService?.setShowGeminiPro(config.showGeminiPro); 189 | statusBarService?.setShowGeminiFlash(config.showGeminiFlash); 190 | statusBarService?.setDisplayStyle(config.displayStyle); 191 | statusBarService?.showFetching(); 192 | 193 | if (config.enabled) { 194 | quotaService.setApiMethod(config.apiMethod === 'COMMAND_MODEL_CONFIG' 195 | ? QuotaApiMethod.COMMAND_MODEL_CONFIG 196 | : QuotaApiMethod.GET_USER_STATUS); 197 | // 使用新的重试方法,成功后会自动恢复轮询 198 | await quotaService.retryFromError(config.pollingInterval); 199 | } 200 | } 201 | ); 202 | 203 | // Command: re-detect port 204 | const detectPortCommand = vscode.commands.registerCommand( 205 | 'antigravity-quota-watcher.detectPort', 206 | async () => { 207 | console.log('[Extension] detectPort command invoked'); 208 | // 使用状态栏显示检测状态,不弹窗 209 | statusBarService?.showDetecting(); 210 | 211 | config = configService!.getConfig(); 212 | statusBarService?.setWarningThreshold(config.warningThreshold); 213 | statusBarService?.setCriticalThreshold(config.criticalThreshold); 214 | statusBarService?.setShowPromptCredits(config.showPromptCredits); 215 | statusBarService?.setShowPlanName(config.showPlanName); 216 | statusBarService?.setShowGeminiPro(config.showGeminiPro); 217 | statusBarService?.setShowGeminiFlash(config.showGeminiFlash); 218 | statusBarService?.setDisplayStyle(config.displayStyle); 219 | 220 | try { 221 | console.log('[Extension] detectPort: invoking portDetectionService'); 222 | const result = await portDetectionService?.detectPort(); 223 | 224 | if (result && result.port && result.csrfToken) { 225 | console.log('[Extension] detectPort command succeeded:', result); 226 | // 如果之前没有 quotaService,需要初始化 227 | if (!quotaService) { 228 | quotaService = new QuotaService(result.port, result.csrfToken, result.httpPort); 229 | quotaService.setPorts(result.connectPort, result.httpPort); 230 | 231 | // 注册回调 232 | quotaService.onQuotaUpdate((snapshot: QuotaSnapshot) => { 233 | statusBarService?.updateDisplay(snapshot); 234 | }); 235 | 236 | quotaService.onError((error: Error) => { 237 | console.error('Quota fetch failed:', error); 238 | statusBarService?.showError(`Connection failed: ${error.message}`); 239 | }); 240 | 241 | } else { 242 | // 更新现有服务的端口 243 | quotaService.setPorts(result.connectPort, result.httpPort); 244 | quotaService.setAuthInfo(undefined, result.csrfToken); 245 | console.log('[Extension] detectPort: updated existing QuotaService ports'); 246 | } 247 | 248 | // 清除之前的错误状态 249 | statusBarService?.clearError(); 250 | 251 | quotaService.stopPolling(); 252 | quotaService.setApiMethod(config.apiMethod === 'COMMAND_MODEL_CONFIG' 253 | ? QuotaApiMethod.COMMAND_MODEL_CONFIG 254 | : QuotaApiMethod.GET_USER_STATUS); 255 | quotaService.startPolling(config.pollingInterval); 256 | 257 | vscode.window.showInformationMessage(localizationService.t('notify.detectionSuccess', { port: result.port })); 258 | } else { 259 | console.warn('[Extension] detectPort command did not return valid ports'); 260 | vscode.window.showErrorMessage( 261 | localizationService.t('notify.unableToDetectPort') + '\n' + 262 | localizationService.t('notify.unableToDetectPortHint1') + '\n' + 263 | localizationService.t('notify.unableToDetectPortHint2') 264 | ); 265 | } 266 | } catch (error: any) { 267 | const errorMsg = error?.message || String(error); 268 | console.error('Port detection failed:', errorMsg); 269 | if (error?.stack) { 270 | console.error('Stack:', error.stack); 271 | } 272 | vscode.window.showErrorMessage(localizationService.t('notify.portDetectionFailed', { error: errorMsg })); 273 | } 274 | } 275 | ); 276 | 277 | // Listen to config changes 278 | const configChangeDisposable = configService.onConfigChange((newConfig) => { 279 | handleConfigChange(newConfig as Config); 280 | }); 281 | 282 | // Add to context subscriptions 283 | context.subscriptions.push( 284 | showQuotaCommand, 285 | quickRefreshQuotaCommand, 286 | refreshQuotaCommand, 287 | detectPortCommand, 288 | configChangeDisposable, 289 | { dispose: () => quotaService?.dispose() }, 290 | { dispose: () => statusBarService?.dispose() } 291 | ); 292 | 293 | // 注册开发工具命令 294 | registerDevCommands(context); 295 | 296 | // Startup log 297 | console.log('Antigravity Quota Watcher initialized'); 298 | } 299 | 300 | /** 301 | * Handle config changes with debounce to prevent race conditions 302 | */ 303 | function handleConfigChange(config: Config): void { 304 | // 防抖:300ms 内的多次变更只执行最后一次 305 | if (configChangeTimer) { 306 | clearTimeout(configChangeTimer); 307 | } 308 | 309 | configChangeTimer = setTimeout(() => { 310 | console.log('Config updated (debounced)', config); 311 | 312 | quotaService?.setApiMethod(config.apiMethod === 'COMMAND_MODEL_CONFIG' 313 | ? QuotaApiMethod.COMMAND_MODEL_CONFIG 314 | : QuotaApiMethod.GET_USER_STATUS); 315 | statusBarService?.setWarningThreshold(config.warningThreshold); 316 | statusBarService?.setCriticalThreshold(config.criticalThreshold); 317 | statusBarService?.setShowPromptCredits(config.showPromptCredits); 318 | statusBarService?.setShowPlanName(config.showPlanName); 319 | statusBarService?.setShowGeminiPro(config.showGeminiPro); 320 | statusBarService?.setShowGeminiFlash(config.showGeminiFlash); 321 | statusBarService?.setDisplayStyle(config.displayStyle); 322 | 323 | // Update language 324 | const localizationService = LocalizationService.getInstance(); 325 | if (localizationService.getLanguage() !== config.language) { 326 | localizationService.setLanguage(config.language); 327 | // Refresh display to reflect language change 328 | quotaService?.quickRefresh(); 329 | } 330 | 331 | if (config.enabled) { 332 | quotaService?.startPolling(config.pollingInterval); 333 | statusBarService?.show(); 334 | } else { 335 | quotaService?.stopPolling(); 336 | statusBarService?.hide(); 337 | } 338 | 339 | vscode.window.showInformationMessage(LocalizationService.getInstance().t('notify.configUpdated')); 340 | }, 300); 341 | } 342 | 343 | /** 344 | * Called when the extension is deactivated 345 | */ 346 | export function deactivate() { 347 | console.log('Antigravity Quota Watcher deactivated'); 348 | quotaService?.dispose(); 349 | statusBarService?.dispose(); 350 | } 351 | -------------------------------------------------------------------------------- /src/statusBar.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Status bar service 3 | */ 4 | 5 | import * as vscode from 'vscode'; 6 | import { ModelQuotaInfo, QuotaLevel, QuotaSnapshot } from './types'; 7 | import { LocalizationService } from './i18n/localizationService'; 8 | 9 | export class StatusBarService { 10 | private statusBarItem: vscode.StatusBarItem; 11 | private warningThreshold: number; 12 | private criticalThreshold: number; 13 | private showPromptCredits: boolean; 14 | private showPlanName: boolean; 15 | private showGeminiPro: boolean; 16 | private showGeminiFlash: boolean; 17 | private displayStyle: 'percentage' | 'progressBar' | 'dots'; 18 | private localizationService: LocalizationService; 19 | 20 | private isQuickRefreshing: boolean = false; 21 | private refreshStartTime: number = 0; 22 | private readonly minRefreshDuration: number = 1000; 23 | 24 | constructor( 25 | warningThreshold: number = 50, 26 | criticalThreshold: number = 30, 27 | showPromptCredits: boolean = false, 28 | showPlanName: boolean = false, 29 | showGeminiPro: boolean = true, 30 | showGeminiFlash: boolean = true, 31 | displayStyle: 'percentage' | 'progressBar' | 'dots' = 'progressBar' 32 | ) { 33 | this.localizationService = LocalizationService.getInstance(); 34 | this.statusBarItem = vscode.window.createStatusBarItem( 35 | vscode.StatusBarAlignment.Right, 36 | 100 37 | ); 38 | this.statusBarItem.command = 'antigravity-quota-watcher.showQuota'; 39 | this.warningThreshold = warningThreshold; 40 | this.criticalThreshold = criticalThreshold; 41 | this.showPromptCredits = showPromptCredits; 42 | this.showPlanName = showPlanName; 43 | this.showGeminiPro = showGeminiPro; 44 | this.showGeminiFlash = showGeminiFlash; 45 | this.displayStyle = displayStyle; 46 | } 47 | 48 | updateDisplay(snapshot: QuotaSnapshot): void { 49 | // Check if we need to wait for the minimum animation duration 50 | if (this.isQuickRefreshing && this.refreshStartTime > 0) { 51 | const elapsed = Date.now() - this.refreshStartTime; 52 | if (elapsed < this.minRefreshDuration) { 53 | const remaining = this.minRefreshDuration - elapsed; 54 | setTimeout(() => { 55 | this.updateDisplay(snapshot); 56 | }, remaining); 57 | return; 58 | } 59 | } 60 | 61 | // 保存最后的快照 62 | 63 | // 清除刷新状态 64 | this.isQuickRefreshing = false; 65 | this.refreshStartTime = 0; 66 | // 设置为快速刷新命令,允许用户点击立即刷新 67 | this.statusBarItem.command = 'antigravity-quota-watcher.quickRefreshQuota'; 68 | 69 | const parts: string[] = []; 70 | 71 | // Display Plan Name if available and enabled 72 | if (this.showPlanName && snapshot.planName) { 73 | const planNameFormatted = this.formatPlanName(snapshot.planName); 74 | parts.push(`Plan: ${planNameFormatted}`); 75 | } 76 | 77 | if (this.showPromptCredits && snapshot.promptCredits) { 78 | const { available, monthly, remainingPercentage } = snapshot.promptCredits; 79 | const indicator = this.getStatusIndicator(remainingPercentage); 80 | const creditsPart = `${indicator} 💳 ${available}/${this.formatNumber(monthly)} (${remainingPercentage.toFixed(0)}%)`; 81 | parts.push(creditsPart); 82 | } 83 | 84 | const modelsToShow = this.selectModelsToDisplay(snapshot.models); 85 | 86 | for (const model of modelsToShow) { 87 | const emoji = this.getModelEmoji(model.label); 88 | const shortName = this.getShortModelName(model.label); 89 | const indicator = this.getStatusIndicator(model.remainingPercentage ?? 0); 90 | 91 | if (model.isExhausted) { 92 | if (this.displayStyle === 'percentage') { 93 | parts.push(`${indicator} ${emoji} ${shortName}: 0%`); 94 | } else if (this.displayStyle === 'dots') { 95 | parts.push(`${indicator} ${emoji} ${shortName} ${this.getDotsBar(0)}`); 96 | } else { 97 | parts.push(`${indicator} ${emoji} ${shortName} ${this.getProgressBar(0)}`); 98 | } 99 | } else if (model.remainingPercentage !== undefined) { 100 | if (this.displayStyle === 'percentage') { 101 | parts.push(`${indicator} ${emoji} ${shortName}: ${model.remainingPercentage.toFixed(0)}%`); 102 | } else if (this.displayStyle === 'dots') { 103 | parts.push(`${indicator} ${emoji} ${shortName} ${this.getDotsBar(model.remainingPercentage)}`); 104 | } else { 105 | parts.push(`${indicator} ${emoji} ${shortName} ${this.getProgressBar(model.remainingPercentage)}`); 106 | } 107 | } 108 | } 109 | 110 | if (parts.length === 0) { 111 | this.statusBarItem.text = this.localizationService.t('status.error'); 112 | this.statusBarItem.backgroundColor = undefined; 113 | this.statusBarItem.tooltip = this.localizationService.t('tooltip.error'); 114 | } else { 115 | // Use space as separator 116 | const displayText = parts.join(' '); 117 | this.statusBarItem.text = displayText; 118 | // 移除背景色变化,保持默认 119 | this.statusBarItem.backgroundColor = undefined; 120 | this.statusBarItem.color = undefined; 121 | this.updateTooltip(snapshot); 122 | } 123 | 124 | this.statusBarItem.show(); 125 | } 126 | 127 | /** 128 | * 根据剩余百分比返回状态指示符号 129 | * 🟢 > warningThreshold (默认50%) 130 | * 🟡 criticalThreshold < percentage <= warningThreshold (默认30%-50%) 131 | * 🔴 0 < percentage <= criticalThreshold (默认<30%) 132 | * ⚫ percentage <= 0 133 | */ 134 | private getStatusIndicator(percentage: number): string { 135 | if (percentage <= 0) { 136 | return '⚫'; // Depleted 137 | } else if (percentage <= this.criticalThreshold) { 138 | return '🔴'; // Critical 139 | } else if (percentage <= this.warningThreshold) { 140 | return '🟡'; // Warning 141 | } 142 | return '🟢'; // Normal 143 | } 144 | 145 | setWarningThreshold(threshold: number): void { 146 | this.warningThreshold = threshold; 147 | } 148 | 149 | setCriticalThreshold(threshold: number): void { 150 | this.criticalThreshold = threshold; 151 | } 152 | 153 | setShowPromptCredits(value: boolean): void { 154 | this.showPromptCredits = value; 155 | } 156 | 157 | setShowPlanName(value: boolean): void { 158 | this.showPlanName = value; 159 | } 160 | 161 | setShowGeminiPro(value: boolean): void { 162 | this.showGeminiPro = value; 163 | } 164 | 165 | setShowGeminiFlash(value: boolean): void { 166 | this.showGeminiFlash = value; 167 | } 168 | 169 | setDisplayStyle(value: 'percentage' | 'progressBar' | 'dots'): void { 170 | this.displayStyle = value; 171 | } 172 | 173 | private updateTooltip(snapshot: QuotaSnapshot): void { 174 | const md = new vscode.MarkdownString(); 175 | md.isTrusted = true; 176 | md.supportHtml = true; 177 | 178 | md.appendMarkdown(`${this.localizationService.t('tooltip.title')}\n\n`); 179 | 180 | if (this.showPromptCredits && snapshot.promptCredits) { 181 | md.appendMarkdown(`${this.localizationService.t('tooltip.credits')}\n`); 182 | // Use a list for better alignment 183 | md.appendMarkdown(`- ${this.localizationService.t('tooltip.available')}: \`${snapshot.promptCredits.available} / ${snapshot.promptCredits.monthly}\`\n`); 184 | md.appendMarkdown(`- ${this.localizationService.t('tooltip.remaining')}: **${snapshot.promptCredits.remainingPercentage.toFixed(1)}%**\n\n`); 185 | } 186 | 187 | // 按模型名称字母顺序排序,使同类模型连续显示 188 | const sortedModels = [...snapshot.models].sort((a, b) => a.label.localeCompare(b.label)); 189 | 190 | if (sortedModels.length > 0) { 191 | md.appendMarkdown(`| ${this.localizationService.t('tooltip.model')} | ${this.localizationService.t('tooltip.status')} | ${this.localizationService.t('tooltip.resetTime')} |\n`); 192 | md.appendMarkdown(`| :--- | :--- | :--- |\n`); 193 | 194 | for (const model of sortedModels) { 195 | const emoji = this.getModelEmoji(model.label); 196 | const name = model.label; // Full name in tooltip 197 | 198 | let status = ''; 199 | if (model.isExhausted) { 200 | status = this.localizationService.t('tooltip.depleted'); 201 | } else if (model.remainingPercentage !== undefined) { 202 | status = `${model.remainingPercentage.toFixed(1)}%`; 203 | } 204 | 205 | md.appendMarkdown(`| ${emoji} ${name} | ${status} | ${model.timeUntilResetFormatted} |\n`); 206 | } 207 | } 208 | 209 | this.statusBarItem.tooltip = md; 210 | } 211 | 212 | private selectModelsToDisplay(models: ModelQuotaInfo[]): ModelQuotaInfo[] { 213 | const result: ModelQuotaInfo[] = []; 214 | 215 | // 1. Claude (非 Thinking 版本) - 必须显示 216 | const claude = models.find(model => this.isClaudeWithoutThinking(model.label)); 217 | if (claude) { 218 | result.push(claude); 219 | } 220 | 221 | // 2. Gemini Pro (Low) - 根据配置决定是否显示 222 | if (this.showGeminiPro) { 223 | const proLow = models.find(model => this.isProLow(model.label)); 224 | if (proLow && !result.includes(proLow)) { 225 | result.push(proLow); 226 | } 227 | } 228 | 229 | // 3. Gemini Flash - 根据配置决定是否显示 230 | if (this.showGeminiFlash) { 231 | const flash = models.find(model => this.isGemini3Flash(model.label)); 232 | if (flash && !result.includes(flash)) { 233 | result.push(flash); 234 | } 235 | } 236 | 237 | return result; 238 | } 239 | 240 | private isProLow(label: string): boolean { 241 | const lower = label.toLowerCase(); 242 | return lower.includes('pro') && lower.includes('low'); 243 | } 244 | 245 | private isGemini3Flash(label: string): boolean { 246 | const lower = label.toLowerCase(); 247 | return lower.includes('gemini') && lower.includes('flash'); 248 | } 249 | 250 | private isClaudeWithoutThinking(label: string): boolean { 251 | const lower = label.toLowerCase(); 252 | return lower.includes('claude') && !lower.includes('thinking'); 253 | } 254 | 255 | private formatNumber(num: number): string { 256 | if (num >= 1000) { 257 | return `${(num / 1000).toFixed(0)}k`; 258 | } 259 | return num.toString(); 260 | } 261 | 262 | private getModelEmoji(label: string): string { 263 | if (label.includes('Claude')) { 264 | return ''; 265 | } 266 | if (label.includes('Gemini') && label.includes('Flash')) { 267 | return ''; 268 | } 269 | if (label.includes('Gemini') && label.includes('Pro')) { 270 | return ''; 271 | } 272 | if (label.includes('GPT')) { 273 | return ''; 274 | } 275 | return ''; 276 | } 277 | 278 | private getShortModelName(label: string): string { 279 | if (label.includes('Claude')) { 280 | return 'Claude'; 281 | } 282 | // Gemini Flash 显示为 G Flash 283 | if (label.includes('Gemini') && label.includes('Flash')) { 284 | return 'G Flash'; 285 | } 286 | // Gemini Pro 显示为 G Pro 287 | if (label.includes('Pro (High)') || label.includes('Pro (Low)') || label.includes('Pro')) { 288 | return 'G Pro'; 289 | } 290 | if (label.includes('GPT')) { 291 | return 'GPT'; 292 | } 293 | 294 | return label.split(' ')[0]; 295 | } 296 | 297 | private getProgressBar(percentage: number): string { 298 | // 确保百分比在 0-100 之间 299 | const p = Math.max(0, Math.min(100, percentage)); 300 | 301 | // 8 blocks for finer granularity: ████████ / ██░░░░░░ 302 | const totalBlocks = 8; 303 | const filledBlocks = Math.round((p / 100) * totalBlocks); 304 | const emptyBlocks = totalBlocks - filledBlocks; 305 | 306 | const filledChar = '█'; 307 | const emptyChar = '░'; 308 | 309 | return `${filledChar.repeat(filledBlocks)}${emptyChar.repeat(emptyBlocks)}`; 310 | } 311 | 312 | private getDotsBar(percentage: number): string { 313 | // 确保百分比在 0-100 之间 314 | const p = Math.max(0, Math.min(100, percentage)); 315 | 316 | // 5 dots for cleaner look: ●●●○○ 317 | const totalDots = 5; 318 | const filledDots = Math.round((p / 100) * totalDots); 319 | const emptyDots = totalDots - filledDots; 320 | 321 | const filledChar = '●'; 322 | const emptyChar = '○'; 323 | 324 | return `${filledChar.repeat(filledDots)}${emptyChar.repeat(emptyDots)}`; 325 | } 326 | 327 | private formatPlanName(rawName: string): string { 328 | // Display as-is from API response 329 | return rawName; 330 | } 331 | 332 | /** 333 | * 显示快速刷新状态 - 在当前配额显示前添加刷新图标 334 | */ 335 | showQuickRefreshing(): void { 336 | if (this.isQuickRefreshing) { 337 | return; // 已经在刷新状态 338 | } 339 | this.isQuickRefreshing = true; 340 | this.refreshStartTime = Date.now(); 341 | 342 | // 在当前文本前添加刷新图标 343 | const currentText = this.statusBarItem.text; 344 | if (!currentText.startsWith('$(sync~spin)')) { 345 | this.statusBarItem.text = `${this.localizationService.t('status.refreshing')}`; 346 | } 347 | // Tooltip handling for string | MarkdownString is tricky, for simple refreshing just keep it simple or append if string 348 | // Simplified for robustness: 349 | this.statusBarItem.tooltip = this.localizationService.t('status.refreshing'); 350 | this.statusBarItem.show(); 351 | } 352 | 353 | showDetecting(): void { 354 | this.statusBarItem.text = this.localizationService.t('status.detecting'); 355 | this.statusBarItem.backgroundColor = undefined; 356 | this.statusBarItem.tooltip = this.localizationService.t('status.detecting'); 357 | this.statusBarItem.show(); 358 | } 359 | 360 | showInitializing(): void { 361 | this.statusBarItem.text = this.localizationService.t('status.initializing'); 362 | this.statusBarItem.backgroundColor = undefined; 363 | this.statusBarItem.tooltip = this.localizationService.t('status.initializing'); 364 | this.statusBarItem.show(); 365 | } 366 | 367 | showFetching(): void { 368 | this.statusBarItem.text = this.localizationService.t('status.fetching'); 369 | this.statusBarItem.backgroundColor = undefined; 370 | this.statusBarItem.tooltip = this.localizationService.t('status.fetching'); 371 | this.statusBarItem.show(); 372 | } 373 | 374 | showRetrying(currentRetry: number, maxRetries: number): void { 375 | this.statusBarItem.text = this.localizationService.t('status.retrying', { current: currentRetry, max: maxRetries }); 376 | this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); 377 | this.statusBarItem.tooltip = this.localizationService.t('status.retrying', { current: currentRetry, max: maxRetries }); 378 | this.statusBarItem.show(); 379 | } 380 | 381 | showError(message: string): void { 382 | this.statusBarItem.text = this.localizationService.t('status.error'); 383 | this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); 384 | this.statusBarItem.tooltip = `${message}\n\n${this.localizationService.t('tooltip.clickToRetry')}`; 385 | // 修改命令为刷新配额 386 | this.statusBarItem.command = 'antigravity-quota-watcher.refreshQuota'; 387 | this.statusBarItem.show(); 388 | } 389 | 390 | clearError(): void { 391 | this.statusBarItem.text = this.localizationService.t('status.fetching'); 392 | this.statusBarItem.backgroundColor = undefined; 393 | this.statusBarItem.tooltip = this.localizationService.t('status.fetching'); 394 | this.statusBarItem.show(); 395 | } 396 | 397 | show(): void { 398 | this.statusBarItem.show(); 399 | } 400 | 401 | hide(): void { 402 | this.statusBarItem.hide(); 403 | } 404 | 405 | dispose(): void { 406 | this.statusBarItem.dispose(); 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /src/quotaService.ts: -------------------------------------------------------------------------------- 1 | import * as https from "https"; 2 | import * as http from "http"; 3 | import { UserStatusResponse, QuotaSnapshot, PromptCreditsInfo, ModelQuotaInfo, ModelConfig } from "./types"; 4 | import { versionInfo } from "./versionInfo"; 5 | 6 | // API 方法枚举 7 | export enum QuotaApiMethod { 8 | COMMAND_MODEL_CONFIG = 'COMMAND_MODEL_CONFIG', 9 | GET_USER_STATUS = 'GET_USER_STATUS' 10 | } 11 | 12 | // 通用请求配置 13 | interface RequestConfig { 14 | path: string; 15 | body: object; 16 | timeout?: number; 17 | } 18 | 19 | // 通用请求方法 20 | async function makeRequest( 21 | config: RequestConfig, 22 | port: number, 23 | httpPort: number | undefined, 24 | csrfToken: string | undefined 25 | ): Promise { 26 | const requestBody = JSON.stringify(config.body); 27 | 28 | const headers: Record = { 29 | 'Content-Type': 'application/json', 30 | 'Content-Length': Buffer.byteLength(requestBody), 31 | 'Connect-Protocol-Version': '1' 32 | }; 33 | 34 | if (csrfToken) { 35 | headers['X-Codeium-Csrf-Token'] = csrfToken; 36 | } else { 37 | throw new Error('Missing CSRF token'); 38 | } 39 | 40 | const doRequest = (useHttps: boolean, targetPort: number) => new Promise((resolve, reject) => { 41 | const options: https.RequestOptions = { 42 | hostname: '127.0.0.1', 43 | port: targetPort, 44 | path: config.path, 45 | method: 'POST', 46 | headers, 47 | rejectUnauthorized: false, 48 | timeout: config.timeout ?? 5000 49 | }; 50 | 51 | console.log(`Request URL: ${useHttps ? 'https' : 'http'}://127.0.0.1:${targetPort}${config.path}`); 52 | 53 | const client = useHttps ? https : http; 54 | const req = client.request(options, (res) => { 55 | let data = ''; 56 | res.on('data', (chunk) => { data += chunk; }); 57 | res.on('end', () => { 58 | if (res.statusCode !== 200) { 59 | let errorDetail = ''; 60 | try { 61 | const errorBody = JSON.parse(data); 62 | errorDetail = errorBody.message || errorBody.error || JSON.stringify(errorBody); 63 | } catch { 64 | errorDetail = data || '(empty response)'; 65 | } 66 | reject(new Error(`HTTP error: ${res.statusCode}, detail: ${errorDetail}`)); 67 | return; 68 | } 69 | try { 70 | resolve(JSON.parse(data)); 71 | } catch (error) { 72 | reject(new Error(`Failed to parse response: ${error}`)); 73 | } 74 | }); 75 | }); 76 | 77 | req.on('error', (error) => reject(error)); 78 | req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); }); 79 | req.write(requestBody); 80 | req.end(); 81 | }); 82 | 83 | // 先尝试 HTTPS,失败后回退到 HTTP 84 | try { 85 | return await doRequest(true, port); 86 | } catch (error: any) { 87 | const msg = (error?.message || '').toLowerCase(); 88 | const shouldRetryHttp = httpPort !== undefined && (error.code === 'EPROTO' || msg.includes('wrong_version_number')); 89 | if (shouldRetryHttp) { 90 | console.warn('HTTPS failed; trying HTTP fallback port:', httpPort); 91 | return await doRequest(false, httpPort); 92 | } 93 | throw error; 94 | } 95 | } 96 | 97 | export class QuotaService { 98 | private readonly GET_USER_STATUS_PATH = '/exa.language_server_pb.LanguageServerService/GetUserStatus'; 99 | private readonly COMMAND_MODEL_CONFIG_PATH = '/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs'; 100 | 101 | // 重试配置 102 | private readonly MAX_RETRY_COUNT = 3; 103 | private readonly RETRY_DELAY_MS = 5000; // 5秒 104 | 105 | // Primary HTTPS Connect port 106 | private port: number; 107 | // Optional HTTP fallback port (extension_server_port) 108 | private httpPort?: number; 109 | private pollingInterval?: NodeJS.Timeout; 110 | private updateCallback?: (snapshot: QuotaSnapshot) => void; 111 | private errorCallback?: (error: Error) => void; 112 | private statusCallback?: (status: 'fetching' | 'retrying', retryCount?: number) => void; 113 | private isFirstAttempt: boolean = true; 114 | private consecutiveErrors: number = 0; 115 | private retryCount: number = 0; 116 | private isRetrying: boolean = false; 117 | private isPollingTransition: boolean = false; // 轮询状态切换锁,防止竞态条件 118 | private csrfToken?: string; 119 | private apiMethod: QuotaApiMethod = QuotaApiMethod.GET_USER_STATUS; 120 | 121 | constructor(port: number, csrfToken?: string, httpPort?: number) { 122 | this.port = port; 123 | this.httpPort = httpPort ?? port; 124 | this.csrfToken = csrfToken; 125 | } 126 | 127 | setApiMethod(method: QuotaApiMethod): void { 128 | this.apiMethod = method; 129 | console.log(`Switching to API: ${method}`); 130 | } 131 | 132 | setAuthInfo(_unused?: any, csrfToken?: string): void { 133 | this.csrfToken = csrfToken; 134 | } 135 | 136 | setPort(port: number): void { 137 | this.port = port; 138 | this.httpPort = this.httpPort ?? port; 139 | this.consecutiveErrors = 0; 140 | this.retryCount = 0; 141 | } 142 | 143 | setPorts(connectPort: number, httpPort?: number): void { 144 | this.port = connectPort; 145 | this.httpPort = httpPort ?? connectPort; 146 | this.consecutiveErrors = 0; 147 | this.retryCount = 0; 148 | } 149 | 150 | onQuotaUpdate(callback: (snapshot: QuotaSnapshot) => void): void { 151 | this.updateCallback = callback; 152 | } 153 | 154 | onError(callback: (error: Error) => void): void { 155 | this.errorCallback = callback; 156 | } 157 | 158 | onStatus(callback: (status: 'fetching' | 'retrying', retryCount?: number) => void): void { 159 | this.statusCallback = callback; 160 | } 161 | 162 | async startPolling(intervalMs: number): Promise { 163 | // 防止快速连续调用导致多个定时器 164 | if (this.isPollingTransition) { 165 | console.log('[QuotaService] Polling transition in progress, skipping...'); 166 | return; 167 | } 168 | 169 | this.isPollingTransition = true; 170 | try { 171 | console.log(`[QuotaService] Starting polling loop every ${intervalMs}ms`); 172 | this.stopPolling(); 173 | await this.fetchQuota(); 174 | this.pollingInterval = setInterval(() => { 175 | this.fetchQuota(); 176 | }, intervalMs); 177 | } finally { 178 | this.isPollingTransition = false; 179 | } 180 | } 181 | 182 | stopPolling(): void { 183 | if (this.pollingInterval) { 184 | console.log('[QuotaService] Stopping polling loop'); 185 | clearInterval(this.pollingInterval); 186 | this.pollingInterval = undefined; 187 | } 188 | } 189 | 190 | /** 191 | * 手动重试获取配额(重置所有状态,重新开始完整流程) 192 | * 成功后会自动恢复轮询 193 | */ 194 | async retryFromError(pollingInterval: number): Promise { 195 | console.log(`Manual quota retry triggered; restarting full flow (interval ${pollingInterval}ms)...`); 196 | // 重置所有错误计数和状态 197 | this.consecutiveErrors = 0; 198 | this.retryCount = 0; 199 | this.isRetrying = false; 200 | this.isFirstAttempt = true; 201 | 202 | // 先停止现有轮询 203 | this.stopPolling(); 204 | 205 | // 执行一次获取,如果成功会自动开启轮询 206 | await this.fetchQuota(); 207 | 208 | // 如果获取成功(consecutiveErrors为0),启动轮询 209 | if (this.consecutiveErrors === 0) { 210 | console.log('Fetch succeeded, starting polling...'); 211 | this.pollingInterval = setInterval(() => { 212 | this.fetchQuota(); 213 | }, pollingInterval); 214 | } else { 215 | console.log('Fetch failed, keeping polling stopped'); 216 | } 217 | } 218 | 219 | /** 220 | * 立即刷新配额(保持轮询不中断) 221 | * 用于用户手动触发快速刷新,不会重置错误状态 222 | */ 223 | async quickRefresh(): Promise { 224 | console.log('Triggering immediate quota refresh...'); 225 | // 直接调用内部获取方法,绕过 isRetrying 检查 226 | await this.doFetchQuota(); 227 | } 228 | 229 | private async fetchQuota(): Promise { 230 | // 如果正在重试中,跳过本次调用 231 | if (this.isRetrying) { 232 | console.log('Currently retrying; skipping this polling run...'); 233 | return; 234 | } 235 | 236 | await this.doFetchQuota(); 237 | } 238 | 239 | /** 240 | * 实际执行配额获取的内部方法 241 | * quickRefresh 和 fetchQuota 都调用此方法 242 | */ 243 | private async doFetchQuota(): Promise { 244 | console.log(`Starting quota fetch with method ${this.apiMethod} (firstAttempt=${this.isFirstAttempt})...`); 245 | 246 | // 通知状态: 正在获取 (仅首次) 247 | if (this.statusCallback && this.isFirstAttempt) { 248 | this.statusCallback('fetching'); 249 | } 250 | 251 | try { 252 | // 注意: 登录状态检测已禁用 253 | // 原因: GetUnleashData API 需要完整的认证上下文(API key等),插件无法获取 254 | // 如果用户未登录,获取配额时会自然失败并显示错误信息 255 | // 256 | // 保留原代码供参考: 257 | // const isLoggedIn = await this.checkLoginStatus(); 258 | // if (!isLoggedIn) { 259 | // console.warn('用户未登录,无法获取配额信息'); 260 | // if (this.loginStatusCallback) { 261 | // this.loginStatusCallback(false); 262 | // } 263 | // this.consecutiveErrors = 0; 264 | // this.retryCount = 0; 265 | // this.isFirstAttempt = false; 266 | // return; 267 | // } 268 | 269 | let snapshot: QuotaSnapshot; 270 | switch (this.apiMethod) { 271 | case QuotaApiMethod.GET_USER_STATUS: { 272 | console.log('Using GetUserStatus API'); 273 | const userStatusResponse = await this.makeGetUserStatusRequest(); 274 | const invalid1 = this.getInvalidCodeInfo(userStatusResponse); 275 | if (invalid1) { 276 | console.error('Response code invalid; skipping update', invalid1); 277 | return; 278 | } 279 | snapshot = this.parseGetUserStatusResponse(userStatusResponse); 280 | break; 281 | } 282 | case QuotaApiMethod.COMMAND_MODEL_CONFIG: 283 | default: { 284 | console.log('Using CommandModelConfig API (recommended)'); 285 | const configResponse = await this.makeCommandModelConfigsRequest(); 286 | const invalid2 = this.getInvalidCodeInfo(configResponse); 287 | if (invalid2) { 288 | console.error('Response code invalid; skipping update', invalid2); 289 | return; 290 | } 291 | snapshot = this.parseCommandModelConfigsResponse(configResponse); 292 | break; 293 | } 294 | } 295 | 296 | // 成功获取配额,重置错误计数和重试计数 297 | this.consecutiveErrors = 0; 298 | this.retryCount = 0; 299 | this.isFirstAttempt = false; 300 | 301 | const modelCount = snapshot.models?.length ?? 0; 302 | const hasPromptCredits = Boolean(snapshot.promptCredits); 303 | console.log(`[QuotaService] Snapshot ready: models=${modelCount}, promptCredits=${hasPromptCredits}`); 304 | 305 | if (this.updateCallback) { 306 | this.updateCallback(snapshot); 307 | } else { 308 | console.warn('updateCallback is not registered'); 309 | } 310 | } catch (error: any) { 311 | this.consecutiveErrors++; 312 | console.error(`Quota fetch failed (attempt ${this.consecutiveErrors}):`, error.message); 313 | if (error?.stack) { 314 | console.error('Stack:', error.stack); 315 | } 316 | 317 | // 如果还没达到最大重试次数,进行延迟重试 318 | if (this.retryCount < this.MAX_RETRY_COUNT) { 319 | this.retryCount++; 320 | this.isRetrying = true; 321 | console.log(`Retry ${this.retryCount} scheduled in ${this.RETRY_DELAY_MS / 1000} seconds...`); 322 | 323 | // 通知状态: 正在重试 324 | if (this.statusCallback) { 325 | this.statusCallback('retrying', this.retryCount); 326 | } 327 | 328 | setTimeout(async () => { 329 | this.isRetrying = false; 330 | await this.fetchQuota(); 331 | }, this.RETRY_DELAY_MS); 332 | return; 333 | } 334 | 335 | // 达到最大重试次数,停止轮询 336 | console.error(`Reached max retry count (${this.MAX_RETRY_COUNT}); stopping polling`); 337 | this.stopPolling(); // 停止定时轮询 338 | 339 | if (this.errorCallback) { 340 | this.errorCallback(error as Error); 341 | } 342 | } 343 | } 344 | 345 | private async makeGetUserStatusRequest(): Promise { 346 | console.log('Using CSRF token:', this.csrfToken ? '[present]' : '[missing]'); 347 | return makeRequest( 348 | { 349 | path: this.GET_USER_STATUS_PATH, 350 | body: { 351 | metadata: { 352 | ideName: 'antigravity', 353 | extensionName: 'antigravity', 354 | ideVersion: versionInfo.getIdeVersion(), 355 | locale: 'en' 356 | } 357 | } 358 | }, 359 | this.port, 360 | this.httpPort, 361 | this.csrfToken 362 | ); 363 | } 364 | 365 | private async makeCommandModelConfigsRequest(): Promise { 366 | console.log('Using CSRF token:', this.csrfToken ? '[present]' : '[missing]'); 367 | return makeRequest( 368 | { 369 | path: this.COMMAND_MODEL_CONFIG_PATH, 370 | body: { 371 | metadata: { 372 | ideName: 'antigravity', 373 | extensionName: 'antigravity', 374 | locale: 'en' 375 | } 376 | } 377 | }, 378 | this.port, 379 | this.httpPort, 380 | this.csrfToken 381 | ); 382 | } 383 | 384 | private parseCommandModelConfigsResponse(response: any): QuotaSnapshot { 385 | const modelConfigs = response?.clientModelConfigs || []; 386 | const models: ModelQuotaInfo[] = modelConfigs 387 | .filter((config: any) => config.quotaInfo) 388 | .map((config: any) => this.parseModelQuota(config)); 389 | 390 | return { 391 | timestamp: new Date(), 392 | promptCredits: undefined, 393 | models, 394 | planName: undefined // CommandModelConfig API doesn't usually return plan info 395 | }; 396 | } 397 | 398 | private parseGetUserStatusResponse(response: UserStatusResponse): QuotaSnapshot { 399 | if (!response || !response.userStatus) { 400 | throw new Error('API response format is invalid; missing userStatus'); 401 | } 402 | 403 | const userStatus = response.userStatus; 404 | const planStatus = userStatus.planStatus; 405 | const modelConfigs = userStatus.cascadeModelConfigData?.clientModelConfigs || []; 406 | 407 | const monthlyCreditsRaw = planStatus?.planInfo?.monthlyPromptCredits; 408 | const availableCreditsRaw = planStatus?.availablePromptCredits; 409 | 410 | const monthlyCredits = monthlyCreditsRaw !== undefined ? Number(monthlyCreditsRaw) : undefined; 411 | const availableCredits = availableCreditsRaw !== undefined ? Number(availableCreditsRaw) : undefined; 412 | 413 | const promptCredits: PromptCreditsInfo | undefined = 414 | planStatus && monthlyCredits !== undefined && monthlyCredits > 0 && availableCredits !== undefined 415 | ? { 416 | available: availableCredits, 417 | monthly: monthlyCredits, 418 | usedPercentage: ((monthlyCredits - availableCredits) / monthlyCredits) * 100, 419 | remainingPercentage: (availableCredits / monthlyCredits) * 100 420 | } 421 | : undefined; 422 | 423 | const models: ModelQuotaInfo[] = modelConfigs 424 | .filter(config => config.quotaInfo) 425 | .map(config => this.parseModelQuota(config)); 426 | 427 | const planName = planStatus?.planInfo?.planName; 428 | 429 | return { 430 | timestamp: new Date(), 431 | promptCredits, 432 | models, 433 | planName 434 | }; 435 | } 436 | 437 | private parseModelQuota(config: any): ModelQuotaInfo { 438 | const quotaInfo = config.quotaInfo; 439 | const remainingFraction = quotaInfo?.remainingFraction; 440 | const resetTime = new Date(quotaInfo.resetTime); 441 | const timeUntilReset = resetTime.getTime() - Date.now(); 442 | 443 | return { 444 | label: config.label, 445 | modelId: config.modelOrAlias.model, 446 | remainingFraction, 447 | remainingPercentage: remainingFraction !== undefined ? remainingFraction * 100 : undefined, 448 | isExhausted: remainingFraction === undefined || remainingFraction === 0, 449 | resetTime, 450 | timeUntilReset, 451 | timeUntilResetFormatted: this.formatTimeUntilReset(timeUntilReset) 452 | }; 453 | } 454 | 455 | private formatTimeUntilReset(ms: number): string { 456 | if (ms <= 0) { 457 | return 'Expired'; 458 | } 459 | 460 | const seconds = Math.floor(ms / 1000); 461 | const minutes = Math.floor(seconds / 60); 462 | const hours = Math.floor(minutes / 60); 463 | const days = Math.floor(hours / 24); 464 | 465 | if (days > 0) { 466 | return `${days}d${hours % 24}h from now`; 467 | } else if (hours > 0) { 468 | return `${hours}h ${minutes % 60}m from now`; 469 | } else if (minutes > 0) { 470 | return `${minutes}m ${seconds % 60}s from now`; 471 | } 472 | return `${seconds}s from now`; 473 | } 474 | 475 | private getInvalidCodeInfo(response: any): { code: any; message?: any } | null { 476 | const code = response?.code; 477 | if (code === undefined || code === null) { 478 | return null; 479 | } 480 | 481 | const okValues = [0, '0', 'OK', 'Ok', 'ok', 'success', 'SUCCESS']; 482 | if (okValues.includes(code)) { 483 | return null; 484 | } 485 | 486 | return { code, message: response?.message }; 487 | } 488 | 489 | dispose(): void { 490 | this.stopPolling(); 491 | } 492 | } 493 | --------------------------------------------------------------------------------