Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

162 lines
7.8 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
import { unwrapEvaluateResult } from './_actions.js';
// Antigravity exposes the active model via the composer button whose
// aria-label looks like:
// "Select model, current: Gemini 3.5 Flash (Medium)"
// We parse the current model from that aria-label, and switch by clicking
// the button to open the model picker dialog, then matching by visible
// text inside the dialog.
cli({
site: 'antigravity',
name: 'model',
access: 'write',
description: 'Read or switch the active model in Antigravity. Without arguments, reports the current model. With <name> (substring, case-insensitive), switches.',
domain: '127.0.0.1',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'name', required: false, positional: true, help: 'Substring (case-insensitive) of target model name. Omit to read current.' },
{ name: 'list', type: 'boolean', default: false, help: 'List models in the picker (does not switch)' },
],
columns: ['Status', 'Model'],
func: async (page, kwargs) => {
const name = String(kwargs.name || '').trim().toLowerCase();
const listOnly = kwargs.list === true || kwargs.list === 'true';
const normalize = (value) => String(value || '').trim().replace(/\s+/g, ' ').toLowerCase();
// Read current model from button's aria-label.
const current = unwrapEvaluateResult(await page.evaluate(`(function() {
const btn = document.querySelector('button[aria-label^="Select model, current:"]');
if (!btn) return '';
const aria = btn.getAttribute('aria-label') || '';
const m = aria.match(/current:\\s*(.*)$/i);
return m ? m[1].trim() : (btn.textContent || '').trim();
})()`));
if (!current) {
throw selectorError('Antigravity model button (button[aria-label^="Select model, current:"]). Make sure a chat is open in the foreground.');
}
if (!name && !listOnly) {
return [{ Status: 'Active', Model: current }];
}
const namejson = JSON.stringify(name);
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const trigger = document.querySelector('button[aria-label^="Select model, current:"]');
if (!trigger) return { ok: false, reason: 'trigger missing' };
// Open the picker dialog (full pointer chain — radix uses pointer events).
const r = trigger.getBoundingClientRect();
const init = {
bubbles: true, cancelable: true, button: 0, buttons: 1,
clientX: Math.round(r.left + r.width / 2),
clientY: Math.round(r.top + r.height / 2),
};
trigger.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
trigger.dispatchEvent(new MouseEvent('mousedown', init));
trigger.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
trigger.dispatchEvent(new MouseEvent('mouseup', init));
trigger.dispatchEvent(new MouseEvent('click', init));
// Wait for the picker dialog to open. Antigravity renders it as a
// [role="dialog"] or a div with selectable rows (cursor-pointer).
let rows = [];
for (let attempt = 0; attempt < 18; attempt += 1) {
await wait(80);
rows = Array.from(document.querySelectorAll('[role="dialog"] .cursor-pointer, [role="dialog"] [role="option"], [role="dialog"] li, .cursor-pointer'))
.filter((el) => el instanceof HTMLElement && el.offsetParent);
// Filter out rows clearly outside the dialog (e.g. global cursor-pointer in sidebar)
const dialog = document.querySelector('[role="dialog"]');
if (dialog) {
rows = rows.filter((r) => dialog.contains(r));
}
if (rows.length) break;
}
if (!rows.length) {
return { ok: false, reason: 'Model picker dialog did not surface any rows.' };
}
const labels = rows.map((r) => (r.innerText || r.textContent || '').trim().slice(0, 80));
const target = ${namejson};
const listOnly = ${listOnly ? 'true' : 'false'};
if (!target || listOnly) {
// Close picker (Esc) and return list.
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
return { ok: true, labels };
}
const exactMatches = labels
.map((label, index) => ({ label, index }))
.filter((entry) => entry.label.toLowerCase() === target);
const matches = exactMatches.length ? exactMatches : labels
.map((label, index) => ({ label, index }))
.filter((entry) => entry.label.toLowerCase().includes(target));
if (!matches.length) {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
return { ok: false, reason: 'No model matched.', detail: 'wanted=' + target + ' visible=' + JSON.stringify(labels) };
}
if (matches.length > 1) {
document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
return { ok: false, reason: 'Ambiguous model match.', detail: 'wanted=' + target + ' matches=' + JSON.stringify(matches.map((m) => m.label)) };
}
const chosen = rows[matches[0].index];
const chosenLabel = matches[0].label;
const cr = chosen.getBoundingClientRect();
const cinit = {
bubbles: true, cancelable: true, button: 0, buttons: 1,
clientX: Math.round(cr.left + cr.width / 2),
clientY: Math.round(cr.top + cr.height / 2),
};
Promise.resolve().then(() => {
try {
chosen.dispatchEvent(new PointerEvent('pointerdown', { ...cinit, pointerType: 'mouse' }));
chosen.dispatchEvent(new MouseEvent('mousedown', cinit));
chosen.dispatchEvent(new PointerEvent('pointerup', { ...cinit, pointerType: 'mouse' }));
chosen.dispatchEvent(new MouseEvent('mouseup', cinit));
chosen.dispatchEvent(new MouseEvent('click', cinit));
} catch {}
});
return { ok: true, switched: true, chosen: chosenLabel, labels };
})()`));
if (!result.ok) {
if (result.reason === 'Ambiguous model match.') {
throw new ArgumentError(result.detail || 'Ambiguous model match.');
}
throw new CommandExecutionError(result.reason, result.detail || '');
}
if (listOnly) {
return result.labels.map((m) => ({ Status: m.startsWith(current.slice(0, 20)) ? 'Active' : 'Available', Model: m }));
}
await page.wait(0.8);
let verified = '';
for (let attempt = 0; attempt < 8; attempt += 1) {
verified = unwrapEvaluateResult(await page.evaluate(`(function() {
const btn = document.querySelector('button[aria-label^="Select model, current:"]');
if (!btn) return '';
const aria = btn.getAttribute('aria-label') || '';
const m = aria.match(/current:\\s*(.*)$/i);
return m ? m[1].trim() : (btn.textContent || '').trim();
})()`));
if (
normalize(verified)
&& (normalize(result.chosen).includes(normalize(verified)) || normalize(verified).includes(normalize(result.chosen)))
) {
return [{ Status: 'switched', Model: verified }];
}
if (normalize(verified) === normalize(result.chosen)) {
return [{ Status: 'switched', Model: verified }];
}
await page.wait(0.4);
}
throw new CommandExecutionError(
`Could not verify Antigravity model switched to ${result.chosen}.`,
`Read back current model: ${verified || '(empty)'}`,
);
},
});