From 0dd59cd210b38d600894c18f782783d8049289c4 Mon Sep 17 00:00:00 2001 From: jinuk-rarajob Date: Wed, 20 May 2026 15:25:17 +0900 Subject: [PATCH 1/4] feat: add Antigravity IDE support --- README.md | 1 + docs/providers/antigravity-ide.md | 52 ++++ plugins/antigravity-ide/icon.svg | 3 + plugins/antigravity-ide/plugin.js | 238 +++++++++++++++ plugins/antigravity-ide/plugin.json | 14 + plugins/antigravity-ide/plugin.test.js | 401 +++++++++++++++++++++++++ src/lib/settings.test.ts | 16 +- src/lib/settings.ts | 35 ++- 8 files changed, 758 insertions(+), 2 deletions(-) create mode 100644 docs/providers/antigravity-ide.md create mode 100644 plugins/antigravity-ide/icon.svg create mode 100644 plugins/antigravity-ide/plugin.js create mode 100644 plugins/antigravity-ide/plugin.json create mode 100644 plugins/antigravity-ide/plugin.test.js diff --git a/README.md b/README.md index d992761a..91402885 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Amp**](docs/providers/amp.md) / free tier, bonus, credits - [**Antigravity**](docs/providers/antigravity.md) / all models +- [**Antigravity IDE**](docs/providers/antigravity-ide.md) / all models - [**Claude**](docs/providers/claude.md) / session, weekly, extra usage, local token usage (ccusage) - [**Codex**](docs/providers/codex.md) / session, weekly, reviews, credits - [**Copilot**](docs/providers/copilot.md) / premium, chat, completions diff --git a/docs/providers/antigravity-ide.md b/docs/providers/antigravity-ide.md new file mode 100644 index 00000000..7521e925 --- /dev/null +++ b/docs/providers/antigravity-ide.md @@ -0,0 +1,52 @@ +# Antigravity IDE + +> Antigravity IDE is the standalone 2.0 version of [Antigravity](antigravity.md). Shares the same Codeium language server binary and Connect-RPC protocol. + +## Overview + +- **Vendor:** Google +- **Protocol:** Connect RPC v1 (JSON over HTTP) on local language server +- **Service:** `exa.language_server_pb.LanguageServerService` +- **Auth:** CSRF token from process args +- **Quota:** fraction (0.0–1.0, where 1.0 = 100% remaining) +- **Quota window:** 5 hours +- **Requires:** Antigravity IDE running (language server process) + +## Differences from Antigravity + +| | Antigravity | Antigravity IDE | +|---|---|---| +| App bundle | `Antigravity.app` | `Antigravity IDE.app` | +| LS marker (`--app_data_dir`) | `antigravity` | `antigravity-ide` | +| State DB path | `~/Library/Application Support/Antigravity/...` | `~/Library/Application Support/Antigravity IDE/...` | +| OAuth in SQLite | ✅ `antigravityUnifiedStateSync.oauthToken` | ❌ Not present | +| Cloud Code API fallback | ✅ | ❌ (no OAuth tokens) | + +## Discovery + +Same as [Antigravity](antigravity.md#discovery), but with marker `antigravity-ide`: + +```bash +# Find process +ps -ax -o pid=,command= | grep 'language_server_macos.*antigravity-ide' +# Match: --app_data_dir antigravity-ide +``` + +## Endpoints + +Identical to [Antigravity](antigravity.md#endpoints) — same LS binary, same RPC service: + +- `GetUserStatus` (primary) +- `GetCommandModelConfigs` (fallback) + +Metadata uses `ideName: "antigravity-ide"` and `extensionName: "antigravity-ide"`. + +## Plugin Strategy + +1. Discover LS process via `ctx.host.ls.discover()` with marker `antigravity-ide` +2. Probe ports with `GetUnleashData` to find the Connect-RPC endpoint +3. Call `GetUserStatus` for plan name + per-model quota +4. Fall back to `GetCommandModelConfigs` if `GetUserStatus` fails +5. If LS not found or all calls fail: error "Start Antigravity IDE and try again." + +No Cloud Code API fallback — Antigravity IDE's SQLite does not contain OAuth tokens. diff --git a/plugins/antigravity-ide/icon.svg b/plugins/antigravity-ide/icon.svg new file mode 100644 index 00000000..4794cc58 --- /dev/null +++ b/plugins/antigravity-ide/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/antigravity-ide/plugin.js b/plugins/antigravity-ide/plugin.js new file mode 100644 index 00000000..d6983d84 --- /dev/null +++ b/plugins/antigravity-ide/plugin.js @@ -0,0 +1,238 @@ +(function () { + var LS_SERVICE = "exa.language_server_pb.LanguageServerService" + var CC_MODEL_BLACKLIST = { + "MODEL_CHAT_20706": true, + "MODEL_CHAT_23310": true, + "MODEL_GOOGLE_GEMINI_2_5_FLASH": true, + "MODEL_GOOGLE_GEMINI_2_5_FLASH_THINKING": true, + "MODEL_GOOGLE_GEMINI_2_5_FLASH_LITE": true, + "MODEL_GOOGLE_GEMINI_2_5_PRO": true, + "MODEL_PLACEHOLDER_M19": true, + "MODEL_PLACEHOLDER_M9": true, + "MODEL_PLACEHOLDER_M12": true, + } + + // --- LS discovery --- + + function discoverLs(ctx) { + return ctx.host.ls.discover({ + processName: "language_server_macos", + markers: ["antigravity-ide"], + csrfFlag: "--csrf_token", + portFlag: "--extension_server_port", + }) + } + + function probePort(ctx, scheme, port, csrf) { + ctx.host.http.request({ + method: "POST", + url: scheme + "://127.0.0.1:" + port + "/" + LS_SERVICE + "/GetUnleashData", + headers: { + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + "x-codeium-csrf-token": csrf, + }, + bodyText: JSON.stringify({ + context: { + properties: { + devMode: "false", + extensionVersion: "unknown", + ide: "antigravity-ide", + ideVersion: "unknown", + os: "macos", + }, + }, + }), + timeoutMs: 5000, + dangerouslyIgnoreTls: scheme === "https", + }) + // Any HTTP response means this port is alive (even 400 validation errors). + return true + } + + function findWorkingPort(ctx, discovery) { + var ports = discovery.ports || [] + for (var i = 0; i < ports.length; i++) { + var port = ports[i] + // Try HTTPS first (LS may use self-signed cert), then HTTP + try { if (probePort(ctx, "https", port, discovery.csrf)) return { port: port, scheme: "https" } } catch (e) { /* ignore */ } + try { if (probePort(ctx, "http", port, discovery.csrf)) return { port: port, scheme: "http" } } catch (e) { /* ignore */ } + ctx.host.log.info("port " + port + " probe failed on both schemes") + } + if (discovery.extensionPort) return { port: discovery.extensionPort, scheme: "http" } + return null + } + + function callLs(ctx, port, scheme, csrf, method, body) { + var resp = ctx.host.http.request({ + method: "POST", + url: scheme + "://127.0.0.1:" + port + "/" + LS_SERVICE + "/" + method, + headers: { + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + "x-codeium-csrf-token": csrf, + }, + bodyText: JSON.stringify(body || {}), + timeoutMs: 10000, + dangerouslyIgnoreTls: scheme === "https", + }) + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("callLs " + method + " returned " + resp.status) + return null + } + return ctx.util.tryParseJson(resp.bodyText) + } + + // --- Line builders --- + + function normalizeLabel(label) { + // "Gemini 3 Pro (High)" -> "Gemini 3 Pro" + return label.replace(/\s*\([^)]*\)\s*$/, "").trim() + } + + function poolLabel(normalizedLabel) { + var lower = normalizedLabel.toLowerCase() + if (lower.indexOf("gemini") !== -1 && lower.indexOf("pro") !== -1) return "Gemini Pro" + if (lower.indexOf("gemini") !== -1 && lower.indexOf("flash") !== -1) return "Gemini Flash" + // All non-Gemini models (Claude, GPT-OSS, etc.) share a single quota pool + return "Claude" + } + + function modelSortKey(label) { + var lower = label.toLowerCase() + // Gemini Pro variants first, then other Gemini, then Claude Opus, then other Claude, then rest + if (lower.indexOf("gemini") !== -1 && lower.indexOf("pro") !== -1) return "0a_" + label + if (lower.indexOf("gemini") !== -1) return "0b_" + label + if (lower.indexOf("claude") !== -1 && lower.indexOf("opus") !== -1) return "1a_" + label + if (lower.indexOf("claude") !== -1) return "1b_" + label + return "2_" + label + } + + var QUOTA_PERIOD_MS = 5 * 60 * 60 * 1000 // 5 hours + + function modelLine(ctx, label, remainingFraction, resetTime) { + var clamped = Math.max(0, Math.min(1, remainingFraction)) + var used = Math.round((1 - clamped) * 100) + return ctx.line.progress({ + label: label, + used: used, + limit: 100, + format: { kind: "percent" }, + resetsAt: resetTime || undefined, + periodDurationMs: QUOTA_PERIOD_MS, + }) + } + + function buildModelLines(ctx, configs) { + var deduped = {} + for (var i = 0; i < configs.length; i++) { + var c = configs[i] + var label = (typeof c.label === "string") ? c.label.trim() : "" + if (!label) continue + var qi = c.quotaInfo + var frac = (qi && typeof qi.remainingFraction === "number") ? qi.remainingFraction : 0 + var rtime = (qi && qi.resetTime) || undefined + var pool = poolLabel(normalizeLabel(label)) + if (!deduped[pool] || frac < deduped[pool].remainingFraction) { + deduped[pool] = { + label: pool, + remainingFraction: frac, + resetTime: rtime, + } + } + } + + var models = [] + var keys = Object.keys(deduped) + for (var i = 0; i < keys.length; i++) { + var m = deduped[keys[i]] + m.sortKey = modelSortKey(m.label) + models.push(m) + } + + models.sort(function (a, b) { + return a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0 + }) + + var lines = [] + for (var i = 0; i < models.length; i++) { + lines.push(modelLine(ctx, models[i].label, models[i].remainingFraction, models[i].resetTime)) + } + return lines + } + + // --- Probe --- + + function probe(ctx) { + var discovery = discoverLs(ctx) + if (!discovery) throw "Start Antigravity IDE and try again." + + var found = findWorkingPort(ctx, discovery) + if (!found) throw "Start Antigravity IDE and try again." + + ctx.host.log.info("using LS at " + found.scheme + "://127.0.0.1:" + found.port) + + var metadata = { + ideName: "antigravity-ide", + extensionName: "antigravity-ide", + ideVersion: "unknown", + locale: "en", + } + + // Try GetUserStatus first, fall back to GetCommandModelConfigs + var data = null + try { + data = callLs(ctx, found.port, found.scheme, discovery.csrf, "GetUserStatus", { metadata: metadata }) + } catch (e) { + ctx.host.log.warn("GetUserStatus threw: " + String(e)) + } + var hasUserStatus = data && data.userStatus + + if (!hasUserStatus) { + ctx.host.log.warn("GetUserStatus failed, trying GetCommandModelConfigs") + data = callLs(ctx, found.port, found.scheme, discovery.csrf, "GetCommandModelConfigs", { metadata: metadata }) + } + + // Parse model configs + var configs + if (hasUserStatus) { + configs = (data.userStatus.cascadeModelConfigData || {}).clientModelConfigs || [] + } else if (data && data.clientModelConfigs) { + configs = data.clientModelConfigs + } else { + throw "Start Antigravity IDE and try again." + } + + var filtered = [] + for (var j = 0; j < configs.length; j++) { + var mid = configs[j].modelOrAlias && configs[j].modelOrAlias.model + if (mid && CC_MODEL_BLACKLIST[mid]) continue + filtered.push(configs[j]) + } + + var lines = buildModelLines(ctx, filtered) + if (lines.length === 0) throw "Start Antigravity IDE and try again." + + var plan = null + if (hasUserStatus) { + // Prefer userTier.name (Google's own subscription system) over the legacy + // planInfo.planName field inherited from Windsurf/Codeium, which always + // returns "Pro" for all paid tiers including Google AI Ultra. + var ut = data.userStatus.userTier + var userTierName = + ut && typeof ut.name === "string" && ut.name.trim() ? ut.name.trim() : null + if (userTierName) { + plan = userTierName + } else { + var ps = data.userStatus.planStatus || {} + var pi = ps.planInfo || {} + plan = + typeof pi.planName === "string" && pi.planName.trim() ? pi.planName.trim() : null + } + } + + return { plan: plan, lines: lines } + } + + globalThis.__openusage_plugin = { id: "antigravity-ide", probe: probe } +})() diff --git a/plugins/antigravity-ide/plugin.json b/plugins/antigravity-ide/plugin.json new file mode 100644 index 00000000..1e6d5546 --- /dev/null +++ b/plugins/antigravity-ide/plugin.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "antigravity-ide", + "name": "Antigravity IDE", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#4285F4", + "lines": [ + { "type": "progress", "label": "Gemini Pro", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Gemini Flash", "scope": "overview" }, + { "type": "progress", "label": "Claude", "scope": "overview" } + ] +} diff --git a/plugins/antigravity-ide/plugin.test.js b/plugins/antigravity-ide/plugin.test.js new file mode 100644 index 00000000..b56c7405 --- /dev/null +++ b/plugins/antigravity-ide/plugin.test.js @@ -0,0 +1,401 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +// --- Fixtures --- + +function makeDiscovery(overrides) { + return Object.assign( + { pid: 12345, csrf: "test-csrf-token", ports: [42001, 42002], extensionPort: null }, + overrides + ) +} + +function makeUserStatusResponse(overrides) { + var base = { + userStatus: { + planStatus: { + planInfo: { + planName: "Pro", + monthlyPromptCredits: 50000, + monthlyFlowCredits: 150000, + monthlyFlexCreditPurchaseAmount: 25000, + }, + availablePromptCredits: 500, + availableFlowCredits: 100, + usedFlexCredits: 5000, + }, + cascadeModelConfigData: { + clientModelConfigs: [ + { + label: "Gemini 3.1 Pro (High)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M37" }, + quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Gemini 3.1 Pro (Low)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M36" }, + quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Gemini 3 Flash", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M18" }, + quotaInfo: { remainingFraction: 1.0, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Claude Sonnet 4.6 (Thinking)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M35" }, + quotaInfo: { resetTime: "2026-02-26T15:23:41Z" }, + }, + { + label: "Claude Opus 4.6 (Thinking)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M26" }, + quotaInfo: { resetTime: "2026-02-26T15:23:41Z" }, + }, + { + label: "GPT-OSS 120B (Medium)", + modelOrAlias: { model: "MODEL_OPENAI_GPT_OSS_120B_MEDIUM" }, + quotaInfo: { resetTime: "2026-02-26T15:23:41Z" }, + }, + ], + }, + }, + } + if (overrides) { + if (overrides.planName !== undefined) base.userStatus.planStatus.planInfo.planName = overrides.planName + if (overrides.configs !== undefined) base.userStatus.cascadeModelConfigData.clientModelConfigs = overrides.configs + if (overrides.planStatus !== undefined) base.userStatus.planStatus = overrides.planStatus + if (overrides.userTier !== undefined) base.userStatus.userTier = overrides.userTier + } + return base +} + +function setupLsMock(ctx, discovery, responseBody) { + ctx.host.ls.discover.mockReturnValue(discovery) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + return { status: 200, bodyText: JSON.stringify(responseBody) } + }) +} + +// --- Tests --- + +describe("antigravity-ide plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("registers with correct id", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(null) + const plugin = await loadPlugin() + expect(plugin.id).toBe("antigravity-ide") + }) + + it("throws when LS not found", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(null) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Start Antigravity IDE and try again.") + }) + + it("throws when no working port found", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery()) + ctx.host.http.request.mockImplementation(() => { + throw new Error("connection refused") + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Start Antigravity IDE and try again.") + }) + + it("throws when both GetUserStatus and GetCommandModelConfigs fail", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery()) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + return { status: 500, bodyText: "" } + }) + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Start Antigravity IDE and try again.") + }) + + it("returns models + plan from GetUserStatus", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + const labels = result.lines.map((l) => l.label) + expect(labels).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) + }) + + it("deduplicates models by normalized label (keeps worst-case fraction)", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const pro = result.lines.find((l) => l.label === "Gemini Pro") + expect(pro).toBeTruthy() + expect(pro.used).toBe(20) // (1 - 0.8) * 100 + }) + + it("orders: Gemini (Pro, Flash), Claude (Opus, Sonnet), then others", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const labels = result.lines.map((l) => l.label) + expect(labels).toEqual(["Gemini Pro", "Gemini Flash", "Claude"]) + }) + + it("falls back to GetCommandModelConfigs when GetUserStatus fails", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery()) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (String(opts.url).includes("GetUserStatus")) { + return { status: 500, bodyText: "" } + } + if (String(opts.url).includes("GetCommandModelConfigs")) { + return { + status: 200, + bodyText: JSON.stringify({ + clientModelConfigs: [ + { + label: "Gemini 3 Pro (High)", + modelOrAlias: { model: "M7" }, + quotaInfo: { remainingFraction: 0.6, resetTime: "2026-02-08T09:10:56Z" }, + }, + ], + }), + } + } + return { status: 500, bodyText: "" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBeNull() + const pro = result.lines.find((l) => l.label === "Gemini Pro") + expect(pro).toBeTruthy() + expect(pro.used).toBe(40) // (1 - 0.6) * 100 + }) + + it("uses extension port as fallback when all ports fail probing", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery({ ports: [99999], extensionPort: 42010 })) + + let usedPort = null + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData") && url.includes("99999")) { + throw new Error("refused") + } + if (url.includes("GetUserStatus")) { + usedPort = parseInt(url.match(/:(\d+)\//)[1]) + return { + status: 200, + bodyText: JSON.stringify(makeUserStatusResponse()), + } + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(usedPort).toBe(42010) + expect(result.lines.length).toBeGreaterThan(0) + }) + + it("treats models with no quotaInfo as depleted (100% used)", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ + configs: [ + { label: "Gemini 3 Pro (High)", modelOrAlias: { model: "M7" }, quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T09:10:56Z" } }, + { label: "Claude Opus 4.6 (Thinking)", modelOrAlias: { model: "M26" } }, + ], + }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const claude = result.lines.find((l) => l.label === "Claude") + expect(claude).toBeTruthy() + expect(claude.used).toBe(100) + expect(claude.limit).toBe(100) + expect(claude.resetsAt).toBeUndefined() + expect(result.lines.find((l) => l.label === "Gemini Pro")).toBeTruthy() + }) + + it("skips configs with missing or empty labels", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ + configs: [ + { label: "Gemini 3 Pro (High)", modelOrAlias: { model: "M7" }, quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T09:10:56Z" } }, + { label: "", modelOrAlias: { model: "M99" }, quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" } }, + { modelOrAlias: { model: "M100" }, quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T09:10:56Z" } }, + ], + }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.length).toBe(1) + expect(result.lines[0].label).toBe("Gemini Pro") + }) + + it("includes resetsAt on model lines", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const pro = result.lines.find((l) => l.label === "Gemini Pro") + expect(pro.resetsAt).toBe("2026-02-08T09:10:56Z") + }) + + it("clamps remainingFraction outside 0-1 range", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ + configs: [ + { label: "Gemini Pro (Over)", modelOrAlias: { model: "M1" }, quotaInfo: { remainingFraction: 1.5, resetTime: "2026-02-08T09:10:56Z" } }, + { label: "Gemini Flash (Neg)", modelOrAlias: { model: "M2" }, quotaInfo: { remainingFraction: -0.3, resetTime: "2026-02-08T09:10:56Z" } }, + ], + }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const over = result.lines.find((l) => l.label === "Gemini Pro") + const neg = result.lines.find((l) => l.label === "Gemini Flash") + expect(over.used).toBe(0) // clamped to 1.0 → 0% used + expect(neg.used).toBe(100) // clamped to 0.0 → 100% used + }) + + it("probes ports with HTTPS first, then HTTP, picks first success", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(makeDiscovery({ ports: [10001, 10002] })) + + const probed = [] + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + const port = parseInt(url.match(/:(\d+)\//)[1]) + const scheme = url.startsWith("https") ? "https" : "http" + probed.push({ port, scheme }) + if (port === 10002 && scheme === "https") return { status: 200, bodyText: "{}" } + throw new Error("refused") + } + return { status: 200, bodyText: JSON.stringify(makeUserStatusResponse()) } + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + expect(probed).toEqual([ + { port: 10001, scheme: "https" }, + { port: 10001, scheme: "http" }, + { port: 10002, scheme: "https" }, + ]) + }) + + it("never sends apiKey in LS metadata", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse() + setupLsMock(ctx, discovery, response) + + let capturedMetadata = null + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("GetUnleashData")) { + return { status: 200, bodyText: "{}" } + } + if (url.includes("GetUserStatus")) { + const body = JSON.parse(opts.bodyText) + capturedMetadata = body.metadata + return { status: 200, bodyText: JSON.stringify(response) } + } + return { status: 200, bodyText: "{}" } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.length).toBeGreaterThan(0) + expect(capturedMetadata).toBeTruthy() + expect(capturedMetadata.apiKey).toBeUndefined() + expect(capturedMetadata.ideName).toBe("antigravity-ide") + expect(capturedMetadata.extensionName).toBe("antigravity-ide") + }) + + it("sends antigravity-ide marker to ls.discover", async () => { + const ctx = makeCtx() + ctx.host.ls.discover.mockReturnValue(null) + const plugin = await loadPlugin() + try { plugin.probe(ctx) } catch (e) { /* expected */ } + expect(ctx.host.ls.discover).toHaveBeenCalledWith({ + processName: "language_server_macos", + markers: ["antigravity-ide"], + csrfFlag: "--csrf_token", + portFlag: "--extension_server_port", + }) + }) + + it("prefers userTier.name over planInfo.planName", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ userTier: { name: "Ultra" } }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.plan).toBe("Ultra") + }) + + it("filters blacklisted model IDs", async () => { + const ctx = makeCtx() + const discovery = makeDiscovery() + const response = makeUserStatusResponse({ + configs: [ + { label: "Gemini 3 Pro (High)", modelOrAlias: { model: "MODEL_PLACEHOLDER_M37" }, quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" } }, + { label: "Blacklisted Model", modelOrAlias: { model: "MODEL_GOOGLE_GEMINI_2_5_PRO" }, quotaInfo: { remainingFraction: 0.5, resetTime: "2026-02-08T09:10:56Z" } }, + ], + }) + setupLsMock(ctx, discovery, response) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + expect(result.lines.length).toBe(1) + expect(result.lines[0].label).toBe("Gemini Pro") + }) +}) diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 1c686863..fcaea820 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -88,7 +88,7 @@ describe("settings", () => { { order: ["b", "b", "c"], disabled: ["c", "a"] }, plugins ) - expect(normalized).toEqual({ order: ["b", "a"], disabled: ["a"] }) + expect(normalized).toEqual({ order: ["a", "b"], disabled: ["a"] }) }) it("auto-disables new non-default plugins", () => { @@ -102,6 +102,20 @@ describe("settings", () => { expect(result.disabled).toEqual(["copilot", "windsurf"]) }) + it("inserts newly added plugins alphabetically relative to existing order", () => { + const plugins: PluginMeta[] = [ + { id: "amp", name: "Amp", iconUrl: "", lines: [] }, + { id: "antigravity", name: "Antigravity", iconUrl: "", lines: [] }, + { id: "antigravity-ide", name: "Antigravity IDE", iconUrl: "", lines: [] }, + { id: "claude", name: "Claude", iconUrl: "", lines: [] }, + ] + const result = normalizePluginSettings( + { order: ["amp", "antigravity", "claude"], disabled: [] }, + plugins + ) + expect(result.order).toEqual(["amp", "antigravity", "antigravity-ide", "claude"]) + }) + it("compares settings equality", () => { const a = { order: ["a"], disabled: [] } const b = { order: ["a"], disabled: [] } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a94d0a7c..945428f1 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -137,11 +137,44 @@ export function normalizePluginSettings( for (const id of knownIds) { if (!seen.has(id)) { seen.add(id); - order.push(id); newlyAdded.push(id); } } + for (const id of newlyAdded) { + const sortedIdx = knownIds.indexOf(id); + let inserted = false; + + // Try to find the closest alphabetical predecessor that is already in `order` + for (let i = sortedIdx - 1; i >= 0; i--) { + const prevId = knownIds[i]; + const idxInOrder = order.indexOf(prevId); + if (idxInOrder !== -1) { + order.splice(idxInOrder + 1, 0, id); + inserted = true; + break; + } + } + + // If not inserted, try to find the closest successor + if (!inserted) { + for (let i = sortedIdx + 1; i < knownIds.length; i++) { + const nextId = knownIds[i]; + const idxInOrder = order.indexOf(nextId); + if (idxInOrder !== -1) { + order.splice(idxInOrder, 0, id); + inserted = true; + break; + } + } + } + + // Fallback: append to the end + if (!inserted) { + order.push(id); + } + } + const disabled = settings.disabled.filter((id) => knownSet.has(id)); for (const id of newlyAdded) { if (!DEFAULT_ENABLED_PLUGINS.has(id) && !disabled.includes(id)) { From 72dc57b11307525cfc9249774afaaee54ae41c02 Mon Sep 17 00:00:00 2001 From: jinuk-rarajob Date: Wed, 20 May 2026 15:26:57 +0900 Subject: [PATCH 2/4] revert: plugin settings alphabetical sorting logic --- src/lib/settings.test.ts | 16 +--------------- src/lib/settings.ts | 35 +---------------------------------- 2 files changed, 2 insertions(+), 49 deletions(-) diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index fcaea820..1c686863 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -88,7 +88,7 @@ describe("settings", () => { { order: ["b", "b", "c"], disabled: ["c", "a"] }, plugins ) - expect(normalized).toEqual({ order: ["a", "b"], disabled: ["a"] }) + expect(normalized).toEqual({ order: ["b", "a"], disabled: ["a"] }) }) it("auto-disables new non-default plugins", () => { @@ -102,20 +102,6 @@ describe("settings", () => { expect(result.disabled).toEqual(["copilot", "windsurf"]) }) - it("inserts newly added plugins alphabetically relative to existing order", () => { - const plugins: PluginMeta[] = [ - { id: "amp", name: "Amp", iconUrl: "", lines: [] }, - { id: "antigravity", name: "Antigravity", iconUrl: "", lines: [] }, - { id: "antigravity-ide", name: "Antigravity IDE", iconUrl: "", lines: [] }, - { id: "claude", name: "Claude", iconUrl: "", lines: [] }, - ] - const result = normalizePluginSettings( - { order: ["amp", "antigravity", "claude"], disabled: [] }, - plugins - ) - expect(result.order).toEqual(["amp", "antigravity", "antigravity-ide", "claude"]) - }) - it("compares settings equality", () => { const a = { order: ["a"], disabled: [] } const b = { order: ["a"], disabled: [] } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 945428f1..a94d0a7c 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -137,41 +137,8 @@ export function normalizePluginSettings( for (const id of knownIds) { if (!seen.has(id)) { seen.add(id); - newlyAdded.push(id); - } - } - - for (const id of newlyAdded) { - const sortedIdx = knownIds.indexOf(id); - let inserted = false; - - // Try to find the closest alphabetical predecessor that is already in `order` - for (let i = sortedIdx - 1; i >= 0; i--) { - const prevId = knownIds[i]; - const idxInOrder = order.indexOf(prevId); - if (idxInOrder !== -1) { - order.splice(idxInOrder + 1, 0, id); - inserted = true; - break; - } - } - - // If not inserted, try to find the closest successor - if (!inserted) { - for (let i = sortedIdx + 1; i < knownIds.length; i++) { - const nextId = knownIds[i]; - const idxInOrder = order.indexOf(nextId); - if (idxInOrder !== -1) { - order.splice(idxInOrder, 0, id); - inserted = true; - break; - } - } - } - - // Fallback: append to the end - if (!inserted) { order.push(id); + newlyAdded.push(id); } } From 83d653a97c716a5a8f7734fc769df69697c9dfa3 Mon Sep 17 00:00:00 2001 From: jinuk-rarajob Date: Wed, 20 May 2026 15:33:45 +0900 Subject: [PATCH 3/4] test: add Gemini 3.5 Flash models to Antigravity IDE mock configs --- plugins/antigravity-ide/plugin.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugins/antigravity-ide/plugin.test.js b/plugins/antigravity-ide/plugin.test.js index b56c7405..987979cb 100644 --- a/plugins/antigravity-ide/plugin.test.js +++ b/plugins/antigravity-ide/plugin.test.js @@ -41,6 +41,16 @@ function makeUserStatusResponse(overrides) { modelOrAlias: { model: "MODEL_PLACEHOLDER_M36" }, quotaInfo: { remainingFraction: 0.8, resetTime: "2026-02-08T09:10:56Z" }, }, + { + label: "Gemini 3.5 Flash (High)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M19" }, + quotaInfo: { remainingFraction: 0.9, resetTime: "2026-02-08T09:10:56Z" }, + }, + { + label: "Gemini 3.5 Flash (Medium)", + modelOrAlias: { model: "MODEL_PLACEHOLDER_M20" }, + quotaInfo: { remainingFraction: 0.95, resetTime: "2026-02-08T09:10:56Z" }, + }, { label: "Gemini 3 Flash", modelOrAlias: { model: "MODEL_PLACEHOLDER_M18" }, From de1706b43dd53d612eb3cc3042662926bd1e664a Mon Sep 17 00:00:00 2001 From: jinuk-rarajob Date: Wed, 20 May 2026 15:42:34 +0900 Subject: [PATCH 4/4] change antigravity ide icon color --- plugins/antigravity-ide/plugin.json | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/plugins/antigravity-ide/plugin.json b/plugins/antigravity-ide/plugin.json index 1e6d5546..837e6e07 100644 --- a/plugins/antigravity-ide/plugin.json +++ b/plugins/antigravity-ide/plugin.json @@ -5,10 +5,23 @@ "version": "0.0.1", "entry": "plugin.js", "icon": "icon.svg", - "brandColor": "#4285F4", + "brandColor": "#000000", "lines": [ - { "type": "progress", "label": "Gemini Pro", "scope": "overview", "primaryOrder": 1 }, - { "type": "progress", "label": "Gemini Flash", "scope": "overview" }, - { "type": "progress", "label": "Claude", "scope": "overview" } + { + "type": "progress", + "label": "Gemini Pro", + "scope": "overview", + "primaryOrder": 1 + }, + { + "type": "progress", + "label": "Gemini Flash", + "scope": "overview" + }, + { + "type": "progress", + "label": "Claude", + "scope": "overview" + } ] -} +} \ No newline at end of file