chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// Shared helpers for Codex conversation management (pin/unpin/archive/rename).
|
||||
//
|
||||
// Codex App exposes 8 actions via the "Chat actions" header dropdown on the
|
||||
// currently-active chat. We use that path because it's the only one that
|
||||
// works regardless of window visibility:
|
||||
//
|
||||
// - The per-row sidebar buttons (Pin chat / Archive chat) are React
|
||||
// hover-only — they're LAZILY MOUNTED only when the row is hovered,
|
||||
// AND only when `document.visibilityState === 'visible'`. When the
|
||||
// Codex window is hidden / minimized, even programmatic mouseenter
|
||||
// won't surface them.
|
||||
//
|
||||
// - The Chat actions menu mounts its items on click, doesn't care about
|
||||
// window visibility, and supports all the operations we need:
|
||||
// Unpin chat ⌥⌘P (or "Pin chat" when not pinned)
|
||||
// Rename chat ⌥⌘R
|
||||
// Archive chat ⇧⌘A
|
||||
// Open side chat, Copy, Fork, Add automation…, Open in new window
|
||||
//
|
||||
// Caveat: this means each action targets the ACTIVE chat. We select the
|
||||
// target first via openCodexConversation (using --project / --conversation
|
||||
// / --index / --thread-id), then trigger the menu and click.
|
||||
|
||||
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
collectCodexProjectsFromDocument,
|
||||
conversationSelectionArgs,
|
||||
hasConversationTarget,
|
||||
openCodexConversation,
|
||||
} from './sidebar.js';
|
||||
|
||||
export { conversationSelectionArgs };
|
||||
|
||||
export function unwrapEvaluateResult(result) {
|
||||
if (result && typeof result === 'object' && 'data' in result && 'session' in result) {
|
||||
return result.data;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function sameProject(a, b) {
|
||||
const left = cleanText(a).toLowerCase();
|
||||
const right = cleanText(b).toLowerCase();
|
||||
return !left || !right || left === right;
|
||||
}
|
||||
|
||||
export function findCodexConversation(projects, ref) {
|
||||
if (!Array.isArray(projects)) {
|
||||
return null;
|
||||
}
|
||||
for (const project of projects) {
|
||||
for (const conversation of project.conversations || []) {
|
||||
if (ref.threadId && conversation.threadId === ref.threadId) {
|
||||
return { project, conversation };
|
||||
}
|
||||
if (!ref.threadId
|
||||
&& ref.conversation
|
||||
&& cleanText(conversation.title) === cleanText(ref.conversation)
|
||||
&& sameProject(project.project, ref.project)) {
|
||||
return { project, conversation };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findActiveCodexConversation(projects) {
|
||||
const active = [];
|
||||
for (const project of projects || []) {
|
||||
for (const conversation of project.conversations || []) {
|
||||
if (conversation.active) {
|
||||
active.push({ project, conversation });
|
||||
}
|
||||
}
|
||||
}
|
||||
return active.length === 1 ? active[0] : null;
|
||||
}
|
||||
|
||||
export async function readConversationProjects(page) {
|
||||
const projects = unwrapEvaluateResult(await page.evaluate(`(${collectCodexProjectsFromDocument.toString()})()`));
|
||||
if (!Array.isArray(projects)) {
|
||||
throw new CommandExecutionError('Codex sidebar extraction returned an invalid payload.');
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
export async function resolveActionConversation(page, kwargs) {
|
||||
const selected = await openCodexConversation(page, kwargs);
|
||||
const projects = await readConversationProjects(page);
|
||||
const resolved = selected
|
||||
? findCodexConversation(projects, selected)
|
||||
: findActiveCodexConversation(projects);
|
||||
if (!resolved) {
|
||||
const hint = hasConversationTarget(kwargs)
|
||||
? 'The selected Codex conversation was not visible after selection.'
|
||||
: 'Pass --project/--conversation/--index/--thread-id, or keep the active conversation visible in the sidebar.';
|
||||
throw new CommandExecutionError('Could not resolve a stable Codex conversation identity.', hint);
|
||||
}
|
||||
if (!resolved.conversation.threadId) {
|
||||
throw new CommandExecutionError(
|
||||
'Could not resolve a stable Codex conversation identity.',
|
||||
'The selected sidebar row is missing its Codex thread id; selectors may have drifted.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
project: resolved.project.project,
|
||||
projectPath: resolved.project.projectPath,
|
||||
conversation: resolved.conversation.title,
|
||||
threadId: resolved.conversation.threadId,
|
||||
pinned: resolved.conversation.pinned,
|
||||
index: resolved.conversation.index,
|
||||
};
|
||||
}
|
||||
|
||||
function conversationRefForError(ref) {
|
||||
return ref.threadId || `${ref.project || '(unknown project)'}/${ref.conversation || '(unknown conversation)'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the "Chat actions" header menu on the currently-active chat and
|
||||
* click the menu item whose visible text matches one of `labelOptions`.
|
||||
*
|
||||
* Single-evaluate so the menu stays mounted while we click — and uses
|
||||
* the full pointer-event chain because radix's menu trigger only responds
|
||||
* to pointerdown/up sequences, not bare .click().
|
||||
*
|
||||
* Returns { ok, clicked? , reason?, detail? }.
|
||||
*/
|
||||
export async function clickChatActionsMenuItem(page, labelOptions) {
|
||||
const labelsJson = JSON.stringify(labelOptions);
|
||||
|
||||
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const labels = ${labelsJson};
|
||||
|
||||
const trigger = document.querySelector('button[aria-label="Chat actions"]');
|
||||
if (!(trigger instanceof HTMLButtonElement)) {
|
||||
return { ok: false, reason: 'Chat actions button not found in the chat header.' };
|
||||
}
|
||||
|
||||
// Radix listens to pointer events — bare .click() is silently ignored.
|
||||
const rect = trigger.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),
|
||||
};
|
||||
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));
|
||||
|
||||
// Poll for menu items to mount (typically < 300ms).
|
||||
let menuItems = [];
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await wait(75);
|
||||
menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'))
|
||||
.filter((it) => it instanceof HTMLElement && it.offsetParent);
|
||||
if (menuItems.length) break;
|
||||
}
|
||||
if (!menuItems.length) {
|
||||
return { ok: false, reason: 'Chat actions menu did not open after pointer click.' };
|
||||
}
|
||||
|
||||
// Match by label — menu items render as "<label><kbd>shortcut</kbd>" so
|
||||
// we compare the leading text (innerText through the first newline /
|
||||
// kbd boundary).
|
||||
function leadingText(el) {
|
||||
const clone = el.cloneNode(true);
|
||||
clone.querySelectorAll('kbd').forEach((k) => k.remove());
|
||||
return (clone.textContent || '').trim();
|
||||
}
|
||||
|
||||
let target = null;
|
||||
for (const item of menuItems) {
|
||||
const text = leadingText(item);
|
||||
for (const label of labels) {
|
||||
if (text === label || text.startsWith(label)) {
|
||||
target = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target) break;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
const visible = menuItems.map(leadingText);
|
||||
// Close the menu so it doesn't stay open as a side effect.
|
||||
document.body.click();
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'No menu item matched the requested label.',
|
||||
detail: 'wanted=' + JSON.stringify(labels) + ' visible=' + JSON.stringify(visible),
|
||||
};
|
||||
}
|
||||
|
||||
// Defer click to a microtask so the eval response returns BEFORE the
|
||||
// action triggers a re-render that could swallow our reply.
|
||||
const matchedLabel = leadingText(target);
|
||||
Promise.resolve().then(() => { try { target.click(); } catch {} });
|
||||
return { ok: true, clicked: matchedLabel };
|
||||
})()`));
|
||||
|
||||
return result || { ok: false, reason: 'Empty result from page.evaluate.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that selects the target first, then clicks the menu.
|
||||
*/
|
||||
export async function selectAndClickAction(page, kwargs, labelOptions) {
|
||||
const selected = await resolveActionConversation(page, kwargs);
|
||||
await page.wait(0.4);
|
||||
const result = await clickChatActionsMenuItem(page, labelOptions);
|
||||
if (!result.ok) {
|
||||
const detail = result.detail ? ` ${result.detail}` : '';
|
||||
throw new CommandExecutionError(
|
||||
`${result.reason || 'Failed to perform action.'}${detail}`,
|
||||
'Make sure Codex Desktop is running and the target conversation is selectable.',
|
||||
);
|
||||
}
|
||||
return { ...result, selected };
|
||||
}
|
||||
|
||||
export async function waitForConversationPostcondition(page, ref, predicate, description, timeoutMs = 4000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastMatch = null;
|
||||
while (Date.now() < deadline) {
|
||||
const projects = await readConversationProjects(page);
|
||||
lastMatch = findCodexConversation(projects, ref);
|
||||
if (predicate(lastMatch)) {
|
||||
return lastMatch;
|
||||
}
|
||||
await page.wait(0.2);
|
||||
}
|
||||
throw new CommandExecutionError(
|
||||
`Codex ${description} was not verified for ${conversationRefForError(ref)}.`,
|
||||
'The UI action may have failed or the sidebar selectors may have drifted.',
|
||||
);
|
||||
}
|
||||
|
||||
export async function setConversationPinned(page, kwargs, desiredPinned) {
|
||||
const selected = await resolveActionConversation(page, kwargs);
|
||||
if (selected.pinned === desiredPinned) {
|
||||
return { status: desiredPinned ? 'already-pinned' : 'already-unpinned', selected };
|
||||
}
|
||||
const action = await selectAndClickAction(page, kwargs, [desiredPinned ? 'Pin chat' : 'Unpin chat']);
|
||||
await waitForConversationPostcondition(
|
||||
page,
|
||||
action.selected,
|
||||
match => match?.conversation?.pinned === desiredPinned,
|
||||
desiredPinned ? 'pin' : 'unpin',
|
||||
);
|
||||
return { status: desiredPinned ? 'pinned' : 'unpinned', selected: action.selected };
|
||||
}
|
||||
|
||||
export async function archiveConversation(page, kwargs) {
|
||||
const selected = await selectAndClickAction(page, kwargs, ['Archive chat']);
|
||||
await waitForConversationPostcondition(
|
||||
page,
|
||||
selected.selected,
|
||||
match => !match,
|
||||
'archive',
|
||||
);
|
||||
return { status: 'archived', selected: selected.selected };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { archiveConversation, conversationSelectionArgs, resolveActionConversation } from './_actions.js';
|
||||
|
||||
cli({
|
||||
site: 'codex',
|
||||
name: 'archive',
|
||||
access: 'write',
|
||||
description: 'Archive (Codex\'s term for delete) the selected conversation via the Chat actions header menu. No confirmation in UI — pass --yes to actually archive.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'yes', type: 'boolean', default: false, help: 'Actually archive (default: dry-run preview)' },
|
||||
...conversationSelectionArgs,
|
||||
],
|
||||
columns: ['status', 'thread_id', 'project', 'conversation'],
|
||||
func: async (page, kwargs) => {
|
||||
const yes = kwargs.yes === true || kwargs.yes === 'true' || kwargs.yes === '1';
|
||||
if (!yes) {
|
||||
// Resolve target so the dry-run still names what WOULD be archived.
|
||||
const selected = await resolveActionConversation(page, kwargs);
|
||||
return [{
|
||||
status: 'dry-run',
|
||||
thread_id: selected.threadId,
|
||||
project: selected.project,
|
||||
conversation: selected.conversation,
|
||||
}];
|
||||
}
|
||||
const result = await archiveConversation(page, kwargs);
|
||||
return [{
|
||||
status: result.status,
|
||||
thread_id: result.selected.threadId,
|
||||
project: result.selected.project,
|
||||
conversation: result.selected.conversation,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, selectorError } from '@jackwener/opencli/errors';
|
||||
import { conversationSelectionArgs, openCodexConversation } from './sidebar.js';
|
||||
export const askCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'ask',
|
||||
access: 'write',
|
||||
description: 'Send a prompt to the current or selected Codex conversation and wait for the AI response',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'text', required: true, positional: true, help: 'Prompt to send' },
|
||||
{ name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait for response (default: 60)', default: 60 },
|
||||
...conversationSelectionArgs,
|
||||
],
|
||||
columns: ['Role', 'Project', 'Conversation', 'Text'],
|
||||
func: async (page, kwargs) => {
|
||||
const text = kwargs.text;
|
||||
const timeout = kwargs.timeout;
|
||||
if (!Number.isInteger(timeout) || timeout < 1) {
|
||||
throw new ArgumentError('--timeout must be a positive integer (seconds)');
|
||||
}
|
||||
const selected = await openCodexConversation(page, kwargs);
|
||||
// Snapshot the current content length before sending
|
||||
const beforeLen = await page.evaluate(`
|
||||
(function() {
|
||||
const turns = document.querySelectorAll('[data-content-search-turn-key]');
|
||||
return turns.length;
|
||||
})()
|
||||
`);
|
||||
// Inject and send
|
||||
const injected = await page.evaluate(`
|
||||
(function(text) {
|
||||
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
|
||||
const composer = editables.length > 0 ? editables[editables.length - 1] : document.querySelector('textarea');
|
||||
if (!composer) return false;
|
||||
composer.focus();
|
||||
document.execCommand('insertText', false, text);
|
||||
return true;
|
||||
})(${JSON.stringify(text)})
|
||||
`);
|
||||
if (!injected)
|
||||
throw selectorError('Codex input element');
|
||||
await page.wait(0.5);
|
||||
await page.pressKey('Enter');
|
||||
// Poll for new content
|
||||
const pollInterval = 3;
|
||||
const maxPolls = Math.ceil(timeout / pollInterval);
|
||||
let response = '';
|
||||
for (let i = 0; i < maxPolls; i++) {
|
||||
await page.wait(pollInterval);
|
||||
const result = await page.evaluate(`
|
||||
(function(prevLen) {
|
||||
const turns = document.querySelectorAll('[data-content-search-turn-key]');
|
||||
if (turns.length <= prevLen) return null;
|
||||
const lastTurn = turns[turns.length - 1];
|
||||
const text = lastTurn.innerText || lastTurn.textContent;
|
||||
return text ? text.trim() : null;
|
||||
})(${beforeLen})
|
||||
`);
|
||||
if (result) {
|
||||
response = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!response) {
|
||||
return [
|
||||
{ Role: 'User', Project: selected?.project || '', Conversation: selected?.conversation || '', Text: text },
|
||||
{ Role: 'System', Project: selected?.project || '', Conversation: selected?.conversation || '', Text: `No response within ${timeout}s. The agent may still be working.` },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ Role: 'User', Project: selected?.project || '', Conversation: selected?.conversation || '', Text: text },
|
||||
{ Role: 'Assistant', Project: selected?.project || '', Conversation: selected?.conversation || '', Text: response },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
import { makeDumpCommand } from '../_shared/desktop-commands.js';
|
||||
export const dumpCommand = makeDumpCommand('codex');
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const exportCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'export',
|
||||
access: 'read',
|
||||
description: 'Export the current Codex conversation to a Markdown file',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'output', required: false, help: 'Output file (default: /tmp/codex-export.md)' },
|
||||
],
|
||||
columns: ['Status', 'File', 'Messages'],
|
||||
func: async (page, kwargs) => {
|
||||
const outputPath = kwargs.output || '/tmp/codex-export.md';
|
||||
const md = await page.evaluate(`
|
||||
(function() {
|
||||
const turns = document.querySelectorAll('[data-content-search-turn-key]');
|
||||
if (turns.length > 0) {
|
||||
return Array.from(turns).map((t, i) => '## Turn ' + (i + 1) + '\\n\\n' + (t.innerText || t.textContent).trim()).join('\\n\\n---\\n\\n');
|
||||
}
|
||||
|
||||
const main = document.querySelector('main, [role="main"], [role="log"]');
|
||||
if (main) return main.innerText || main.textContent;
|
||||
return document.body.innerText;
|
||||
})()
|
||||
`);
|
||||
fs.writeFileSync(outputPath, '# Codex Conversation Export\\n\\n' + md);
|
||||
return [
|
||||
{
|
||||
Status: 'Success',
|
||||
File: outputPath,
|
||||
Messages: md.split('## Turn').length - 1,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
export const extractDiffCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'extract-diff',
|
||||
access: 'read',
|
||||
description: 'Extract visual code review diff patches from Codex',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['File', 'Diff'],
|
||||
func: async (page) => {
|
||||
const diffs = await page.evaluate(`
|
||||
(function() {
|
||||
const results = [];
|
||||
// Assuming diffs are rendered with standard diff classes or monaco difference editors
|
||||
const diffBlocks = document.querySelectorAll('.diff-editor, .monaco-diff-editor, [data-testid="diff-view"]');
|
||||
|
||||
diffBlocks.forEach((block, index) => {
|
||||
// Very roughly scrape text representing additions/deletions mapped from the inner wrapper
|
||||
results.push({
|
||||
File: block.getAttribute('data-filename') || \`DiffBlock_\${index+1}\`,
|
||||
Diff: block.innerText || block.textContent
|
||||
});
|
||||
});
|
||||
|
||||
// If no structured diffs found, try to find any code blocks labeled as patches
|
||||
if (results.length === 0) {
|
||||
const codeBlocks = document.querySelectorAll('pre code.language-diff, pre code.language-patch');
|
||||
codeBlocks.forEach((code, index) => {
|
||||
results.push({
|
||||
File: \`Patch_\${index+1}\`,
|
||||
Diff: code.innerText || code.textContent
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
if (diffs.length === 0) {
|
||||
return [{ File: 'No diffs found', Diff: 'Try running opencli codex send "/review" first' }];
|
||||
}
|
||||
return diffs;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { flattenCodexProjects, readCodexProjects } from './sidebar.js';
|
||||
export const historyCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'history',
|
||||
access: 'read',
|
||||
description: 'List visible Codex conversation threads grouped by project',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'project', required: false, help: 'Filter by project label or path' },
|
||||
{ name: 'limit', required: false, help: 'Max conversations per project' },
|
||||
],
|
||||
columns: ['Project', 'Index', 'Title', 'Updated', 'Active'],
|
||||
func: async (page, kwargs) => {
|
||||
const projects = await readCodexProjects(page);
|
||||
const rows = flattenCodexProjects(projects, kwargs);
|
||||
if (rows.length === 0) {
|
||||
throw new EmptyResultError('codex history', kwargs.project
|
||||
? `No Codex conversations were visible for project "${kwargs.project}".`
|
||||
: 'No Codex conversations were visible. Open the Codex sidebar and retry.');
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
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 }];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
import { makeNewCommand } from '../_shared/desktop-commands.js';
|
||||
export const newCommand = makeNewCommand('codex', 'Codex conversation');
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { conversationSelectionArgs, setConversationPinned } from './_actions.js';
|
||||
|
||||
function defineToggle(name, desiredPinned) {
|
||||
cli({
|
||||
site: 'codex',
|
||||
name,
|
||||
access: 'write',
|
||||
description: `${name === 'pin' ? 'Pin' : 'Unpin'} the selected Codex conversation via the Chat actions header menu.`,
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [...conversationSelectionArgs],
|
||||
columns: ['status', 'thread_id', 'project', 'conversation'],
|
||||
func: async (page, kwargs) => {
|
||||
const result = await setConversationPinned(page, kwargs, desiredPinned);
|
||||
return [{
|
||||
status: result.status,
|
||||
thread_id: result.selected.threadId,
|
||||
project: result.selected.project,
|
||||
conversation: result.selected.conversation,
|
||||
}];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The Chat actions menu only shows the CURRENT state's label (Pin chat OR
|
||||
// Unpin chat, never both). Each command binds to its matching label.
|
||||
defineToggle('pin', true);
|
||||
defineToggle('unpin', false);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { flattenCodexProjects, readCodexProjects } from './sidebar.js';
|
||||
|
||||
export const projectsCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'projects',
|
||||
access: 'read',
|
||||
description: 'List Codex projects and visible conversations from the sidebar',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'project', required: false, help: 'Filter by project label or path' },
|
||||
{ name: 'limit', required: false, help: 'Max conversations per project' },
|
||||
],
|
||||
columns: ['Project', 'Index', 'Title', 'Updated', 'Active'],
|
||||
func: async (page, kwargs) => {
|
||||
const projects = await readCodexProjects(page);
|
||||
const rows = flattenCodexProjects(projects, kwargs);
|
||||
if (rows.length === 0) {
|
||||
throw new EmptyResultError('codex projects', kwargs.project
|
||||
? `No Codex projects matched "${kwargs.project}".`
|
||||
: 'No Codex projects were visible. Open the Codex sidebar and retry.');
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { conversationSelectionArgs, openCodexConversation } from './sidebar.js';
|
||||
export const readCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'read',
|
||||
access: 'read',
|
||||
description: 'Read the contents of the current or selected Codex conversation thread',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
...conversationSelectionArgs,
|
||||
],
|
||||
columns: ['Project', 'Conversation', 'Content'],
|
||||
func: async (page, kwargs) => {
|
||||
const selected = await openCodexConversation(page, kwargs);
|
||||
const historyText = await page.evaluate(`
|
||||
(function() {
|
||||
const turns = Array.from(document.querySelectorAll('[data-content-search-turn-key]'));
|
||||
if (turns.length > 0) {
|
||||
return turns.map(t => t.innerText || t.textContent).join('\\n\\n---\\n\\n');
|
||||
}
|
||||
|
||||
const threadContainer = document.querySelector('[role="log"], [data-testid="conversation"], .thread-container, .messages-list, main');
|
||||
|
||||
if (threadContainer) {
|
||||
return threadContainer.innerText || threadContainer.textContent;
|
||||
}
|
||||
|
||||
return document.body.innerText;
|
||||
})()
|
||||
`);
|
||||
return [
|
||||
{
|
||||
Project: selected?.project || '',
|
||||
Conversation: selected?.conversation || '',
|
||||
Content: historyText,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
||||
import {
|
||||
conversationSelectionArgs,
|
||||
selectAndClickAction,
|
||||
unwrapEvaluateResult,
|
||||
waitForConversationPostcondition,
|
||||
} from './_actions.js';
|
||||
|
||||
cli({
|
||||
site: 'codex',
|
||||
name: 'rename',
|
||||
access: 'write',
|
||||
description: 'Rename the selected Codex conversation. Opens the Chat actions menu → "Rename chat", then types the new title.',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'title', required: true, positional: true, help: 'New title (single line, no newlines)' },
|
||||
...conversationSelectionArgs,
|
||||
],
|
||||
columns: ['status', 'title', 'thread_id', 'project'],
|
||||
func: async (page, kwargs) => {
|
||||
const title = String(kwargs.title || '').trim();
|
||||
if (!title) throw new ArgumentError('title cannot be empty');
|
||||
if (title.includes('\n')) throw new ArgumentError('title must be a single line');
|
||||
|
||||
// 1. Select the target chat AND click "Rename chat" in the menu.
|
||||
const action = await selectAndClickAction(page, kwargs, ['Rename chat']);
|
||||
await page.wait(0.5);
|
||||
|
||||
// 2. The rename input is the only non-ProseMirror editable that just appeared.
|
||||
// Fill it via execCommand insertText (Codex uses a contenteditable, not a plain input).
|
||||
const filled = unwrapEvaluateResult(await page.evaluate(`(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let input = null;
|
||||
for (let attempt = 0; attempt < 15; attempt += 1) {
|
||||
const candidates = Array.from(document.querySelectorAll('input[type="text"], input:not([type]), [contenteditable="true"]'))
|
||||
.filter((el) => el.offsetParent && !el.classList.contains('ProseMirror'));
|
||||
if (candidates.length) {
|
||||
candidates.sort((a, b) => (a.getBoundingClientRect().left || 9999) - (b.getBoundingClientRect().left || 9999));
|
||||
input = candidates[0];
|
||||
break;
|
||||
}
|
||||
await wait(120);
|
||||
}
|
||||
if (!input) return { ok: false, reason: 'Rename input did not appear after menu click.' };
|
||||
input.focus();
|
||||
const newTitle = ${JSON.stringify(title)};
|
||||
if (input instanceof HTMLInputElement) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(input, '');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
setter.call(input, newTitle);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(input);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.execCommand('delete');
|
||||
document.execCommand('insertText', false, newTitle);
|
||||
}
|
||||
return { ok: true };
|
||||
})()`));
|
||||
if (!filled?.ok) {
|
||||
throw new CommandExecutionError(filled?.reason || 'Failed to fill rename input.', '');
|
||||
}
|
||||
|
||||
await page.pressKey('Enter');
|
||||
await waitForConversationPostcondition(
|
||||
page,
|
||||
action.selected,
|
||||
match => (match?.conversation?.title || '').trim() === title,
|
||||
'rename',
|
||||
);
|
||||
|
||||
return [{
|
||||
status: 'renamed',
|
||||
title,
|
||||
thread_id: action.selected.threadId,
|
||||
project: action.selected.project,
|
||||
}];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
import { makeScreenshotCommand } from '../_shared/desktop-commands.js';
|
||||
export const screenshotCommand = makeScreenshotCommand('codex', 'Codex');
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||||
import { selectorError } from '@jackwener/opencli/errors';
|
||||
import { conversationSelectionArgs, openCodexConversation } from './sidebar.js';
|
||||
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)' },
|
||||
...conversationSelectionArgs,
|
||||
],
|
||||
columns: ['Status', 'Project', 'Conversation', 'InjectedText'],
|
||||
func: async (page, kwargs) => {
|
||||
const textToInsert = kwargs.text;
|
||||
const selected = await openCodexConversation(page, kwargs);
|
||||
const injected = await page.evaluate(`
|
||||
(function(text) {
|
||||
let composer = document.querySelector('textarea, [contenteditable="true"]');
|
||||
|
||||
const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
|
||||
if (editables.length > 0) {
|
||||
composer = editables[editables.length - 1];
|
||||
}
|
||||
|
||||
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);
|
||||
// Simulate Enter key to submit
|
||||
await page.pressKey('Enter');
|
||||
return [
|
||||
{
|
||||
Status: 'Success',
|
||||
Project: selected?.project || '',
|
||||
Conversation: selected?.conversation || '',
|
||||
InjectedText: textToInsert,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
|
||||
function cleanText(value) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeMatch(value) {
|
||||
return cleanText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function matchesProject(project, query) {
|
||||
if (!query)
|
||||
return true;
|
||||
const label = normalizeMatch(project.project);
|
||||
const projectPath = normalizeMatch(project.projectPath);
|
||||
const needle = normalizeMatch(query);
|
||||
if (!needle)
|
||||
return true;
|
||||
return label === needle
|
||||
|| label.includes(needle)
|
||||
|| projectPath === needle
|
||||
|| projectPath.endsWith(`/${needle}`);
|
||||
}
|
||||
|
||||
export function hasConversationTarget(kwargs) {
|
||||
return !!(kwargs?.project || kwargs?.conversation || kwargs?.index || kwargs?.['thread-id']);
|
||||
}
|
||||
|
||||
export function parsePositiveIntegerOption(raw, label) {
|
||||
const value = cleanText(raw);
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new ArgumentError(`${label} must be a positive integer`);
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new ArgumentError(`${label} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseOptionalPositiveIntegerOption(raw, label) {
|
||||
if (raw == null || cleanText(raw) === '') {
|
||||
return null;
|
||||
}
|
||||
return parsePositiveIntegerOption(raw, label);
|
||||
}
|
||||
|
||||
export function requireNonEmptyOption(raw, label) {
|
||||
const value = cleanText(raw);
|
||||
if (!value) {
|
||||
throw new ArgumentError(`${label} cannot be empty`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function collectCodexProjectsFromDocument(doc = document) {
|
||||
const projectRowSelector = '[data-app-action-sidebar-project-row]';
|
||||
const threadRowSelector = '[data-app-action-sidebar-thread-row]';
|
||||
|
||||
function visibleText(el) {
|
||||
return (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isRelativeTime(text) {
|
||||
return /^(?:(?:\d+\s*)?(?:刚刚|秒|分钟|小时|天|周|个月|年|sec|min|hr|hour|day|week|month|year|s|m|h|d|w)|.*\bago)$/i.test(text.trim());
|
||||
}
|
||||
|
||||
function getUpdatedText(row, title) {
|
||||
const candidates = Array.from(row.querySelectorAll('.tabular-nums, [class*="tabular-nums"], [class*="description"]'))
|
||||
.map(visibleText)
|
||||
.filter(Boolean);
|
||||
const direct = candidates.find(isRelativeTime);
|
||||
if (direct)
|
||||
return direct;
|
||||
const fullText = visibleText(row);
|
||||
const suffix = fullText.replace(title, '').trim();
|
||||
return isRelativeTime(suffix) ? suffix : '';
|
||||
}
|
||||
|
||||
return Array.from(doc.querySelectorAll(projectRowSelector)).map((projectRow, projectIndex) => {
|
||||
const label = projectRow.getAttribute('data-app-action-sidebar-project-label')
|
||||
|| projectRow.getAttribute('aria-label')
|
||||
|| visibleText(projectRow);
|
||||
const path = projectRow.getAttribute('data-app-action-sidebar-project-id') || '';
|
||||
const projectItem = projectRow.closest('[role="listitem"][aria-label]') || projectRow.parentElement;
|
||||
const threadRows = projectItem
|
||||
? Array.from(projectItem.querySelectorAll(threadRowSelector))
|
||||
: [];
|
||||
const conversations = threadRows.map((row, index) => {
|
||||
const title = row.getAttribute('data-app-action-sidebar-thread-title') || visibleText(row);
|
||||
return {
|
||||
index: index + 1,
|
||||
title,
|
||||
updated: getUpdatedText(row, title),
|
||||
active: row.getAttribute('data-app-action-sidebar-thread-active') === 'true',
|
||||
pinned: row.getAttribute('data-app-action-sidebar-thread-pinned') === 'true',
|
||||
threadId: row.getAttribute('data-app-action-sidebar-thread-id') || '',
|
||||
hostId: row.getAttribute('data-app-action-sidebar-thread-host-id') || '',
|
||||
kind: row.getAttribute('data-app-action-sidebar-thread-kind') || '',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
index: projectIndex + 1,
|
||||
project: label,
|
||||
projectPath: path,
|
||||
collapsed: projectRow.getAttribute('data-app-action-sidebar-project-collapsed') === 'true'
|
||||
|| projectRow.getAttribute('aria-expanded') === 'false',
|
||||
conversations,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function selectCodexConversationInDocument(target, doc = document) {
|
||||
const projectRowSelector = '[data-app-action-sidebar-project-row]';
|
||||
const threadRowSelector = '[data-app-action-sidebar-thread-row]';
|
||||
|
||||
function clean(value) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return clean(value).toLowerCase();
|
||||
}
|
||||
|
||||
function matches(value, query) {
|
||||
const haystack = normalize(value);
|
||||
const needle = normalize(query);
|
||||
return !!needle && (haystack === needle || haystack.includes(needle));
|
||||
}
|
||||
|
||||
function projectLabel(row) {
|
||||
return row.getAttribute('data-app-action-sidebar-project-label')
|
||||
|| row.getAttribute('aria-label')
|
||||
|| row.textContent
|
||||
|| '';
|
||||
}
|
||||
|
||||
function projectPath(row) {
|
||||
return row.getAttribute('data-app-action-sidebar-project-id') || '';
|
||||
}
|
||||
|
||||
function threadTitle(row) {
|
||||
return row.getAttribute('data-app-action-sidebar-thread-title')
|
||||
|| row.textContent
|
||||
|| '';
|
||||
}
|
||||
|
||||
function centerPoint(row) {
|
||||
const rect = row.getBoundingClientRect?.();
|
||||
if (!rect || !Number.isFinite(rect.left) || !Number.isFinite(rect.top)) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
const projectRows = Array.from(doc.querySelectorAll(projectRowSelector));
|
||||
let projectRow = null;
|
||||
if (target.project) {
|
||||
projectRow = projectRows.find(row => matches(projectLabel(row), target.project))
|
||||
|| projectRows.find(row => matches(projectPath(row), target.project));
|
||||
if (!projectRow) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Project not found: ${target.project}`,
|
||||
projects: projectRows.map(row => projectLabel(row)).filter(Boolean),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (projectRow) {
|
||||
const collapsed = projectRow.getAttribute('data-app-action-sidebar-project-collapsed') === 'true'
|
||||
|| projectRow.getAttribute('aria-expanded') === 'false';
|
||||
if (collapsed) {
|
||||
projectRow.scrollIntoView?.({ block: 'center' });
|
||||
if (!target.preferNativeClick) {
|
||||
projectRow.click();
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
expanded: true,
|
||||
selected: false,
|
||||
project: projectLabel(projectRow),
|
||||
projectPath: projectPath(projectRow),
|
||||
...centerPoint(projectRow),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const scope = projectRow
|
||||
? (projectRow.closest('[role="listitem"][aria-label]') || projectRow.parentElement || doc)
|
||||
: doc;
|
||||
const threadRows = Array.from(scope.querySelectorAll(threadRowSelector));
|
||||
if (!threadRows.length) {
|
||||
return {
|
||||
ok: false,
|
||||
error: projectRow
|
||||
? `No visible conversations under project: ${projectLabel(projectRow)}`
|
||||
: 'No visible Codex conversations found',
|
||||
};
|
||||
}
|
||||
|
||||
let threadRow = null;
|
||||
if (target.threadId) {
|
||||
threadRow = threadRows.find(row => row.getAttribute('data-app-action-sidebar-thread-id') === target.threadId);
|
||||
}
|
||||
if (!threadRow && target.index != null) {
|
||||
const index = Number(target.index);
|
||||
if (!Number.isInteger(index) || index < 1) {
|
||||
return { ok: false, error: `Invalid conversation index: ${target.index}` };
|
||||
}
|
||||
threadRow = threadRows[index - 1] || null;
|
||||
}
|
||||
if (!threadRow && target.conversation) {
|
||||
const exact = threadRows.filter(row => normalize(threadTitle(row)) === normalize(target.conversation));
|
||||
threadRow = exact[0] || threadRows.find(row => matches(threadTitle(row), target.conversation)) || null;
|
||||
if (exact.length > 1 && !target.project) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Multiple conversations matched "${target.conversation}". Pass --project to disambiguate.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!threadRow) {
|
||||
return {
|
||||
ok: false,
|
||||
error: target.threadId
|
||||
? `Thread not found: ${target.threadId}`
|
||||
: target.conversation
|
||||
? `Conversation not found: ${target.conversation}`
|
||||
: 'Pass --conversation, --thread-id, or --index to select a Codex conversation',
|
||||
conversations: threadRows.map((row, index) => ({ index: index + 1, title: threadTitle(row) })),
|
||||
};
|
||||
}
|
||||
|
||||
threadRow.scrollIntoView?.({ block: 'center' });
|
||||
if (!target.preferNativeClick) {
|
||||
threadRow.click();
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
expanded: false,
|
||||
selected: true,
|
||||
project: projectRow ? projectLabel(projectRow) : '',
|
||||
projectPath: projectRow ? projectPath(projectRow) : '',
|
||||
conversation: threadTitle(threadRow),
|
||||
threadId: threadRow.getAttribute('data-app-action-sidebar-thread-id') || '',
|
||||
index: threadRows.indexOf(threadRow) + 1,
|
||||
...centerPoint(threadRow),
|
||||
};
|
||||
}
|
||||
|
||||
export function flattenCodexProjects(projects, opts = {}) {
|
||||
const projectFilter = opts.project;
|
||||
const limit = parseOptionalPositiveIntegerOption(opts.limit, 'codex --limit');
|
||||
const rows = [];
|
||||
for (const project of projects) {
|
||||
if (!matchesProject(project, projectFilter)) {
|
||||
continue;
|
||||
}
|
||||
const conversations = limit
|
||||
? project.conversations.slice(0, limit)
|
||||
: project.conversations;
|
||||
if (conversations.length === 0) {
|
||||
rows.push({
|
||||
Project: project.project,
|
||||
Index: 0,
|
||||
Title: project.collapsed ? '(collapsed)' : '(no visible conversations)',
|
||||
Updated: '',
|
||||
Active: '',
|
||||
ProjectPath: project.projectPath,
|
||||
ThreadId: '',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const conversation of conversations) {
|
||||
rows.push({
|
||||
Project: project.project,
|
||||
Index: conversation.index,
|
||||
Title: conversation.title,
|
||||
Updated: conversation.updated,
|
||||
Active: conversation.active ? 'yes' : '',
|
||||
ProjectPath: project.projectPath,
|
||||
ThreadId: conversation.threadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function readCodexProjects(page) {
|
||||
const projects = await page.evaluate(`(${collectCodexProjectsFromDocument.toString()})()`);
|
||||
if (!Array.isArray(projects)) {
|
||||
throw new CommandExecutionError('Codex sidebar project extraction returned an invalid payload');
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
export async function openCodexConversation(page, kwargs) {
|
||||
if (!hasConversationTarget(kwargs))
|
||||
return null;
|
||||
const index = parseOptionalPositiveIntegerOption(kwargs.index, 'codex conversation --index');
|
||||
const threadId = kwargs['thread-id'] ? requireNonEmptyOption(kwargs['thread-id'], 'codex conversation --thread-id') : '';
|
||||
const target = {
|
||||
project: kwargs.project || '',
|
||||
conversation: kwargs.conversation || '',
|
||||
index: index == null ? '' : String(index),
|
||||
threadId,
|
||||
preferNativeClick: typeof page.nativeClick === 'function',
|
||||
};
|
||||
let result = await page.evaluate(`(${selectCodexConversationInDocument.toString()})(${JSON.stringify(target)})`);
|
||||
if (result?.expanded) {
|
||||
if (typeof page.nativeClick === 'function' && Number.isFinite(result.x) && Number.isFinite(result.y)) {
|
||||
await page.nativeClick(result.x, result.y);
|
||||
}
|
||||
await page.wait(0.75);
|
||||
result = await page.evaluate(`(${selectCodexConversationInDocument.toString()})(${JSON.stringify(target)})`);
|
||||
}
|
||||
if (!result?.ok || !result?.selected) {
|
||||
const detail = result?.conversations
|
||||
? ` Available: ${result.conversations.map(item => `${item.index}. ${item.title}`).join('; ')}`
|
||||
: result?.projects
|
||||
? ` Available projects: ${result.projects.join(', ')}`
|
||||
: '';
|
||||
const message = `${result?.error || 'Could not select Codex conversation'}${detail}`;
|
||||
if (result?.error?.startsWith('Invalid conversation index:')) {
|
||||
throw new ArgumentError(message);
|
||||
}
|
||||
if (result?.error?.startsWith('Multiple conversations matched')) {
|
||||
throw new ArgumentError(message, 'Pass --project or --thread-id to disambiguate the target conversation.');
|
||||
}
|
||||
if (result?.error?.startsWith('Project not found:')
|
||||
|| result?.error?.startsWith('Conversation not found:')
|
||||
|| result?.error?.startsWith('Thread not found:')
|
||||
|| result?.error?.startsWith('No visible conversations under project:')) {
|
||||
throw new EmptyResultError('codex conversation', message);
|
||||
}
|
||||
throw new CommandExecutionError(message, 'Open the Codex sidebar and verify project/conversation rows are visible.');
|
||||
}
|
||||
if (typeof page.nativeClick === 'function' && Number.isFinite(result.x) && Number.isFinite(result.y)) {
|
||||
await page.nativeClick(result.x, result.y);
|
||||
}
|
||||
await page.wait(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const conversationSelectionArgs = [
|
||||
{ name: 'project', required: false, help: 'Project label or path to select before running the command' },
|
||||
{ name: 'conversation', required: false, help: 'Conversation title to select within --project' },
|
||||
{ name: 'index', required: false, help: '1-based conversation index within --project' },
|
||||
{ name: 'thread-id', required: false, help: 'Exact Codex thread id to select' },
|
||||
];
|
||||
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||||
import { askCommand } from './ask.js';
|
||||
import { historyCommand } from './history.js';
|
||||
import { projectsCommand } from './projects.js';
|
||||
import {
|
||||
collectCodexProjectsFromDocument,
|
||||
flattenCodexProjects,
|
||||
openCodexConversation,
|
||||
selectCodexConversationInDocument,
|
||||
} from './sidebar.js';
|
||||
import {
|
||||
findActiveCodexConversation,
|
||||
findCodexConversation,
|
||||
resolveActionConversation,
|
||||
} from './_actions.js';
|
||||
import {
|
||||
findUniqueModelOption,
|
||||
modelSelectionVerified,
|
||||
} from './model.js';
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div', attrs = {}, children = [], text = '') {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.attrs = attrs;
|
||||
this.children = children;
|
||||
this.parentElement = null;
|
||||
this.textContent = text;
|
||||
this.innerText = text;
|
||||
this.className = attrs.class || '';
|
||||
this.listeners = new Map();
|
||||
for (const child of children) {
|
||||
child.parentElement = this;
|
||||
}
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attrs[name] ?? null;
|
||||
}
|
||||
|
||||
addEventListener(name, fn) {
|
||||
const listeners = this.listeners.get(name) || [];
|
||||
listeners.push(fn);
|
||||
this.listeners.set(name, listeners);
|
||||
}
|
||||
|
||||
click() {
|
||||
for (const listener of this.listeners.get('click') || []) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
scrollIntoView() {
|
||||
}
|
||||
|
||||
closest(selector) {
|
||||
let current = this;
|
||||
while (current) {
|
||||
if (matchesSelector(current, selector))
|
||||
return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const selectors = selector.split(',').map(part => part.trim());
|
||||
const results = [];
|
||||
const visit = (node) => {
|
||||
if (selectors.some(part => matchesSelector(node, part))) {
|
||||
results.push(node);
|
||||
}
|
||||
for (const child of node.children) {
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
for (const child of this.children) {
|
||||
visit(child);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesSelector(node, selector) {
|
||||
if (selector === '[data-app-action-sidebar-project-row]') {
|
||||
return node.getAttribute('data-app-action-sidebar-project-row') !== null;
|
||||
}
|
||||
if (selector === '[data-app-action-sidebar-thread-row]') {
|
||||
return node.getAttribute('data-app-action-sidebar-thread-row') !== null;
|
||||
}
|
||||
if (selector === '[role="listitem"][aria-label]') {
|
||||
return node.getAttribute('role') === 'listitem' && node.getAttribute('aria-label') !== null;
|
||||
}
|
||||
if (selector === '.tabular-nums') {
|
||||
return String(node.className || '').split(/\s+/).includes('tabular-nums');
|
||||
}
|
||||
if (selector === '[class*="tabular-nums"]') {
|
||||
return String(node.className || '').includes('tabular-nums');
|
||||
}
|
||||
if (selector === '[class*="description"]') {
|
||||
return String(node.className || '').includes('description');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function el(tagName, attrs, children = [], text = '') {
|
||||
return new FakeElement(tagName, attrs, children, text);
|
||||
}
|
||||
|
||||
function thread(attrs, title, updated) {
|
||||
return el('div', {
|
||||
role: 'button',
|
||||
'data-app-action-sidebar-thread-row': '',
|
||||
'data-app-action-sidebar-thread-title': title,
|
||||
'data-app-action-sidebar-thread-id': attrs.threadId,
|
||||
'data-app-action-sidebar-thread-host-id': attrs.hostId || '',
|
||||
'data-app-action-sidebar-thread-kind': attrs.kind || '',
|
||||
'data-app-action-sidebar-thread-active': attrs.active ? 'true' : 'false',
|
||||
'data-app-action-sidebar-thread-pinned': attrs.pinned ? 'true' : 'false',
|
||||
}, [
|
||||
el('span', { 'data-thread-title': '' }, [], title),
|
||||
el('span', { class: 'tabular-nums' }, [], updated),
|
||||
], `${title} ${updated}`);
|
||||
}
|
||||
|
||||
function project(label, projectPath, children) {
|
||||
return el('div', { role: 'listitem', 'aria-label': label }, [
|
||||
el('div', {
|
||||
role: 'button',
|
||||
'aria-expanded': 'true',
|
||||
'data-app-action-sidebar-project-row': '',
|
||||
'data-app-action-sidebar-project-label': label,
|
||||
'data-app-action-sidebar-project-id': projectPath,
|
||||
}, [], label),
|
||||
...children,
|
||||
]);
|
||||
}
|
||||
|
||||
function fixtureDocument() {
|
||||
return el('document', {}, [
|
||||
project('stock', '/Users/youngcan/stock', [
|
||||
thread({ threadId: 'local:stock-sync', hostId: 'local', kind: 'local', active: true }, '同步各仓库最新代码', '4 小时'),
|
||||
thread({ threadId: 'local:trading-agents' }, '借鉴 TradingAgents', '2 小时'),
|
||||
]),
|
||||
project('opencli', '/Users/youngcan/opencli', [
|
||||
thread({ threadId: 'local:opencli-groups' }, '统一 opencli 二级命令分组', '1 天'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
describe('codex sidebar helpers', () => {
|
||||
it('collects projects and visible conversations from Codex data attributes', () => {
|
||||
const projects = collectCodexProjectsFromDocument(fixtureDocument());
|
||||
|
||||
expect(projects).toHaveLength(2);
|
||||
expect(projects[0]).toMatchObject({
|
||||
project: 'stock',
|
||||
projectPath: '/Users/youngcan/stock',
|
||||
collapsed: false,
|
||||
});
|
||||
expect(projects[0].conversations[0]).toMatchObject({
|
||||
index: 1,
|
||||
title: '同步各仓库最新代码',
|
||||
updated: '4 小时',
|
||||
active: true,
|
||||
threadId: 'local:stock-sync',
|
||||
});
|
||||
});
|
||||
|
||||
it('flattens project rows with project filters', () => {
|
||||
const projects = collectCodexProjectsFromDocument(fixtureDocument());
|
||||
const rows = flattenCodexProjects(projects, { project: 'opencli' });
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
Project: 'opencli',
|
||||
Index: 1,
|
||||
Title: '统一 opencli 二级命令分组',
|
||||
Updated: '1 天',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects invalid project/history limits instead of silently ignoring them', () => {
|
||||
const projects = collectCodexProjectsFromDocument(fixtureDocument());
|
||||
|
||||
expect(() => flattenCodexProjects(projects, { limit: '0' })).toThrowError(ArgumentError);
|
||||
expect(() => flattenCodexProjects(projects, { limit: '1.5' })).toThrowError(ArgumentError);
|
||||
expect(() => flattenCodexProjects(projects, { limit: 'abc' })).toThrowError(ArgumentError);
|
||||
});
|
||||
|
||||
it('does not match nested project paths when filtering by a parent label', () => {
|
||||
const projects = collectCodexProjectsFromDocument(fixtureDocument());
|
||||
projects.push({
|
||||
index: 3,
|
||||
project: 'nested',
|
||||
projectPath: '/Users/youngcan/opencli/nested',
|
||||
collapsed: false,
|
||||
conversations: [
|
||||
{ index: 1, title: 'Nested thread', updated: '', active: false, threadId: 'local:nested' },
|
||||
],
|
||||
});
|
||||
|
||||
const rows = flattenCodexProjects(projects, { project: 'opencli' });
|
||||
|
||||
expect(rows.map(row => row.Project)).toEqual(['opencli']);
|
||||
});
|
||||
|
||||
it('selects a conversation by project and title', () => {
|
||||
const doc = fixtureDocument();
|
||||
const selected = [];
|
||||
for (const row of doc.querySelectorAll('[data-app-action-sidebar-thread-row]')) {
|
||||
row.addEventListener('click', () => selected.push(row.getAttribute('data-app-action-sidebar-thread-id')));
|
||||
}
|
||||
|
||||
const result = selectCodexConversationInDocument({
|
||||
project: 'stock',
|
||||
conversation: 'TradingAgents',
|
||||
}, doc);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
selected: true,
|
||||
project: 'stock',
|
||||
conversation: '借鉴 TradingAgents',
|
||||
threadId: 'local:trading-agents',
|
||||
index: 2,
|
||||
});
|
||||
expect(selected).toEqual(['local:trading-agents']);
|
||||
});
|
||||
|
||||
it('does not dispatch DOM click when native click is preferred', () => {
|
||||
const doc = fixtureDocument();
|
||||
const selected = [];
|
||||
for (const row of doc.querySelectorAll('[data-app-action-sidebar-thread-row]')) {
|
||||
row.addEventListener('click', () => selected.push(row.getAttribute('data-app-action-sidebar-thread-id')));
|
||||
}
|
||||
|
||||
const result = selectCodexConversationInDocument({
|
||||
project: 'stock',
|
||||
conversation: 'TradingAgents',
|
||||
preferNativeClick: true,
|
||||
}, doc);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
selected: true,
|
||||
threadId: 'local:trading-agents',
|
||||
});
|
||||
expect(selected).toEqual([]);
|
||||
});
|
||||
|
||||
it('selects a conversation by index within a project', () => {
|
||||
const result = selectCodexConversationInDocument({
|
||||
project: '/Users/youngcan/opencli',
|
||||
index: '1',
|
||||
}, fixtureDocument());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
project: 'opencli',
|
||||
conversation: '统一 opencli 二级命令分组',
|
||||
threadId: 'local:opencli-groups',
|
||||
});
|
||||
});
|
||||
|
||||
it('finds a postcondition target by stable thread id', () => {
|
||||
const projects = collectCodexProjectsFromDocument(fixtureDocument());
|
||||
|
||||
const result = findCodexConversation(projects, {
|
||||
threadId: 'local:trading-agents',
|
||||
project: 'wrong project',
|
||||
conversation: 'wrong title',
|
||||
});
|
||||
|
||||
expect(result?.project.project).toBe('stock');
|
||||
expect(result?.conversation.title).toBe('借鉴 TradingAgents');
|
||||
});
|
||||
|
||||
it('requires exactly one active conversation for active-chat write postconditions', () => {
|
||||
const projects = collectCodexProjectsFromDocument(fixtureDocument());
|
||||
expect(findActiveCodexConversation(projects)?.conversation.threadId).toBe('local:stock-sync');
|
||||
|
||||
projects[1].conversations[0].active = true;
|
||||
expect(findActiveCodexConversation(projects)).toBeNull();
|
||||
});
|
||||
|
||||
it('matches model options without allowing ambiguous substrings', () => {
|
||||
const labels = ['GPT-5.5', 'GPT-5.4', 'Medium', 'Extra High'];
|
||||
|
||||
expect(findUniqueModelOption(labels, 'medium')).toBe('Medium');
|
||||
expect(findUniqueModelOption(labels, '5.5')).toBe('GPT-5.5');
|
||||
expect(() => findUniqueModelOption(labels, '5')).toThrowError(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('verifies model switch postconditions against the visible selector text', () => {
|
||||
expect(modelSelectionVerified('5.5 Extra High', 'GPT-5.5')).toBe(true);
|
||||
expect(modelSelectionVerified('5.5 Medium', 'Medium')).toBe(true);
|
||||
expect(modelSelectionVerified('5 High', 'GPT-5')).toBe(true);
|
||||
expect(modelSelectionVerified('5.5 Extra High', 'Medium')).toBe(false);
|
||||
expect(modelSelectionVerified('5.5 Extra High', 'High')).toBe(false);
|
||||
expect(modelSelectionVerified('5.5 Medium', 'GPT-5')).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a stable thread id for write action postconditions', async () => {
|
||||
const doc = fixtureDocument();
|
||||
const row = doc.querySelectorAll('[data-app-action-sidebar-thread-row]')[0];
|
||||
row.attrs['data-app-action-sidebar-thread-id'] = '';
|
||||
|
||||
const page = {
|
||||
evaluate: async () => collectCodexProjectsFromDocument(doc),
|
||||
};
|
||||
|
||||
await expect(resolveActionConversation(page, {})).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('reports exact thread-id misses as not found', () => {
|
||||
const result = selectCodexConversationInDocument({
|
||||
project: 'stock',
|
||||
threadId: 'local:missing',
|
||||
}, fixtureDocument());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: 'Thread not found: local:missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps project/conversation misses to EmptyResultError in command selection', async () => {
|
||||
const page = {
|
||||
evaluate: async () => selectCodexConversationInDocument({ project: 'missing' }, fixtureDocument()),
|
||||
};
|
||||
|
||||
await expect(openCodexConversation(page, { project: 'missing' })).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('maps missing sidebar DOM to CommandExecutionError instead of empty success', async () => {
|
||||
const page = {
|
||||
evaluate: async () => selectCodexConversationInDocument({ conversation: 'anything' }, el('document', {}, [])),
|
||||
};
|
||||
|
||||
await expect(openCodexConversation(page, { conversation: 'anything' })).rejects.toBeInstanceOf(CommandExecutionError);
|
||||
});
|
||||
|
||||
it('maps ambiguous conversation selection to ArgumentError', async () => {
|
||||
const duplicateDoc = el('document', {}, [
|
||||
project('alpha', '/tmp/alpha', [
|
||||
thread({ threadId: 'local:one' }, 'Shared title', '1 小时'),
|
||||
]),
|
||||
project('beta', '/tmp/beta', [
|
||||
thread({ threadId: 'local:two' }, 'Shared title', '2 小时'),
|
||||
]),
|
||||
]);
|
||||
const page = {
|
||||
evaluate: async () => selectCodexConversationInDocument({ conversation: 'Shared title' }, duplicateDoc),
|
||||
};
|
||||
|
||||
await expect(openCodexConversation(page, { conversation: 'Shared title' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex sidebar commands', () => {
|
||||
it('projects fails empty result instead of returning a sentinel row', async () => {
|
||||
const page = {
|
||||
evaluate: async () => [],
|
||||
};
|
||||
|
||||
await expect(projectsCommand.func(page, {})).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('history fails empty result instead of returning a sentinel row', async () => {
|
||||
const page = {
|
||||
evaluate: async () => [],
|
||||
};
|
||||
|
||||
await expect(historyCommand.func(page, {})).rejects.toBeInstanceOf(EmptyResultError);
|
||||
});
|
||||
|
||||
it('ask rejects invalid timeout instead of falling back to the default', async () => {
|
||||
const page = {
|
||||
evaluate: async () => 0,
|
||||
};
|
||||
|
||||
await expect(askCommand.func(page, { text: 'hello', timeout: 'bogus' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(askCommand.func(page, { text: 'hello', timeout: '0' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
await expect(askCommand.func(page, { text: 'hello', timeout: '1.5' })).rejects.toBeInstanceOf(ArgumentError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
import { makeStatusCommand } from '../_shared/desktop-commands.js';
|
||||
export const statusCommand = makeStatusCommand('codex', 'OpenAI Codex App');
|
||||
Reference in New Issue
Block a user