Files
Bo Liu 05cfbee66e fix(codex): select slash-command picker options from send (#2239)
* fix(codex): select slash-command picker options from send

* fix(codex): unwrap bridge envelopes in send flows

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-08-08 16:41:14 +08:00

244 lines
11 KiB
JavaScript

import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, selectorError } from '@jackwener/opencli/errors';
import { unwrapEvaluateResult } from './_actions.js';
import { conversationSelectionArgs, openCodexConversation } from './sidebar.js';
const PICKER_ITEM_SELECTOR = '[data-list-navigation-item="true"]';
export function findUniquePickerOption(options, rawLabel) {
const wanted = String(rawLabel ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
if (!wanted) {
throw new ArgumentError('--pick label cannot be empty');
}
const normalized = options.map((option, index) => ({
index,
title: String(option.title ?? ''),
normalizedTitle: String(option.title ?? '').replace(/\s+/g, ' ').trim().toLowerCase(),
normalizedText: String(option.text ?? option.title ?? '').replace(/\s+/g, ' ').trim().toLowerCase(),
}));
const exact = normalized.filter(item => item.normalizedTitle === wanted);
if (exact.length === 1) {
return exact[0];
}
if (exact.length > 1) {
throw new CommandExecutionError(`Picker option "${rawLabel}" is ambiguous.`, `Matches: ${exact.map(item => item.title).join(', ')}`);
}
const partial = normalized.filter(item => item.normalizedText.includes(wanted));
if (partial.length === 1) {
return partial[0];
}
if (partial.length > 1) {
throw new CommandExecutionError(`Picker option "${rawLabel}" is ambiguous.`, `Matches: ${partial.map(item => item.title).join(', ')}`);
}
return null;
}
function normalizeChipText(value) {
return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
export function chipMatchesLabel(chip, label) {
const wanted = normalizeChipText(label);
if (!wanted) {
return false;
}
return [chip.name, chip.display].some((value) => {
const candidate = normalizeChipText(value);
return candidate !== '' && (candidate.includes(wanted) || wanted.includes(candidate));
});
}
function describeComposerState(state) {
return `text=${JSON.stringify(state.text)} chips=${JSON.stringify(state.chips.map(chip => chip.display || chip.name))}`;
}
export function findCodexComposerElement(doc = document) {
let composer = doc.querySelector('textarea, [contenteditable="true"]');
const editables = Array.from(doc.querySelectorAll('[contenteditable="true"]'));
if (editables.length > 0) {
composer = editables[editables.length - 1];
}
return composer;
}
async function readComposerState(page) {
const state = unwrapEvaluateResult(await page.evaluate(`
(function() {
const injected = (${findCodexComposerElement.toString()})();
if (!injected) return null;
// Verification must read the element injection wrote into (the #1991
// bug class); the tagged composer counts only when it wraps it.
const composer = injected.closest('[data-codex-composer="true"]') || injected;
const chips = Array.from(composer.querySelectorAll('[skill-mention-name]')).map((chip) => ({
name: chip.getAttribute('skill-mention-name') || '',
display: chip.getAttribute('skill-mention-display-name') || '',
}));
const clone = composer.cloneNode(true);
clone.querySelectorAll('[skill-mention-name]').forEach((chip) => chip.remove());
return {
text: (composer.textContent || '').trim(),
nonChipText: (clone.textContent || '').trim(),
chips,
};
})()
`));
if (!state) {
throw selectorError('Codex Composer input element');
}
return state;
}
async function pollComposerState(page) {
let state = await readComposerState(page);
for (let attempt = 0; attempt < 5 && state.text; attempt += 1) {
await page.wait(0.25);
state = await readComposerState(page);
}
return state;
}
export const sendCommand = cli({
site: 'codex',
name: 'send',
access: 'write',
description: 'Send text/commands to the current or selected Codex AI composer',
domain: 'localhost',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'text', required: true, positional: true, help: 'Text, command (e.g. /review), or skill (e.g. $imagegen)' },
{ name: 'pick', required: false, help: 'Picker option label to select when a slash command opens the picker (e.g. "Review Agent")' },
...conversationSelectionArgs,
],
columns: ['Status', 'Project', 'Conversation', 'InjectedText'],
func: async (page, kwargs) => {
const textToInsert = kwargs.text;
const pick = String(kwargs.pick ?? '').trim();
if (kwargs.pick != null && !pick) {
throw new ArgumentError('--pick label cannot be empty');
}
const selected = await openCodexConversation(page, kwargs);
const injected = unwrapEvaluateResult(await page.evaluate(`
(function(text) {
const composer = (${findCodexComposerElement.toString()})();
if (!composer) return false;
composer.focus();
document.execCommand('insertText', false, text);
return true;
})(${JSON.stringify(textToInsert)})
`));
if (!injected)
throw selectorError('Codex Composer input element');
// Wait for the UI to register the input
await page.wait(0.5);
let picked = null;
if (pick) {
const options = unwrapEvaluateResult(await page.evaluate(`(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let items = [];
for (let attempt = 0; attempt < 20; attempt += 1) {
items = Array.from(document.querySelectorAll('${PICKER_ITEM_SELECTOR}'))
.filter((it) => it instanceof HTMLElement && it.offsetParent);
if (items.length) break;
await wait(75);
}
// Picker titles are split into per-character spans for match
// highlighting, so read the leading truncate div for the title and the
// concatenated textContent (title plus description) for substrings.
return items.map((el) => {
const text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
const lead = el.querySelector('[class*="truncate"]');
return { title: lead ? (lead.textContent || '').replace(/\\s+/g, ' ').trim() : text, text };
});
})()`));
if (!Array.isArray(options) || options.length === 0) {
throw new CommandExecutionError('Codex picker did not open after typing the command.', 'Check that the text opens a picker, or drop --pick.');
}
picked = findUniquePickerOption(options, pick);
if (!picked) {
throw new CommandExecutionError('No picker option matched.', `wanted=${pick} available=${JSON.stringify(options.map((option) => option.title))}`);
}
const clicked = unwrapEvaluateResult(await page.evaluate(`(() => {
const items = Array.from(document.querySelectorAll('${PICKER_ITEM_SELECTOR}'))
.filter((it) => it instanceof HTMLElement && it.offsetParent);
const target = items[${picked.index}];
if (!target) return false;
const rect = target.getBoundingClientRect();
const init = {
bubbles: true, cancelable: true, button: 0, buttons: 1,
clientX: Math.round(rect.left + rect.width / 2),
clientY: Math.round(rect.top + rect.height / 2),
};
// The picker only responds to the full pointer chain, and deferring it
// keeps the eval response ahead of the re-render that closes the picker.
Promise.resolve().then(() => {
try {
target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'mouse' }));
target.dispatchEvent(new MouseEvent('mousedown', init));
target.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerType: 'mouse' }));
target.dispatchEvent(new MouseEvent('mouseup', init));
target.dispatchEvent(new MouseEvent('click', init));
} catch {}
});
return true;
})()`));
if (clicked !== true) {
throw new CommandExecutionError('Codex picker option disappeared before it could be clicked.', `wanted=${picked.title}`);
}
// The click above is deferred, so verify it landed: the picker must
// close and the composer must hold a chip for the picked option.
let applied = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
const state = await readComposerState(page);
const pickerOpen = unwrapEvaluateResult(await page.evaluate(`Array.from(document.querySelectorAll('${PICKER_ITEM_SELECTOR}')).some((it) => it instanceof HTMLElement && !!it.offsetParent)`)) === true;
if (!pickerOpen && state.chips.some((chip) => chipMatchesLabel(chip, picked.title))) {
applied = state;
break;
}
applied = state;
await page.wait(0.1);
}
if (!applied || !applied.chips.some((chip) => chipMatchesLabel(chip, picked.title))) {
throw new CommandExecutionError(
'Codex picker selection did not reach the composer.',
`expected=${picked.title} ${describeComposerState(applied ?? { text: '', chips: [] })}`,
);
}
}
await page.pressKey('Enter');
let state = await pollComposerState(page);
if (state.text) {
// The picker consumes Enter to insert the highlighted entry, so a
// still-loaded composer is either the untouched draft or the chip
// the picker resolved. Anything else was rewritten: never submit.
const slashToken = /^\/\S+$/.test(textToInsert) ? textToInsert.slice(1) : '';
const chipLabel = picked ? picked.title : slashToken;
const unchangedDraft = state.chips.length === 0 && state.text === String(textToInsert).trim();
const pickerResolvedCommand = chipLabel !== ''
&& state.chips.length === 1
&& state.nonChipText === ''
&& chipMatchesLabel(state.chips[0], chipLabel);
if (!unchangedDraft && !pickerResolvedCommand) {
throw new CommandExecutionError(
'Codex send was not submitted.',
`The composer now holds ${describeComposerState(state)}. Re-run with --pick <label> or press Escape in Codex first.`,
);
}
await page.pressKey('Enter');
state = await pollComposerState(page);
if (state.text) {
throw new CommandExecutionError('Codex send was not verified.', `The composer still holds an unsent draft: ${describeComposerState(state)}`);
}
}
return [
{
Status: 'Success',
Project: selected?.project || '',
Conversation: selected?.conversation || '',
InjectedText: textToInsert,
},
];
},
});