From 57010d0f0775c486e4a143b7e25a1b5641b82b82 Mon Sep 17 00:00:00 2001 From: Kasumi <125308847+KasumiChen@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:15:25 -0700 Subject: [PATCH 1/2] feat(chatgpt): add explicit desktop surfaces for CDP --- README.md | 2 +- docs/adapters/desktop/chatgpt.md | 97 ++++-- docs/adapters/index.md | 2 +- src/browser.test.ts | 19 ++ src/browser/cdp.ts | 2 + src/clis/chatgpt/README.md | 5 +- src/clis/chatgpt/README.zh-CN.md | 44 +-- src/clis/chatgpt/ask.ts | 5 +- src/clis/chatgpt/cdp.test.ts | 53 ++++ src/clis/chatgpt/cdp.ts | 510 +++++++++++++++++++++++++++++++ src/clis/chatgpt/compat.test.ts | 48 +++ src/clis/chatgpt/new.ts | 7 +- src/clis/chatgpt/read.ts | 26 +- src/clis/chatgpt/send.ts | 39 ++- src/clis/chatgpt/status.ts | 26 +- src/clis/chatgpt/surface.ts | 41 +++ 16 files changed, 849 insertions(+), 77 deletions(-) create mode 100644 src/clis/chatgpt/cdp.test.ts create mode 100644 src/clis/chatgpt/cdp.ts create mode 100644 src/clis/chatgpt/compat.test.ts create mode 100644 src/clis/chatgpt/surface.ts diff --git a/README.md b/README.md index 2e4de66..3c4eddf 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Each desktop adapter has its own detailed documentation with commands reference, | **Cursor** | Control Cursor IDE — Composer, chat, code extraction | [Doc](./docs/adapters/desktop/cursor.md) | | **Codex** | Drive OpenAI Codex CLI agent headlessly | [Doc](./docs/adapters/desktop/codex.md) | | **Antigravity** | Control Antigravity Ultra from terminal | [Doc](./docs/adapters/desktop/antigravity.md) | -| **ChatGPT** | Automate ChatGPT macOS desktop app | [Doc](./docs/adapters/desktop/chatgpt.md) | +| **ChatGPT** | Automate ChatGPT desktop app (default macOS native + explicit CDP surfaces) | [Doc](./docs/adapters/desktop/chatgpt.md) | | **ChatWise** | Multi-LLM client (GPT-4, Claude, Gemini) | [Doc](./docs/adapters/desktop/chatwise.md) | | **Notion** | Search, read, write Notion pages | [Doc](./docs/adapters/desktop/notion.md) | | **Discord** | Discord Desktop — messages, channels, servers | [Doc](./docs/adapters/desktop/discord.md) | diff --git a/docs/adapters/desktop/chatgpt.md b/docs/adapters/desktop/chatgpt.md index 408c12e..0fadbff 100644 --- a/docs/adapters/desktop/chatgpt.md +++ b/docs/adapters/desktop/chatgpt.md @@ -1,43 +1,104 @@ # ChatGPT -Control the **ChatGPT macOS Desktop App** directly from the terminal. OpenCLI supports two automation approaches for ChatGPT. +Control the **ChatGPT Desktop App** from the terminal. -## Approach 1: AppleScript (Default, No Setup) +OpenCLI now keeps ChatGPT automation split by **target surface** so new Windows support stays additive and the long-standing macOS behavior stays intact. -The current built-in commands use native AppleScript automation — no extra launch flags needed. +## Surface 1: `macos-native` (default) + +This is the original built-in path. If you run `opencli chatgpt ...` with no `--surface` flag, OpenCLI keeps using native macOS automation via AppleScript + Accessibility. ### Prerequisites -1. Install the official [ChatGPT Desktop App](https://openai.com/chatgpt/mac/) from OpenAI. +1. Install the official [ChatGPT desktop app](https://openai.com/chatgpt/download/). 2. Grant **Accessibility permissions** to your terminal app in **System Settings → Privacy & Security → Accessibility**. -### Commands -- `opencli chatgpt status`: Check if the ChatGPT app is currently running. -- `opencli chatgpt new`: Activate ChatGPT and press `Cmd+N` to start a new conversation. -- `opencli chatgpt send "message"`: Copy your message to clipboard, activate ChatGPT, paste, and submit. -- `opencli chatgpt read`: Read the last visible message from the focused ChatGPT window via the Accessibility tree. +### Commands on `macos-native` +- `opencli chatgpt status` +- `opencli chatgpt new` +- `opencli chatgpt send "message"` +- `opencli chatgpt read` +- `opencli chatgpt ask "message"` + +### Notes +- `read` returns the **last visible message** from the focused ChatGPT window via the macOS Accessibility tree. +- `ask` remains the original **send + wait + read** macOS-only flow. + +## Surface 2: `macos-cdp` (experimental) + +This preserves the existing documented idea of a **ChatGPT mac CDP mode**, but makes it explicit instead of automatic. + +Use it only on the commands that currently support the narrow CDP path: + +- `opencli chatgpt status --surface macos-cdp` +- `opencli chatgpt read --surface macos-cdp` +- `opencli chatgpt send --surface macos-cdp "message"` + +## Surface 3: `windows-cdp` (experimental) + +This is the new additive surface for the **Windows ChatGPT desktop app**, including WSL workflows that control the Windows app over a local CDP endpoint. + +Use it on the same narrow command subset: + +- `opencli chatgpt status --surface windows-cdp` +- `opencli chatgpt read --surface windows-cdp` +- `opencli chatgpt send --surface windows-cdp "message"` + +> **Important:** OpenCLI does **not** switch ChatGPT into CDP mode automatically just because `OPENCLI_CDP_ENDPOINT` is set. You must opt in per command with `--surface macos-cdp` or `--surface windows-cdp`. -## Approach 2: CDP (Advanced, Electron Debug Mode) +## CDP setup -ChatGPT Desktop is also an Electron app and can be launched with a remote debugging port: +### macOS example ```bash /Applications/ChatGPT.app/Contents/MacOS/ChatGPT \ --remote-debugging-port=9224 + +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224" +# Optional but recommended when multiple targets exist: +export OPENCLI_CDP_TARGET="chatgpt" +``` + +### Windows / WSL example + +Fully quit ChatGPT first, then launch the real Windows app with a debugging port: + +```powershell +ChatGPT.exe --remote-debugging-port=9224 --remote-debugging-address=127.0.0.1 ``` +Then from WSL or the same Windows machine: + ```bash export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224" +export OPENCLI_CDP_TARGET="chatgpt" # optional but recommended ``` -> The CDP approach enables future advanced commands like DOM inspection, model switching, and code extraction. +> On Windows, a **true cold launch matters**. If ChatGPT is already running, relaunching with debug flags may leave you with no usable `/json` target list. + +## Command support matrix + +| Command | `macos-native` | `macos-cdp` | `windows-cdp` | +|---------|-----------------|-------------|---------------| +| `status` | ✅ | ✅ | ✅ | +| `new` | ✅ | — | — | +| `send` | ✅ | ✅ | ✅ | +| `read` | ✅ | ✅ | ✅ | +| `ask` | ✅ | — | — | + +## How the CDP path behaves today -## How It Works +The current CDP implementation is intentionally narrow: -- **AppleScript mode**: Uses `osascript` to control ChatGPT, `pbcopy`/`pbpaste` to paste prompts, and the macOS Accessibility tree to read visible chat messages. -- **CDP mode**: Connects via Chrome DevTools Protocol to the Electron renderer process. +- `status` attaches to the selected ChatGPT target and reports connection state +- `read` returns the **last visible conversation turn** from the current ChatGPT window +- `send` injects the prompt into the active composer and submits it +- the CDP `send` path returns after submission; use `read` later if you want the latest visible output ## Limitations -- macOS only (AppleScript dependency) -- AppleScript mode requires Accessibility permissions -- `read` returns the last visible message in the focused ChatGPT window — scroll first if the message you want is not visible +- `new` and `ask` remain **macOS-native only**. +- CDP support is intentionally limited to `status`, `read`, and `send`. +- If multiple inspectable targets exist, set `OPENCLI_CDP_TARGET=chatgpt`. +- `send` in CDP mode refuses to overwrite an existing draft already sitting in the composer. +- `read` only returns the **last visible** conversation turn, not a full export. +- DOM selectors may drift as ChatGPT desktop changes. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 590b744..9588334 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -53,7 +53,7 @@ Run `opencli list` for the live registry. | **[Cursor](/adapters/desktop/cursor)** | Control Cursor IDE | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | | **[Codex](/adapters/desktop/codex)** | Drive OpenAI Codex CLI agent | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | | **[Antigravity](/adapters/desktop/antigravity)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | -| **[ChatGPT](/adapters/desktop/chatgpt)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` | +| **[ChatGPT](/adapters/desktop/chatgpt)** | Automate ChatGPT desktop app (default macOS native + explicit CDP surfaces) | `status` `new` `send` `read` `ask` | | **[ChatWise](/adapters/desktop/chatwise)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | | **[Notion](/adapters/desktop/notion)** | Search, read, write pages | `status` `search` `read` `new` `write` `sidebar` `favorites` `export` | | **[Discord](/adapters/desktop/discord)** | Desktop messages & channels | `status` `send` `read` `channels` `servers` `search` `members` | diff --git a/src/browser.test.ts b/src/browser.test.ts index d3fc173..abfdfd8 100644 --- a/src/browser.test.ts +++ b/src/browser.test.ts @@ -90,6 +90,25 @@ describe('browser helpers', () => { expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9226/codex'); }); + + it('boosts ChatGPT targets during generic CDP target scoring', () => { + const target = __test__.selectCDPTarget([ + { + type: 'page', + title: 'Session Overview', + url: 'https://example.com/dashboard', + webSocketDebuggerUrl: 'ws://127.0.0.1:9224/other', + }, + { + type: 'page', + title: 'ChatGPT', + url: 'https://chatgpt.com/?window_style=main_view', + webSocketDebuggerUrl: 'ws://127.0.0.1:9224/chatgpt', + }, + ]); + + expect(target?.webSocketDebuggerUrl).toBe('ws://127.0.0.1:9224/chatgpt'); + }); }); describe('BrowserBridge state', () => { diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 9d6a9ca..0a45ed1 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -341,6 +341,7 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number { if (title.includes('codex')) score += 120; if (title.includes('cursor')) score += 120; if (title.includes('chatwise')) score += 120; + if (title.includes('chatgpt')) score += 120; if (title.includes('notion')) score += 120; if (title.includes('discord')) score += 120; if (title.includes('netease')) score += 120; @@ -349,6 +350,7 @@ function scoreCDPTarget(target: CDPTarget, preferredPattern?: RegExp): number { if (url.includes('codex')) score += 100; if (url.includes('cursor')) score += 100; if (url.includes('chatwise')) score += 100; + if (url.includes('chatgpt') || url.includes('chat.openai')) score += 100; if (url.includes('notion')) score += 100; if (url.includes('discord')) score += 100; if (url.includes('netease')) score += 100; diff --git a/src/clis/chatgpt/README.md b/src/clis/chatgpt/README.md index dd23a62..e48463d 100644 --- a/src/clis/chatgpt/README.md +++ b/src/clis/chatgpt/README.md @@ -1,5 +1,8 @@ # ChatGPT Adapter -Control the **ChatGPT macOS Desktop App** from the terminal via AppleScript or CDP. +Control the **ChatGPT Desktop App** from the terminal. + +- Default surface: **macOS native** (AppleScript + Accessibility) +- Experimental opt-in surfaces on `status`, `read`, and `send`: `--surface macos-cdp` or `--surface windows-cdp` 📖 **Full documentation**: [docs/adapters/desktop/chatgpt](../../../docs/adapters/desktop/chatgpt.md) diff --git a/src/clis/chatgpt/README.zh-CN.md b/src/clis/chatgpt/README.zh-CN.md index 799c8d0..7ea4b57 100644 --- a/src/clis/chatgpt/README.zh-CN.md +++ b/src/clis/chatgpt/README.zh-CN.md @@ -1,44 +1,8 @@ # ChatGPT 桌面端适配器 -在终端中直接控制 **ChatGPT macOS 桌面应用**。OpenCLI 支持两种自动化方式。 +在终端中控制 **ChatGPT Desktop App**。 -## 方式一:AppleScript(默认,无需配置) +- 默认 surface:**macos-native**(AppleScript + 辅助功能) +- `status` / `read` / `send` 可显式切到实验性 CDP surface:`--surface macos-cdp` 或 `--surface windows-cdp` -内置命令使用原生 AppleScript 自动化,无需额外启动参数。 - -### 前置条件 -1. 安装官方 [ChatGPT Desktop App](https://openai.com/chatgpt/mac/)。 -2. 在 **系统设置 → 隐私与安全性 → 辅助功能** 中为终端应用授予权限。 - -### 命令 -- `opencli chatgpt status`:检查 ChatGPT 应用是否在运行。 -- `opencli chatgpt new`:激活 ChatGPT 并按 `Cmd+N` 开始新对话。 -- `opencli chatgpt send "消息"`:将消息复制到剪贴板,激活 ChatGPT,粘贴并提交。 -- `opencli chatgpt read`:通过当前聚焦 ChatGPT 窗口的辅助功能树读取最后一条可见消息并返回文本。 - -## 方式二:CDP(高级,Electron 调试模式) - -ChatGPT Desktop 同样是 Electron 应用,可以通过远程调试端口启动以实现更深度的自动化: - -```bash -/Applications/ChatGPT.app/Contents/MacOS/ChatGPT \ - --remote-debugging-port=9224 -``` - -然后设置环境变量: -```bash -export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224" -``` - -> **注意**:CDP 模式支持未来的高级命令(如 DOM 检查、模型切换、代码提取等),与 Cursor 和 Codex 适配器类似。 - -## 工作原理 - -- **AppleScript 模式**:使用 `osascript` 控制 ChatGPT,发送消息时借助 `pbcopy`/`pbpaste` 粘贴文本,读取消息时通过 macOS 辅助功能树获取当前可见聊天内容。 -- **CDP 模式**:通过 Chrome DevTools Protocol 连接到 Electron 渲染进程,直接操作 DOM。 - -## 限制 - -- 仅支持 macOS(AppleScript 依赖) -- AppleScript 模式需要辅助功能权限 -- `read` 返回的是当前聚焦 ChatGPT 窗口里的最后一条可见消息;如果目标消息不在可见区域,需先手动滚动 +📖 **完整文档**: [docs/adapters/desktop/chatgpt](../../../docs/adapters/desktop/chatgpt.md) diff --git a/src/clis/chatgpt/ask.ts b/src/clis/chatgpt/ask.ts index 33b5c2a..a93ea9a 100644 --- a/src/clis/chatgpt/ask.ts +++ b/src/clis/chatgpt/ask.ts @@ -2,6 +2,7 @@ import { execSync, spawnSync } from 'node:child_process'; import { cli, Strategy } from '../../registry.js'; import type { IPage } from '../../types.js'; import { getVisibleChatMessages } from './ax.js'; +import { requireMacOSHost } from './surface.js'; export const askCommand = cli({ site: 'chatgpt', @@ -15,7 +16,9 @@ export const askCommand = cli({ { name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' }, ], columns: ['Role', 'Text'], - func: async (page: IPage | null, kwargs: any) => { + func: async (_page: IPage | null, kwargs: any) => { + requireMacOSHost('ask'); + const text = kwargs.text as string; const timeout = parseInt(kwargs.timeout as string, 10) || 30; diff --git a/src/clis/chatgpt/cdp.test.ts b/src/clis/chatgpt/cdp.test.ts new file mode 100644 index 0000000..ff716d2 --- /dev/null +++ b/src/clis/chatgpt/cdp.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { __test__ } from './cdp.js'; + +describe('chatgpt cdp helpers', () => { + it('formats a ready ChatGPT CDP status row with explicit surface metadata', () => { + expect(__test__.formatChatGPTStatusRow({ + title: 'ChatGPT', + url: 'https://chatgpt.com/?window_style=main_view', + readyState: 'complete', + likelyChatGPT: true, + turnCount: 6, + composerFound: true, + composerTag: 'DIV', + composerEmpty: true, + draftLength: 0, + sendButtonEnabled: true, + busy: false, + }, 'windows-cdp')).toEqual({ + Status: 'Connected', + Surface: 'windows-cdp', + Url: 'https://chatgpt.com/?window_style=main_view', + Title: 'ChatGPT', + Turns: 6, + Composer: 'Ready', + Busy: 'No', + }); + }); + + it('formats send results as successful submissions while keeping table compatibility narrow', () => { + expect(__test__.formatChatGPTSendResultRow({ + surface: 'windows-cdp', + submitMethod: 'button', + injectedText: 'Research this carefully', + })).toEqual({ + Status: 'Success', + Surface: 'windows-cdp', + Submit: 'button', + InjectedText: 'Research this carefully', + }); + }); + + it('normalizes raw turns and strips repeated UI chrome lines', () => { + expect(__test__.normalizeChatGPTTurns([ + { role: 'user', text: 'Hello there' }, + { role: 'assistant', text: 'Sure\nCopy\nShare' }, + { role: 'assistant', text: 'Sure\nCopy\nShare' }, + { role: 'assistant', text: ' ' }, + ])).toEqual([ + { Role: 'User', Text: 'Hello there' }, + { Role: 'Assistant', Text: 'Sure' }, + ]); + }); +}); diff --git a/src/clis/chatgpt/cdp.ts b/src/clis/chatgpt/cdp.ts new file mode 100644 index 0000000..3627400 --- /dev/null +++ b/src/clis/chatgpt/cdp.ts @@ -0,0 +1,510 @@ +import { CDPBridge } from '../../browser/index.js'; +import { CliError } from '../../errors.js'; +import { browserSession } from '../../runtime.js'; +import type { IPage } from '../../types.js'; +import type { ChatGPTSurface } from './surface.js'; +import { chatGPTCDPHint } from './surface.js'; + +type RawChatGPTTurn = { + role?: string | null; + text?: string | null; +}; + +type ChatGPTCDPProbe = { + title: string; + url: string; + readyState: string; + likelyChatGPT: boolean; + turnCount: number; + composerFound: boolean; + composerTag: string; + composerEmpty: boolean; + draftLength: number; + sendButtonEnabled: boolean; + busy: boolean; +}; + +export type ChatGPTTurn = { + Role: string; + Text: string; +}; + +const CHATGPT_UI_CHROME = new Set([ + 'Copy', + 'Edit', + 'Share', + 'Retry', + 'Regenerate', + 'Read aloud', + 'Good response', + 'Bad response', + 'More', + 'You said:', + 'ChatGPT said:', + '你说:', + 'ChatGPT 说:', + 'Sources', + '来源', +]); + +export function formatChatGPTStatusRow( + probe: ChatGPTCDPProbe, + surface: ChatGPTSurface, +): Record { + return { + Status: probe.likelyChatGPT ? 'Connected' : 'Connected (target unverified)', + Surface: surface, + Url: probe.url, + Title: probe.title, + Turns: probe.turnCount, + Composer: !probe.composerFound + ? 'Missing' + : probe.composerEmpty + ? 'Ready' + : `Draft (${probe.draftLength} chars)`, + Busy: probe.busy ? 'Yes' : 'No', + }; +} + +export function formatChatGPTSendResultRow(opts: { + surface: ChatGPTSurface; + submitMethod?: string; + injectedText: string; +}): Record { + return { + Status: 'Success', + Surface: opts.surface, + Submit: opts.submitMethod || '', + InjectedText: opts.injectedText, + }; +} + +export function normalizeChatGPTText(text: string | null | undefined): string { + const cleaned = String(text ?? '') + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .replace(/\r/g, '') + .trim(); + + if (!cleaned) return ''; + + const lines = cleaned + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length <= 1) return cleaned; + + const filtered = lines.filter((line) => !CHATGPT_UI_CHROME.has(line)); + return filtered.join('\n').trim(); +} + +export function normalizeChatGPTTurns(rawTurns: RawChatGPTTurn[]): ChatGPTTurn[] { + const normalized: ChatGPTTurn[] = []; + + for (const raw of rawTurns) { + const text = normalizeChatGPTText(raw?.text); + if (!text) continue; + + const role = normalizeChatGPTRole(raw?.role); + const nextTurn = { Role: role, Text: text }; + const prevTurn = normalized[normalized.length - 1]; + + if (prevTurn && prevTurn.Role === nextTurn.Role && prevTurn.Text === nextTurn.Text) { + continue; + } + + normalized.push(nextTurn); + } + + return normalized; +} + +export async function probeChatGPTCDP(surface: ChatGPTSurface): Promise> { + return withChatGPTCDP(surface, 'status', async (page) => { + return formatChatGPTStatusRow(await probeChatGPTPage(page), surface); + }); +} + +export async function readChatGPTCDP(surface: ChatGPTSurface): Promise { + return withChatGPTCDP(surface, 'read', async (page) => { + const rawTurns = await page.evaluate(readScript()) as RawChatGPTTurn[]; + const turns = normalizeChatGPTTurns(Array.isArray(rawTurns) ? rawTurns : []); + + if (turns.length > 0) { + return [turns[turns.length - 1]!]; + } + + const probe = await probeChatGPTPage(page); + const detail = probe.likelyChatGPT + ? 'No visible chat messages were found in the current ChatGPT window.' + : 'Connected CDP target does not look like ChatGPT. Try setting OPENCLI_CDP_TARGET=chatgpt.'; + + return [{ Role: 'System', Text: detail }]; + }); +} + +export async function sendChatGPTCDP( + text: string, + surface: ChatGPTSurface, +): Promise>> { + return withChatGPTCDP(surface, 'send', async (page) => { + const probe = await probeChatGPTPage(page); + + if (!probe.likelyChatGPT) { + throw new CliError( + 'COMMAND_EXEC', + 'Connected CDP target does not look like ChatGPT.', + `${chatGPTCDPHint(surface)} If multiple inspectable targets exist, set OPENCLI_CDP_TARGET=chatgpt.`, + ); + } + + if (probe.busy) { + throw new CliError( + 'COMMAND_EXEC', + 'ChatGPT is currently busy or still generating a response.', + 'Wait for the current response to finish (or stop it in the UI) before using the experimental CDP send path again.', + ); + } + + await page.evaluate(injectScript(text)); + await page.wait(0.25); + + let submitMethod = await page.evaluate(submitScript()) as string | null; + if (!submitMethod) { + await page.pressKey('Enter'); + submitMethod = 'keyboard-enter'; + } + + await page.wait(0.5); + + return [ + formatChatGPTSendResultRow({ + surface, + submitMethod, + injectedText: text, + }), + ]; + }); +} + +async function withChatGPTCDP( + surface: ChatGPTSurface, + commandName: string, + fn: (page: IPage) => Promise, +): Promise { + const endpoint = process.env.OPENCLI_CDP_ENDPOINT; + if (!endpoint) { + throw new CliError( + 'CONFIG', + `OPENCLI_CDP_ENDPOINT is required for ChatGPT ${commandName} on the ${surface} surface.`, + chatGPTCDPHint(surface), + ); + } + + try { + return await browserSession(CDPBridge as any, fn, { workspace: 'site:chatgpt' }); + } catch (err: any) { + if (err instanceof CliError) throw err; + + const message = String(err?.message ?? err ?? 'Unknown error'); + const looksLikeConnectFailure = /ECONNREFUSED|fetch failed|Failed to fetch CDP targets|No inspectable targets found|CDP connect timeout/i.test(message); + + if (looksLikeConnectFailure) { + throw new CliError( + 'BROWSER_CONNECT', + `Could not attach to the ChatGPT CDP endpoint at ${endpoint}.`, + chatGPTCDPHint(surface), + ); + } + + const looksLikeSelectorFailure = /composer|ChatGPT|target/i.test(message); + throw new CliError( + looksLikeSelectorFailure ? 'COMMAND_EXEC' : 'BROWSER_CONNECT', + `ChatGPT ${commandName} failed on the ${surface} surface: ${message}`, + chatGPTCDPHint(surface), + ); + } +} + +async function probeChatGPTPage(page: IPage): Promise { + const probe = await page.evaluate(statusScript()) as ChatGPTCDPProbe; + if (!probe || typeof probe !== 'object') { + throw new CliError('COMMAND_EXEC', 'ChatGPT CDP probe returned an invalid page state.'); + } + return probe; +} + +function normalizeChatGPTRole(role: string | null | undefined): string { + const value = String(role ?? '').trim().toLowerCase(); + if (value === 'user' || value === 'human') return 'User'; + if (value === 'assistant' || value === 'ai') return 'Assistant'; + if (value === 'system') return 'System'; + return 'Message'; +} + +function domHelpersScript(): string { + return ` + const normalizeText = (value) => String(value ?? '') + .replace(/[\\u200B-\\u200D\\uFEFF]/g, '') + .replace(/\\r/g, '') + .trim(); + + const elementText = (el) => { + if (!el) return ''; + const value = typeof el.value === 'string' ? el.value : ''; + const innerText = typeof el.innerText === 'string' ? el.innerText : ''; + const textContent = typeof el.textContent === 'string' ? el.textContent : ''; + return normalizeText(value || innerText || textContent); + }; + + const isVisible = (el) => { + if (!el || !(el instanceof Element)) return false; + const style = window.getComputedStyle(el); + if (!style || style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const scoreComposer = (el) => { + if (!isVisible(el)) return Number.NEGATIVE_INFINITY; + let score = 0; + const dataTestId = (el.getAttribute('data-testid') || '').toLowerCase(); + const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase(); + const placeholder = (el.getAttribute('placeholder') || '').toLowerCase(); + + if (el.id === 'prompt-textarea') score += 400; + if (dataTestId.includes('composer')) score += 240; + if (dataTestId.includes('prompt')) score += 180; + if (dataTestId.includes('message')) score += 80; + if (el.tagName === 'TEXTAREA') score += 140; + if (el.getAttribute('contenteditable') === 'true') score += 120; + if (ariaLabel.includes('message')) score += 120; + if (placeholder.includes('message')) score += 120; + if (el.closest('form')) score += 80; + if (el.closest('footer')) score += 40; + if (el.disabled || el.getAttribute('aria-disabled') === 'true') score -= 500; + + return score; + }; + + const selectComposer = () => { + const selector = [ + '#prompt-textarea', + '[data-testid="composer-text-input"]', + '[data-testid*="composer"]', + 'form textarea', + 'form [contenteditable="true"]', + 'textarea', + '[contenteditable="true"][data-lexical-editor="true"]', + '[contenteditable="true"]', + ].join(','); + + let best = null; + let bestScore = Number.NEGATIVE_INFINITY; + + for (const el of Array.from(document.querySelectorAll(selector))) { + const score = scoreComposer(el); + if (score > bestScore) { + best = el; + bestScore = score; + } + } + + return best; + }; + + const selectSendButton = () => { + const selector = [ + 'button[data-testid="send-button"]', + 'button[data-testid*="send"]', + 'button[aria-label*="Send"]', + 'form button[type="submit"]', + 'form button', + ].join(','); + + let best = null; + let bestScore = Number.NEGATIVE_INFINITY; + + for (const el of Array.from(document.querySelectorAll(selector))) { + if (!isVisible(el)) continue; + let score = 0; + const dataTestId = (el.getAttribute('data-testid') || '').toLowerCase(); + const ariaLabel = (el.getAttribute('aria-label') || el.textContent || '').toLowerCase(); + + if (dataTestId.includes('send')) score += 300; + if (ariaLabel.includes('send')) score += 240; + if (el.getAttribute('type') === 'submit') score += 100; + if (el.closest('form')) score += 60; + if (el.disabled || el.getAttribute('aria-disabled') === 'true') score -= 100; + + if (score > bestScore) { + best = el; + bestScore = score; + } + } + + return best; + }; + `; +} + +function statusScript(): string { + return ` + (() => { + ${domHelpersScript()} + + const composer = selectComposer(); + const sendButton = selectSendButton(); + const stopButton = document.querySelector( + 'button[aria-label*="Stop"], button[data-testid*="stop"], button[aria-label*="stop"]' + ); + const turnNodes = Array.from(document.querySelectorAll([ + '[data-message-author-role]', + 'article[data-testid^="conversation-turn-"]', + '[data-testid^="conversation-turn-"]', + '[role="log"] > *', + ].join(','))).filter(isVisible); + + const url = window.location.href || ''; + const title = document.title || ''; + const haystack = (title + ' ' + url).toLowerCase(); + const draft = elementText(composer); + + return { + title, + url, + readyState: document.readyState, + likelyChatGPT: /chatgpt|chat\\.openai|openai/.test(haystack), + turnCount: turnNodes.length, + composerFound: !!composer, + composerTag: composer ? composer.tagName : '', + composerEmpty: draft.length === 0, + draftLength: draft.length, + sendButtonEnabled: !!sendButton && !(sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true'), + busy: !!stopButton, + }; + })() + `; +} + +function readScript(): string { + return ` + (() => { + ${domHelpersScript()} + + const seen = new Set(); + const turns = []; + const selector = [ + 'article[data-testid^="conversation-turn-"]', + '[data-testid^="conversation-turn-"]', + '[data-message-author-role]', + '[role="log"] > *', + ].join(','); + + for (const node of Array.from(document.querySelectorAll(selector))) { + const container = + node.closest('article[data-testid^="conversation-turn-"]') || + node.closest('[data-testid^="conversation-turn-"]') || + node.closest('[data-message-author-role]') || + node; + + if (!container || seen.has(container) || !isVisible(container)) continue; + seen.add(container); + + const roleNode = + container.matches('[data-message-author-role]') + ? container + : container.querySelector('[data-message-author-role]'); + const role = + container.getAttribute('data-turn') || + (roleNode ? roleNode.getAttribute('data-message-author-role') : '') || + ''; + + const contentNode = + container.querySelector('.markdown, .prose, [data-testid*="message-content"], [data-testid*="conversation-turn-content"], .whitespace-pre-wrap, p, li, pre, code') || + container; + const text = elementText(contentNode || container); + + if (!text) continue; + turns.push({ role, text }); + } + + if (turns.length > 0) return turns; + + const fallback = elementText(document.querySelector('main, [role="main"], [role="log"]') || document.body); + if (!fallback) return []; + + return [{ role: 'message', text: fallback }]; + })() + `; +} + +function injectScript(text: string): string { + return ` + (() => { + ${domHelpersScript()} + + const text = ${JSON.stringify(text)}; + const composer = selectComposer(); + if (!composer) { + throw new Error('Could not find the ChatGPT composer in the current CDP target.'); + } + + const existing = elementText(composer); + if (existing.length > 0) { + throw new Error('The ChatGPT composer already contains draft text. Refusing to overwrite it in experimental CDP mode.'); + } + + composer.focus(); + + if (composer.tagName === 'TEXTAREA') { + const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set; + if (!setter) throw new Error('Could not access the textarea setter for the ChatGPT composer.'); + setter.call(composer, text); + composer.dispatchEvent(new Event('input', { bubbles: true })); + composer.dispatchEvent(new Event('change', { bubbles: true })); + } else { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(composer); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + document.execCommand('insertText', false, text); + composer.dispatchEvent(new Event('input', { bubbles: true })); + } + + return 'injected'; + })() + `; +} + +function submitScript(): string { + return ` + (() => { + ${domHelpersScript()} + + const sendButton = selectSendButton(); + if (sendButton && !(sendButton.disabled || sendButton.getAttribute('aria-disabled') === 'true')) { + sendButton.click(); + return 'button'; + } + + const composer = selectComposer(); + const form = composer ? composer.closest('form') : null; + if (form && typeof form.requestSubmit === 'function') { + form.requestSubmit(); + return 'form-requestSubmit'; + } + + return ''; + })() + `; +} + +export const __test__ = { + formatChatGPTSendResultRow, + formatChatGPTStatusRow, + normalizeChatGPTText, + normalizeChatGPTTurns, +}; diff --git a/src/clis/chatgpt/compat.test.ts b/src/clis/chatgpt/compat.test.ts new file mode 100644 index 0000000..d97f7b7 --- /dev/null +++ b/src/clis/chatgpt/compat.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { getRegistry } from '../../registry.js'; +import { askCommand } from './ask.js'; +import { newCommand } from './new.js'; +import { readCommand } from './read.js'; +import { sendCommand } from './send.js'; +import { statusCommand } from './status.js'; + +describe('chatgpt default command surface compatibility', () => { + it('keeps the existing five chatgpt commands', () => { + const names = [...getRegistry().values()] + .filter((cmd) => cmd.site === 'chatgpt') + .map((cmd) => cmd.name) + .sort(); + + expect(names).toEqual(['ask', 'new', 'read', 'send', 'status']); + }); + + it('keeps default table output narrow and preserves existing default wording', () => { + expect(statusCommand.description).toBe('Check ChatGPT Desktop App status'); + expect(statusCommand.columns).toEqual(['Status']); + + expect(sendCommand.description).toBe('Send a message to the active ChatGPT Desktop App window'); + expect(sendCommand.columns).toEqual(['Status']); + expect(sendCommand.args.find((arg) => arg.name === 'text')?.help).toBe('Message to send'); + + expect(readCommand.description).toBe('Read the last visible message from the focused ChatGPT Desktop window'); + expect(readCommand.columns).toEqual(['Role', 'Text']); + }); + + it('adds the explicit surface selector only to the narrow CDP-safe command subset', () => { + expect(statusCommand.args.find((arg) => arg.name === 'surface')).toMatchObject({ + default: 'macos-native', + choices: ['macos-native', 'macos-cdp', 'windows-cdp'], + }); + expect(readCommand.args.find((arg) => arg.name === 'surface')).toMatchObject({ + default: 'macos-native', + choices: ['macos-native', 'macos-cdp', 'windows-cdp'], + }); + expect(sendCommand.args.find((arg) => arg.name === 'surface')).toMatchObject({ + default: 'macos-native', + choices: ['macos-native', 'macos-cdp', 'windows-cdp'], + }); + + expect(newCommand.args.find((arg) => arg.name === 'surface')).toBeUndefined(); + expect(askCommand.args.find((arg) => arg.name === 'surface')).toBeUndefined(); + }); +}); diff --git a/src/clis/chatgpt/new.ts b/src/clis/chatgpt/new.ts index fe065ab..e066495 100644 --- a/src/clis/chatgpt/new.ts +++ b/src/clis/chatgpt/new.ts @@ -1,6 +1,7 @@ import { execSync } from 'node:child_process'; import { cli, Strategy } from '../../registry.js'; import type { IPage } from '../../types.js'; +import { requireMacOSHost } from './surface.js'; export const newCommand = cli({ site: 'chatgpt', @@ -11,14 +12,16 @@ export const newCommand = cli({ browser: false, args: [], columns: ['Status'], - func: async (page: IPage | null) => { + func: async (_page: IPage | null) => { + requireMacOSHost('new'); + try { execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); execSync("osascript -e 'delay 0.5'"); execSync("osascript -e 'tell application \"System Events\" to keystroke \"n\" using command down'"); return [{ Status: 'Success' }]; } catch (err: any) { - return [{ Status: "Error: " + err.message }]; + return [{ Status: 'Error: ' + err.message }]; } }, }); diff --git a/src/clis/chatgpt/read.ts b/src/clis/chatgpt/read.ts index c77231c..92894db 100644 --- a/src/clis/chatgpt/read.ts +++ b/src/clis/chatgpt/read.ts @@ -2,6 +2,12 @@ import { execSync } from 'node:child_process'; import { cli, Strategy } from '../../registry.js'; import type { IPage } from '../../types.js'; import { getVisibleChatMessages } from './ax.js'; +import { readChatGPTCDP } from './cdp.js'; +import { + isChatGPTCDPSurface, + normalizeChatGPTSurface, + requireMacOSHost, +} from './surface.js'; export const readCommand = cli({ site: 'chatgpt', @@ -10,9 +16,23 @@ export const readCommand = cli({ domain: 'localhost', strategy: Strategy.PUBLIC, browser: false, - args: [], + args: [{ + name: 'surface', + required: false, + default: 'macos-native', + choices: ['macos-native', 'macos-cdp', 'windows-cdp'], + help: 'Target ChatGPT surface: macos-native (default), macos-cdp, windows-cdp', + }], columns: ['Role', 'Text'], - func: async (page: IPage | null) => { + func: async (_page: IPage | null, kwargs: any) => { + const surface = normalizeChatGPTSurface(kwargs.surface); + + if (isChatGPTCDPSurface(surface)) { + return await readChatGPTCDP(surface); + } + + requireMacOSHost('read'); + try { execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); execSync("osascript -e 'delay 0.3'"); @@ -24,7 +44,7 @@ export const readCommand = cli({ return [{ Role: 'Assistant', Text: messages[messages.length - 1] }]; } catch (err: any) { - throw new Error("Failed to read from ChatGPT: " + err.message); + throw new Error('Failed to read from ChatGPT: ' + err.message); } }, }); diff --git a/src/clis/chatgpt/send.ts b/src/clis/chatgpt/send.ts index a28f463..a12d1cb 100644 --- a/src/clis/chatgpt/send.ts +++ b/src/clis/chatgpt/send.ts @@ -1,6 +1,12 @@ import { execSync, spawnSync } from 'node:child_process'; import { cli, Strategy } from '../../registry.js'; import type { IPage } from '../../types.js'; +import { sendChatGPTCDP } from './cdp.js'; +import { + isChatGPTCDPSurface, + normalizeChatGPTSurface, + requireMacOSHost, +} from './surface.js'; export const sendCommand = cli({ site: 'chatgpt', @@ -9,30 +15,49 @@ export const sendCommand = cli({ domain: 'localhost', strategy: Strategy.PUBLIC, browser: false, - args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }], + args: [ + { name: 'text', required: true, positional: true, help: 'Message to send' }, + { + name: 'surface', + required: false, + default: 'macos-native', + choices: ['macos-native', 'macos-cdp', 'windows-cdp'], + help: 'Target ChatGPT surface: macos-native (default), macos-cdp, windows-cdp', + }, + ], columns: ['Status'], - func: async (page: IPage | null, kwargs: any) => { + func: async (_page: IPage | null, kwargs: any) => { const text = kwargs.text as string; + const surface = normalizeChatGPTSurface(kwargs.surface); + + if (isChatGPTCDPSurface(surface)) { + return await sendChatGPTCDP(text, surface); + } + + requireMacOSHost('send'); + try { // Backup current clipboard content let clipBackup = ''; try { clipBackup = execSync('pbpaste', { encoding: 'utf-8' }); - } catch { /* clipboard may be empty */ } + } catch { + // clipboard may be empty + } // Copy text to clipboard spawnSync('pbcopy', { input: text }); - + execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); execSync("osascript -e 'delay 0.5'"); - + const cmd = "osascript " + "-e 'tell application \"System Events\"' " + "-e 'keystroke \"v\" using command down' " + "-e 'delay 0.2' " + "-e 'keystroke return' " + "-e 'end tell'"; - + execSync(cmd); // Restore original clipboard content @@ -42,7 +67,7 @@ export const sendCommand = cli({ return [{ Status: 'Success' }]; } catch (err: any) { - return [{ Status: "Error: " + err.message }]; + return [{ Status: 'Error: ' + err.message }]; } }, }); diff --git a/src/clis/chatgpt/status.ts b/src/clis/chatgpt/status.ts index fdad539..9b9116a 100644 --- a/src/clis/chatgpt/status.ts +++ b/src/clis/chatgpt/status.ts @@ -1,17 +1,37 @@ import { execSync } from 'node:child_process'; import { cli, Strategy } from '../../registry.js'; import type { IPage } from '../../types.js'; +import { probeChatGPTCDP } from './cdp.js'; +import { + isChatGPTCDPSurface, + normalizeChatGPTSurface, + requireMacOSHost, +} from './surface.js'; export const statusCommand = cli({ site: 'chatgpt', name: 'status', - description: 'Check if ChatGPT Desktop App is running natively on macOS', + description: 'Check ChatGPT Desktop App status', domain: 'localhost', strategy: Strategy.PUBLIC, browser: false, - args: [], + args: [{ + name: 'surface', + required: false, + default: 'macos-native', + choices: ['macos-native', 'macos-cdp', 'windows-cdp'], + help: 'Target ChatGPT surface: macos-native (default), macos-cdp, windows-cdp', + }], columns: ['Status'], - func: async (page: IPage | null) => { + func: async (_page: IPage | null, kwargs: any) => { + const surface = normalizeChatGPTSurface(kwargs.surface); + + if (isChatGPTCDPSurface(surface)) { + return [await probeChatGPTCDP(surface)]; + } + + requireMacOSHost('status'); + try { const output = execSync("osascript -e 'application \"ChatGPT\" is running'", { encoding: 'utf-8' }).trim(); return [{ Status: output === 'true' ? 'Running' : 'Stopped' }]; diff --git a/src/clis/chatgpt/surface.ts b/src/clis/chatgpt/surface.ts new file mode 100644 index 0000000..aea894d --- /dev/null +++ b/src/clis/chatgpt/surface.ts @@ -0,0 +1,41 @@ +import { CliError } from '../../errors.js'; + +export const CHATGPT_SURFACES = ['macos-native', 'macos-cdp', 'windows-cdp'] as const; + +export type ChatGPTSurface = typeof CHATGPT_SURFACES[number]; + +export const DEFAULT_CHATGPT_SURFACE: ChatGPTSurface = 'macos-native'; + +export function normalizeChatGPTSurface(value: unknown): ChatGPTSurface { + const normalized = String(value ?? '').trim().toLowerCase(); + return (CHATGPT_SURFACES as readonly string[]).includes(normalized) + ? normalized as ChatGPTSurface + : DEFAULT_CHATGPT_SURFACE; +} + +export function isChatGPTCDPSurface(surface: ChatGPTSurface): boolean { + return surface !== 'macos-native'; +} + +export function requireMacOSHost(commandName: string): void { + if (process.platform === 'darwin') return; + + throw new CliError( + 'COMMAND_EXEC', + `ChatGPT ${commandName} defaults to the macOS-native surface, but this host is ${process.platform}.`, + 'On macOS, rerun normally. From WSL/Linux targeting the Windows ChatGPT desktop app, rerun with --surface windows-cdp and OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224.', + ); +} + +export function chatGPTCDPHint(surface: ChatGPTSurface): string { + if (surface === 'windows-cdp') { + return 'Experimental ChatGPT windows-cdp surface: fully quit ChatGPT first, then launch the Windows ChatGPT app with `ChatGPT.exe --remote-debugging-port=9224 --remote-debugging-address=127.0.0.1`. After that export `OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224`. If multiple inspectable targets exist, set `OPENCLI_CDP_TARGET=chatgpt`.'; + } + + return 'Experimental ChatGPT macos-cdp surface: launch `/Applications/ChatGPT.app/Contents/MacOS/ChatGPT --remote-debugging-port=9224`, then export `OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224`. If multiple inspectable targets exist, set `OPENCLI_CDP_TARGET=chatgpt`.'; +} + +export const __test__ = { + normalizeChatGPTSurface, + isChatGPTCDPSurface, +}; From 2b13b372a032f705b75b69a22f6fa9eb5bddce6d Mon Sep 17 00:00:00 2001 From: Kasumi <125308847+KasumiChen@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:33:15 -0700 Subject: [PATCH 2/2] fix(chatgpt): clean windows cdp busy readback --- src/clis/chatgpt/cdp.test.ts | 6 +++++ src/clis/chatgpt/cdp.ts | 50 +++++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/clis/chatgpt/cdp.test.ts b/src/clis/chatgpt/cdp.test.ts index ff716d2..8f7cd81 100644 --- a/src/clis/chatgpt/cdp.test.ts +++ b/src/clis/chatgpt/cdp.test.ts @@ -50,4 +50,10 @@ describe('chatgpt cdp helpers', () => { { Role: 'Assistant', Text: 'Sure' }, ]); }); + + it('strips localized reasoning chrome and timing-only lines from readback text', () => { + expect(__test__.normalizeChatGPTText('立即回答')).toBe(''); + expect(__test__.normalizeChatGPTText('Thought for 10s')).toBe(''); + expect(__test__.normalizeChatGPTText('ChatGPT 说:\n已完成推理\n立即回答\n\nOK')).toBe('OK'); + }); }); diff --git a/src/clis/chatgpt/cdp.ts b/src/clis/chatgpt/cdp.ts index 3627400..d3915ec 100644 --- a/src/clis/chatgpt/cdp.ts +++ b/src/clis/chatgpt/cdp.ts @@ -45,6 +45,18 @@ const CHATGPT_UI_CHROME = new Set([ 'ChatGPT 说:', 'Sources', '来源', + 'Finished thinking', + 'Answer immediately', + '已完成推理', + '立即回答', + '复制消息', + '复制回复', + '编辑消息', + '喜欢', + '不喜欢', + '分享', + '更多操作', + '切换模型', ]); export function formatChatGPTStatusRow( @@ -79,6 +91,15 @@ export function formatChatGPTSendResultRow(opts: { }; } +function isChatGPTChromeLine(text: string | null | undefined): boolean { + const cleaned = String(text ?? '').trim(); + if (!cleaned) return false; + if (CHATGPT_UI_CHROME.has(cleaned)) return true; + + return /^thought for\s+\d+\s*s$/i.test(cleaned) + || /^思考了?\s*\d+\s*秒$/.test(cleaned); +} + export function normalizeChatGPTText(text: string | null | undefined): string { const cleaned = String(text ?? '') .replace(/[\u200B-\u200D\uFEFF]/g, '') @@ -86,15 +107,14 @@ export function normalizeChatGPTText(text: string | null | undefined): string { .trim(); if (!cleaned) return ''; + if (isChatGPTChromeLine(cleaned)) return ''; const lines = cleaned .split('\n') .map((line) => line.trim()) .filter(Boolean); - if (lines.length <= 1) return cleaned; - - const filtered = lines.filter((line) => !CHATGPT_UI_CHROME.has(line)); + const filtered = lines.filter((line) => !isChatGPTChromeLine(line)); return filtered.join('\n').trim(); } @@ -127,16 +147,28 @@ export async function probeChatGPTCDP(surface: ChatGPTSurface): Promise { return withChatGPTCDP(surface, 'read', async (page) => { - const rawTurns = await page.evaluate(readScript()) as RawChatGPTTurn[]; - const turns = normalizeChatGPTTurns(Array.isArray(rawTurns) ? rawTurns : []); + const rawTurnValue = await page.evaluate(readScript()); + const rawTurns = Array.isArray(rawTurnValue) ? rawTurnValue as RawChatGPTTurn[] : []; + const turns = normalizeChatGPTTurns(rawTurns); + const probe = await probeChatGPTPage(page); if (turns.length > 0) { - return [turns[turns.length - 1]!]; + const lastTurn = turns[turns.length - 1]!; + const lastRawTurn = rawTurns[rawTurns.length - 1]; + const lastRawRole = normalizeChatGPTRole(lastRawTurn?.role); + const lastRawText = normalizeChatGPTText(lastRawTurn?.text); + + if (probe.busy && lastTurn.Role === 'User' && lastRawRole === 'Assistant' && !lastRawText) { + return [{ Role: 'System', Text: 'ChatGPT is currently generating a response.' }]; + } + + return [lastTurn]; } - const probe = await probeChatGPTPage(page); const detail = probe.likelyChatGPT - ? 'No visible chat messages were found in the current ChatGPT window.' + ? probe.busy + ? 'ChatGPT is currently generating a response.' + : 'No visible chat messages were found in the current ChatGPT window.' : 'Connected CDP target does not look like ChatGPT. Try setting OPENCLI_CDP_TARGET=chatgpt.'; return [{ Role: 'System', Text: detail }]; @@ -421,7 +453,7 @@ function readScript(): string { ''; const contentNode = - container.querySelector('.markdown, .prose, [data-testid*="message-content"], [data-testid*="conversation-turn-content"], .whitespace-pre-wrap, p, li, pre, code') || + container.querySelector('.markdown, .prose, [data-testid*="message-content"], [data-testid*="conversation-turn-content"], .whitespace-pre-wrap, pre, code') || container; const text = elementText(contentNode || container);