9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
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
265 lines
12 KiB
JavaScript
265 lines
12 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
|
|
|
|
// Codex Desktop App exposes the active model + reasoning level on a button
|
|
// in the composer bottom toolbar. As of 2026-05-31 the button has no
|
|
// stable aria-label or data-testid — we anchor to it by walking up from
|
|
// the composer contenteditable and finding the button whose visible text
|
|
// matches a known pattern (model version like "5.5"/"5.4" OR reasoning
|
|
// level "Low"/"Medium"/"High"/"Extra High"/"Auto"/"Speed").
|
|
//
|
|
// Clicking the button opens a menu with BOTH:
|
|
// - Reasoning levels: Low / Medium / High / Extra High / Speed / Auto
|
|
// - Model versions: GPT-5.5 / GPT-5.4 / ...
|
|
// Either kind of value can be selected via 'opencli codex model <name>'.
|
|
|
|
const MODEL_BTN_TEXT_RE = /5\.\d|[Ee]xtra [Hh]igh|^High$|^Medium$|^Low$|^Auto$|^Fast$|^Speed$|^Pro$|GPT-/;
|
|
const MODEL_BTN_PATTERN = MODEL_BTN_TEXT_RE.source;
|
|
|
|
function unwrapEvaluateResult(result) {
|
|
if (result && typeof result === 'object' && 'data' in result && 'session' in result) {
|
|
return result.data;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeModelText(value) {
|
|
return String(value ?? '')
|
|
.toLowerCase()
|
|
.replace(/\bgpt[-\s]*/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
const REASONING_OPTIONS = ['extra high', 'medium', 'high', 'low', 'auto', 'fast', 'speed', 'pro'];
|
|
|
|
function extractReasoning(value) {
|
|
return REASONING_OPTIONS.find(option => value === option || value.endsWith(` ${option}`)) || '';
|
|
}
|
|
|
|
function extractModelVersion(value) {
|
|
const match = value.match(/(?:^|\s)(\d+(?:\.\d+)?)(?=\s|$)/);
|
|
return match?.[1] || '';
|
|
}
|
|
|
|
export function findUniqueModelOption(labels, rawName) {
|
|
const name = normalizeModelText(rawName);
|
|
if (!name) {
|
|
throw new ArgumentError('model name cannot be empty');
|
|
}
|
|
const normalized = labels.map((label) => ({ label, normalized: normalizeModelText(label) }));
|
|
const exact = normalized.filter(item => item.normalized === name);
|
|
if (exact.length === 1) {
|
|
return exact[0].label;
|
|
}
|
|
if (exact.length > 1) {
|
|
throw new CommandExecutionError(`Model name "${rawName}" is ambiguous.`, `Matches: ${exact.map(item => item.label).join(', ')}`);
|
|
}
|
|
const partial = normalized.filter(item => item.normalized.includes(name));
|
|
if (partial.length === 1) {
|
|
return partial[0].label;
|
|
}
|
|
if (partial.length > 1) {
|
|
throw new CommandExecutionError(`Model name "${rawName}" is ambiguous.`, `Matches: ${partial.map(item => item.label).join(', ')}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function modelSelectionVerified(current, chosen) {
|
|
const active = normalizeModelText(current);
|
|
const selected = normalizeModelText(chosen);
|
|
if (!active || !selected) {
|
|
return false;
|
|
}
|
|
if (active === selected) {
|
|
return true;
|
|
}
|
|
if (REASONING_OPTIONS.includes(selected)) {
|
|
return extractReasoning(active) === selected;
|
|
}
|
|
const selectedModel = extractModelVersion(selected);
|
|
if (selectedModel) {
|
|
return extractModelVersion(active) === selectedModel;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export const modelCommand = cli({
|
|
site: 'codex',
|
|
name: 'model',
|
|
access: 'write',
|
|
description: 'Read, list, or switch the active model / reasoning level in Codex Desktop. The composer toolbar button toggles a menu that mixes model variants (GPT-5.5, Speed) with reasoning levels (Low/Medium/High/Extra High).',
|
|
domain: 'localhost',
|
|
strategy: Strategy.UI,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'name', required: false, positional: true, help: 'Substring (case-insensitive) of a model / reasoning level to switch to. Omit to read current.' },
|
|
{ name: 'list', type: 'boolean', default: false, help: 'List all menu options (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 patternJson = JSON.stringify(MODEL_BTN_PATTERN);
|
|
|
|
const current = unwrapEvaluateResult(await page.evaluate(`(function() {
|
|
const re = new RegExp(${patternJson});
|
|
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
|
const last = composers[composers.length - 1];
|
|
if (!last) return '';
|
|
let root = last;
|
|
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
|
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
|
const match = btns.find((b) => re.test((b.textContent || '').trim()));
|
|
return match ? (match.textContent || '').trim() : '';
|
|
})()`));
|
|
if (!current) {
|
|
throw selectorError('Codex model button (composer toolbar). Make sure a chat is open.');
|
|
}
|
|
|
|
if (!name && !listOnly) {
|
|
return [{ Status: 'Active', Model: current }];
|
|
}
|
|
|
|
const namejson = JSON.stringify(listOnly ? '' : name);
|
|
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const re = new RegExp(${patternJson});
|
|
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
|
const last = composers[composers.length - 1];
|
|
if (!last) return { ok: false, reason: 'composer not found' };
|
|
let root = last;
|
|
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
|
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
|
const trigger = btns.find((b) => re.test((b.textContent || '').trim()));
|
|
if (!trigger) return { ok: false, reason: 'model trigger button not found in composer' };
|
|
|
|
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));
|
|
|
|
let items = [];
|
|
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
await wait(80);
|
|
items = Array.from(document.querySelectorAll('[role="menuitem"], [role="option"]'))
|
|
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
|
if (items.length) break;
|
|
}
|
|
if (!items.length) {
|
|
return { ok: false, reason: 'Model menu did not open after click.' };
|
|
}
|
|
|
|
function leadingText(el) {
|
|
const clone = el.cloneNode(true);
|
|
clone.querySelectorAll('kbd').forEach((k) => k.remove());
|
|
return (clone.textContent || '').trim();
|
|
}
|
|
// Filter unrelated Chat-actions menu items so they don't pollute
|
|
// the model list — Codex sometimes shares the menu root with
|
|
// 'Pin chat / Rename chat / Archive chat / Open side chat / Copy /
|
|
// Fork / Add automation… / Open in new window'.
|
|
const CHAT_ACTION_LABELS = new Set([
|
|
'Pin chat', 'Unpin chat', 'Rename chat', 'Archive chat',
|
|
'Open side chat', 'Copy', 'Fork', 'Add automation…', 'Open in new window',
|
|
]);
|
|
const modelItems = items.filter((it) => {
|
|
const t = leadingText(it).replace(/[⌥⌘⌃⇧⏎].*$/, '').trim();
|
|
return t && !CHAT_ACTION_LABELS.has(t);
|
|
});
|
|
|
|
const labels = modelItems.map(leadingText);
|
|
const target = ${namejson};
|
|
if (!target) {
|
|
document.body.click();
|
|
return { ok: true, labels };
|
|
}
|
|
const wanted = target.replace(/\\bgpt[-\\s]*/g, '').replace(/\\s+/g, ' ').trim();
|
|
const normalizedLabels = labels.map((l) => l.toLowerCase().replace(/\\bgpt[-\\s]*/g, '').replace(/\\s+/g, ' ').trim());
|
|
let matches = normalizedLabels
|
|
.map((label, index) => ({ label, index }))
|
|
.filter((item) => item.label === wanted);
|
|
if (matches.length === 0) {
|
|
matches = normalizedLabels
|
|
.map((label, index) => ({ label, index }))
|
|
.filter((item) => item.label.includes(wanted));
|
|
}
|
|
if (matches.length === 0) {
|
|
document.body.click();
|
|
return { ok: false, reason: 'No model matched.', detail: 'wanted=' + target + ' available=' + JSON.stringify(labels) };
|
|
}
|
|
if (matches.length > 1) {
|
|
document.body.click();
|
|
return { ok: false, reason: 'Model name is ambiguous.', detail: 'wanted=' + target + ' matches=' + JSON.stringify(matches.map((m) => labels[m.index])) };
|
|
}
|
|
const idx = matches[0].index;
|
|
const chosen = modelItems[idx];
|
|
const chosenLabel = labels[idx];
|
|
|
|
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) {
|
|
throw new CommandExecutionError(result.reason, result.detail || '');
|
|
}
|
|
if (listOnly) {
|
|
return result.labels.map((m) => ({ Status: m === current ? 'Active' : 'Available', Model: m }));
|
|
}
|
|
const selected = findUniqueModelOption(result.labels || [], name);
|
|
if (!selected) {
|
|
throw new CommandExecutionError('No model matched.', `wanted=${name} available=${JSON.stringify(result.labels || [])}`);
|
|
}
|
|
if (selected !== result.chosen) {
|
|
throw new CommandExecutionError('Codex model selection was inconsistent.', `expected=${selected} chosen=${result.chosen}`);
|
|
}
|
|
let verified = '';
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
await page.wait(0.25);
|
|
const reread = unwrapEvaluateResult(await page.evaluate(`(function() {
|
|
const re = new RegExp(${patternJson});
|
|
const composers = Array.from(document.querySelectorAll('[contenteditable="true"]')).filter((el) => el.offsetParent);
|
|
const last = composers[composers.length - 1];
|
|
if (!last) return '';
|
|
let root = last;
|
|
for (let i = 0; i < 5; i++) root = root.parentElement || root;
|
|
const btns = Array.from(root.querySelectorAll('button')).filter((b) => b.offsetParent);
|
|
const match = btns.find((b) => re.test((b.textContent || '').trim()));
|
|
return match ? (match.textContent || '').trim() : '';
|
|
})()`));
|
|
if (modelSelectionVerified(reread, selected)) {
|
|
verified = reread;
|
|
break;
|
|
}
|
|
}
|
|
if (!verified) {
|
|
throw new CommandExecutionError(
|
|
`Codex model switch to "${selected}" was not verified.`,
|
|
'The model menu click may have failed or the model selector text may have drifted.',
|
|
);
|
|
}
|
|
return [{ Status: 'switched', Model: verified }];
|
|
},
|
|
});
|