From ac3a25ca373e35fbdff424970847db6c504c1cbc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:38:28 -0700 Subject: [PATCH] Wait for Codex's live composer before pasting linked issue drafts (#25779) * Wait for Codex's live composer before pasting launch drafts * Keep timed-out Codex draft delivery consumed across remounts * Document the checked terminal fixtures used by the remount test * Distinguish Codex's reserved footer row from early multiline input * Complete transcript baselines and suppress hidden terminal strings * Align watcher scan budget with linked-directory reconciliation * Drop unused snapshots after main removed the readiness census --- .../serialize-grid-transcript-replay.test.ts | 5 + .../codex-fullscreen-custom-footer.meta.json | 9 ++ .../codex-fullscreen-custom-footer.txt | 1 + .../codex-fullscreen-early-input.meta.json | 9 ++ .../codex-fullscreen-early-input.txt | 1 + ...fullscreen-multiline-early-input.meta.json | 9 ++ ...codex-fullscreen-multiline-early-input.txt | 1 + .../codex-fullscreen-startup.meta.json | 9 ++ .../__fixtures__/codex-fullscreen-startup.txt | 1 + .../codex-startup-paste-transcripts.test.ts | 55 +++++++++++ .../file-explorer-watch-reconcile.test.ts | 7 +- ...nection-ssh-startup-draft-delivery.test.ts | 23 ++++- .../pty-connection/pane-serializer-settle.ts | 14 +++ .../src/lib/agent-paste-draft.test.ts | 32 ++++++- src/renderer/src/lib/agent-paste-draft.ts | 18 ++-- .../src/lib/startup-draft-input-kind.test.ts | 32 ++++++- .../codex-composer-ready-scanner.test.ts | 70 ++++++++++++++ src/shared/codex-composer-ready-scanner.ts | 96 +++++++++++++++++++ src/shared/draft-paste-ready-scanner.ts | 62 ++---------- src/shared/opencode-agent-row-scanner.ts | 58 +---------- src/shared/terminal-escape-scanner.ts | 78 +++++++++++++++ 21 files changed, 462 insertions(+), 128 deletions(-) create mode 100644 src/main/runtime/__fixtures__/codex-fullscreen-custom-footer.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-fullscreen-custom-footer.txt create mode 100644 src/main/runtime/__fixtures__/codex-fullscreen-early-input.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-fullscreen-early-input.txt create mode 100644 src/main/runtime/__fixtures__/codex-fullscreen-multiline-early-input.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-fullscreen-multiline-early-input.txt create mode 100644 src/main/runtime/__fixtures__/codex-fullscreen-startup.meta.json create mode 100644 src/main/runtime/__fixtures__/codex-fullscreen-startup.txt create mode 100644 src/main/runtime/codex-startup-paste-transcripts.test.ts create mode 100644 src/shared/codex-composer-ready-scanner.test.ts create mode 100644 src/shared/codex-composer-ready-scanner.ts create mode 100644 src/shared/terminal-escape-scanner.ts diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index c7a87341c10..41b840ea644 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -74,6 +74,11 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { 'codex-0-158-0-approval': 12, 'codex-0-158-0-timed-turn': 20, 'codex-0-158-0-trustprompt': 36, + // Fullscreen startup captures diverge identically with the base b58f8197dc36 serializer. + 'codex-fullscreen-custom-footer': 18, + 'codex-fullscreen-early-input': 4, + 'codex-fullscreen-multiline-early-input': 10, + 'codex-fullscreen-startup': 14, 'claude-dialog-trust-workspace-answered': 13, // DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this // transcript's divergences is the same shape: `visible-grid row=0`, a true-colour diff --git a/src/main/runtime/__fixtures__/codex-fullscreen-custom-footer.meta.json b/src/main/runtime/__fixtures__/codex-fullscreen-custom-footer.meta.json new file mode 100644 index 00000000000..9ec193c4eb0 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-fullscreen-custom-footer.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-06T04:19:41.191Z", + "platform": "darwin", + "command": ["codex", "--no-daemon", "-c", "tui.status_line=[\"model-name\"]"], + "cols": 120, + "rows": 40, + "note": "Codex CLI 0.160.1; single model-name status item", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-fullscreen-custom-footer.txt b/src/main/runtime/__fixtures__/codex-fullscreen-custom-footer.txt new file mode 100644 index 00000000000..105e493bf3c --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-fullscreen-custom-footer.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1003h[?1006h[?25l[?2026h[?25l>_OpenAI Codex (v0.160.1)loadingWhatarewepokingwithametaphoricalstick?⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca/workspaces/orca/workspace-xxxxxxxxxxxxxxxxxxxxxxxxx permissions: YOLO modeWhatarewepokingwithametaphoricalstick?[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25lGPT-6.1-Sol[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ workspace-xxxxxxxxxxxxxx]0;⠼ workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ workspace-xxxxxxxxxxxxxx]0;⠇ workspace-xxxxxxxxxxxxxx]0;⠏ workspace-xxxxxxxxxxxxxx]0;⠋ workspace-xxxxxxxxxxxxxx]0;workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-fullscreen-early-input.meta.json b/src/main/runtime/__fixtures__/codex-fullscreen-early-input.meta.json new file mode 100644 index 00000000000..03d797e05df --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-fullscreen-early-input.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-06T04:15:29.384Z", + "platform": "darwin", + "command": ["codex", "--no-daemon"], + "cols": 120, + "rows": 40, + "note": "Codex CLI 0.160.1; typing before live footer", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-fullscreen-early-input.txt b/src/main/runtime/__fixtures__/codex-fullscreen-early-input.txt new file mode 100644 index 00000000000..995952024f4 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-fullscreen-early-input.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1003h[?1006h[?25l[?2026h[?25l>_OpenAI Codex (v0.160.1)loadingMaythesourcebewithyou.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca/workspaces/orca/workspace-xxxxxxxxxxxxxxxxxxxxxxxxx permissions: YOLO modeMaythesourcebewithyou.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l early note[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25lGPT-6.1-Soldefault·~/orca/workspaces/orca/workspace-xxxxxxxxxxxxxxxxxxxxxxxxx·smarter-codex-issue-paste-detectio…[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ workspace-xxxxxxxxxxxxxx]0;⠦ workspace-xxxxxxxxxxxxxx[?2026h[?25lhigh · ~/orca/workspaces/orca/smarte-codex-issue-paste-dtecion · smarte-codex-issue-paste-dtecion ·tabtoqueuemessage[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l]0;⠇ workspace-xxxxxxxxxxxxxx]0;⠏ workspace-xxxxxxxxxxxxxx]0;⠋ workspace-xxxxxxxxxxxxxx]0;workspace-xxxxxxxxxxxxxx[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-fullscreen-multiline-early-input.meta.json b/src/main/runtime/__fixtures__/codex-fullscreen-multiline-early-input.meta.json new file mode 100644 index 00000000000..102913da3ed --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-fullscreen-multiline-early-input.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-06T05:13:09.417Z", + "platform": "darwin", + "command": ["codex", "--no-daemon", "-c", "check_for_update_on_startup=false"], + "cols": 120, + "rows": 40, + "note": "Codex CLI 0.160.1; bracketed multiline input followed by Up on first composer frame", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-fullscreen-multiline-early-input.txt b/src/main/runtime/__fixtures__/codex-fullscreen-multiline-early-input.txt new file mode 100644 index 00000000000..fd9f8cf6186 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-fullscreen-multiline-early-input.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1003h[?1006h[?25l[?2026h[?25l>_OpenAI Codex (v0.160.1)loadingAh,theterminal.Aclassicmeetingspot.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25l›firstline second · line[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca/workspaces/orca/workspace-xxxxxxxxxxxxxxxxxxxxxxxxx permissions: YOLO modeAh,theterminal.Aclassicmeetingspot.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;XXXXXXXXXXXXXXXXXXXXXXXX[?2026h[?25lGPT-6.1-Soldefault·..........................................................·...................................[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;X XXXXXXXXXXXXXXXXXXXXXXXX[?2026h[?25lhigh · ~/o.................................................... · ................................ ·tabtoqueuemessage[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;X XXXXXXXXXXXXXXXXXXXXXXXX[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;X XXXXXXXXXXXXXXXXXXXXXXXX[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;X XXXXXXXXXXXXXXXXXXXXXXXX[?2026h[?25h[?2026l]0;X XXXXXXXXXXXXXXXXXXXXXXXX]0;X XXXXXXXXXXXXXXXXXXXXXXXX]0;X XXXXXXXXXXXXXXXXXXXXXXXX]0;X XXXXXXXXXXXXXXXXXXXXXXXX]0;X XXXXXXXXXXXXXXXXXXXXXXXX]0;XXXXXXXXXXXXXXXXXXXXXXXX[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/codex-fullscreen-startup.meta.json b/src/main/runtime/__fixtures__/codex-fullscreen-startup.meta.json new file mode 100644 index 00000000000..68b12386e4b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-fullscreen-startup.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-06T04:12:15.320Z", + "platform": "darwin", + "command": ["codex", "--no-daemon"], + "cols": 120, + "rows": 40, + "note": "Codex CLI 0.160.1; startup without input", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-fullscreen-startup.txt b/src/main/runtime/__fixtures__/codex-fullscreen-startup.txt new file mode 100644 index 00000000000..3e6937a452b --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-fullscreen-startup.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1003h[?1006h[?25l[?2026h[?25l>_OpenAI Codex (v0.160.1)loadingComeonin.There’sroomforanidea.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca/workspaces/orca/workspace-xxxxxxxxxxxxxxxxxxxxxxxxx permissions: YOLO modeComeonin.There’sroomforanidea.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;workspace-xxxxxxxxxxxxxx[?2026h[?25lGPT-6.1-Soldefault·~/orca/workspaces/orca/workspace-xxxxxxxxxxxxxxxxxxxxxxxxx·smarter-codex-issue-paste-detectio…[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ workspace-xxxxxxxxxxxxxx[?2026h[?25lhigh · ~/orca/workspaces/orca/smarte-codex-issue-paste-dtecion · smarte-codex-issue-paste-dtecion ·[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l]0;⠦ workspace-xxxxxxxxxxxxxx]0;⠧ workspace-xxxxxxxxxxxxxx]0;⠇ workspace-xxxxxxxxxxxxxx]0;⠏ workspace-xxxxxxxxxxxxxx]0;workspace-xxxxxxxxxxxxxx[?2026h[?25h[?2026l[?2026h[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/codex-startup-paste-transcripts.test.ts b/src/main/runtime/codex-startup-paste-transcripts.test.ts new file mode 100644 index 00000000000..da5f65aac48 --- /dev/null +++ b/src/main/runtime/codex-startup-paste-transcripts.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { readRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' + +describe('Codex launch draft readiness from captured PTY output', () => { + it.each([ + 'codex-fullscreen-startup', + 'codex-fullscreen-early-input', + 'codex-fullscreen-multiline-early-input', + 'codex-fullscreen-custom-footer' + ])('%s: waits through the provisional composer and resolves on the live footer', async (name) => { + const data = readRuntimeFixture(name) + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + let sawProvisionalComposer = false + let sawReady = false + let offset = 0 + for await (const frame of replayTranscript(data, 120, 40)) { + const result = scanner.observe(data.slice(offset, offset + 64)) + offset += 64 + const hasFooter = frame.screenLines.some((line) => line.includes('GPT-6.1-Sol')) + const hasComposer = frame.screenLines.some((line) => line.trimStart().startsWith('›')) + if (hasComposer && !hasFooter) { + sawProvisionalComposer = true + expect(result.ready).toBe(false) + } + if (result.ready) { + expect(hasFooter).toBe(true) + sawReady = true + } + } + expect(sawProvisionalComposer).toBe(true) + expect(sawReady).toBe(true) + }) + + it.each(['codex-0-158-0-trustprompt', 'codex-0158-update-available-dialog'])( + '%s: its selection glyph never opens the fullscreen paste gate', + (name) => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + const data = readRuntimeFixture(name) + for (let offset = 0; offset < data.length; offset += 17) { + expect(scanner.observe(data.slice(offset, offset + 17)).ready).toBe(false) + } + } + ) + + it.each([1, 7, 64, 1024, Infinity])('handles PTY chunks of %s characters', (size) => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + const data = readRuntimeFixture('codex-fullscreen-early-input') + let ready = false + for (let offset = 0; offset < data.length; offset += size) { + ready ||= scanner.observe(data.slice(offset, offset + size)).ready + } + expect(ready).toBe(true) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts index 7e86bcad3eb..6a19338c294 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-watch-reconcile.test.ts @@ -365,7 +365,7 @@ describe('processFileExplorerFsPayload update reconciliation', () => { expect(refreshDir).toHaveBeenCalledTimes(2) }) - it('purges distinct cached directory renames with one bounded cache scan', () => { + it('purges distinct cached directory renames with bounded batch cache scans', () => { const root = '/repo' const worktreeId = 'watch-reconcile-perf' const entries: Record = { [root]: cacheWithChildren([]) } @@ -431,9 +431,8 @@ describe('processFileExplorerFsPayload update reconciliation', () => { } expect(setDirCache).toHaveBeenCalledOnce() - // One scan, in purgeDirCacheSubtrees. The casing-fallback index stays unbuilt because every - // lookup here hits `dirPath in cache` directly. - expect(keyVisits).toBe(entryCount) + // One index scan for linked-directory detection, then one purge scan for the entire batch. + expect(keyVisits).toBe(entryCount * 2) expect(expandedPathReads).toBe(expandedPaths.length) expect(remainingExpanded).toEqual(new Set()) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts index 4d4ac3d1d4d..8f8e2eea119 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-ssh-startup-draft-delivery.test.ts @@ -228,9 +228,11 @@ describe('connectPanePty', () => { ) }) - it('waits past 8s for a cold Codex composer and preserves input ordering', async () => { + it.each([10_000, 20_000])('keeps Codex draft delivery bounded after %d ms', async (waitMs) => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') + const { beginAgentStartupDeliveryAttempt: claimStartupDelivery } = + await import('@/lib/agent-startup-delayed-delivery') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport('pty-codex') @@ -259,7 +261,8 @@ describe('connectPanePty', () => { }) vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') - connectPanePty(pane as never, manager as never, deps as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the connection fields exercised here without a real terminal or DOM. + const binding = connectPanePty(pane as never, manager as never, deps as never) await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) await flushAsyncTicks() expect(capturedDataCallback.current).not.toBeNull() @@ -276,11 +279,25 @@ describe('connectPanePty', () => { } ).mock.calls[0]?.[0]('USER_DRAFT') ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() - await vi.advanceTimersByTimeAsync(10_000) + await vi.advanceTimersByTimeAsync(waitMs) expect(transport.sendInputAccepted).not.toHaveBeenCalled() capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') await flushAsyncTicks() + if (waitMs === 20_000) { + expect(transport.sendInputAccepted).not.toHaveBeenCalled() + expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual(['\x1b[I', 'USER_DRAFT']) + binding.dispose() + expect( + claimStartupDelivery({ + worktreeId: 'wt-1', + tabId: 'tab-1', + launchToken: 'launch-token-1' + }) + ).toBe(false) + return + } + expect(transport.sendInputAccepted).toHaveBeenCalledWith( '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~', 'launch' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index 8015e49dd11..843350a6ec6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -9,6 +9,7 @@ import { resolveDraftPasteReadyTimeoutMs } from '../../../../../shared/draft-pas import { createDraftPasteReadyScanner } from '../../../../../shared/draft-paste-ready-scanner' import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' import { writeTerminalPastePtyInput } from '../terminal-pty-paste-writer' +import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' import { STARTUP_DRAFT_PASTE_QUIET_MS } from './pty-connect-limits' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' @@ -174,6 +175,19 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { } startupDraftHardTimer = setTimeout(() => { startupDraftHardTimer = null + if (session.startupDraftAgent === 'codex') { + startupDraftPasteSettled = true + // A timed-out launch stays consumed across pane disposal and remount. + session.startupDraftPasteAttempted = true + session.cleanupStartupDraftPasteTimers() + createPasteReadinessTimeoutNotice({ + worktreeId: session.deps.worktreeId, + tabId: session.deps.tabId, + agent: 'codex', + submitted: false + }).onTimeout() + return + } void deliverStartupDraftIfAgentOwnsPty() }, resolveDraftPasteReadyTimeoutMs(session.startupDraftAgent)) } diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index ca5f3930417..2427c7a1e3b 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -432,18 +432,18 @@ describe('pasteDraftWhenAgentReady', () => { it('best-effort pastes when the ready escape was missed but the agent process is running', async () => { testState.inspectRuntimeTerminalProcess.mockResolvedValue({ - foregroundProcess: 'codex', + foregroundProcess: 'gemini', hasChildProcesses: false }) const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, - agent: 'codex' + agent: 'gemini' }) await flushMicrotasks() - await vi.advanceTimersByTimeAsync(20000) + await vi.advanceTimersByTimeAsync(8000) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( @@ -539,7 +539,31 @@ describe('pasteDraftWhenAgentReady', () => { ) }) - it('honors the fallback inspection deadline for pty-bound draft paste', async () => { + it.each(['tab', 'pty'] as const)( + 'never pastes late into a Codex process without composer readiness (%s)', + async (target) => { + const onTimeout = vi.fn() + testState.inspectRuntimeTerminalProcess.mockResolvedValue({ foregroundProcess: 'codex' }) + const args = { + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'codex' as const, + timeoutMs: 1, + onTimeout + } + const promise = + target === 'pty' + ? pasteDraftToAgentPtyWhenReady({ ...args, ptyId: 'pty-1' }) + : pasteDraftWhenAgentReady(args) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(20_000) + await expect(promise).resolves.toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + } + ) + + it('ends the Codex readiness wait without a fallback inspection', async () => { const onTimeout = vi.fn() testState.inspectRuntimeTerminalProcess.mockReturnValue(new Promise(() => {})) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index f0fc547e358..042fe0a6956 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -73,8 +73,7 @@ export function getSettingsForAgentTabRuntimeOwner( * 1. `\x1b[?2004h` (DECSET 2004 — bracketed-paste-enable) on the PTY * output. This is the protocol-level "I accept bracketed paste" * handshake. - * 2. Either ≥`BRACKETED_PASTE_QUIET_MS` of silence after the last byte of - * the post-handshake render burst, or Codex's composer prompt glyph. + * 2. The agent's composer-ready signal, or its configured quiet window. */ export async function pasteDraftWhenAgentReady(args: { tabId: string @@ -121,9 +120,11 @@ export async function pasteDraftWhenAgentReady(args: { // this sidecar subscription attaches. If process/title inspection says the // launched agent owns the PTY, fall back to a best-effort paste instead of // silently dropping generated prompts. - const fallbackReady = agentConfig - ? await waitForAgentReady(tabId, agentConfig.expectedProcess, { timeoutMs: 1000 }) - : { ready: false } + // A running Codex can still own a startup dialog or discard provisional input. + const fallbackReady = + agentConfig && agent !== 'codex' + ? await waitForAgentReady(tabId, agentConfig.expectedProcess, { timeoutMs: 1000 }) + : { ready: false } if (!fallbackReady.ready) { onTimeout?.() return false @@ -178,9 +179,10 @@ export async function pasteDraftToAgentPtyWhenReady(args: { const budget = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings) if (!ready) { - const fallbackReady = agentConfig - ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) - : false + const fallbackReady = + agentConfig && agent !== 'codex' + ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) + : false if (!fallbackReady) { onTimeout?.() return false diff --git a/src/renderer/src/lib/startup-draft-input-kind.test.ts b/src/renderer/src/lib/startup-draft-input-kind.test.ts index 34c96e3073f..a69ea68a103 100644 --- a/src/renderer/src/lib/startup-draft-input-kind.test.ts +++ b/src/renderer/src/lib/startup-draft-input-kind.test.ts @@ -3,11 +3,18 @@ // startup paste, or the draft helper that waits for the agent. Both must write it as launch input. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalInputKind } from '../../../shared/terminal-input-kind' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { bindSettlePaneSerializer } from '@/components/terminal-pane/pty-connection/pane-serializer-settle' import type { ConnectPanePtySession } from '@/components/terminal-pane/pty-connection/connect-pane-pty-session' import { STARTUP_DRAFT_PASTE_QUIET_MS } from '@/components/terminal-pane/pty-connection/pty-connect-limits' import { pasteDraftToAgentPtyWhenReady, submitPromptToAgentPty } from './agent-paste-draft' +const { onTimeout } = vi.hoisted(() => ({ onTimeout: vi.fn() })) +vi.mock('./launch-agent-paste-timeout-notice', () => ({ + createPasteReadinessTimeoutNotice: () => ({ onTimeout }) +})) + vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ settings: {}, terminalLayoutsByTabId: {} }), @@ -38,8 +45,7 @@ function stubPtyApi(): { kinds: TerminalInputKind[] } { return { kinds } } -/** The pane route: the pane's transport pastes the draft once the agent's composer is ready. */ -async function paneRouteKinds(): Promise { +function createPaneDraftSession(agent?: 'codex') { const { kinds } = stubPtyApi() const transport = { getPtyId: () => 'pty-1', @@ -56,12 +62,20 @@ async function paneRouteKinds(): Promise { ownsStartupDraftPaste: true, shouldDeliverStartupViaTerminalPaste: false, startupDraftPrompt: DRAFT, + startupDraftAgent: agent, + startupDraftAgentConfig: agent ? TUI_AGENT_CONFIG[agent] : undefined, recordTerminalInputForHibernation: () => {}, deps: { worktreeId: 'wt-1', paneTransportsRef: { current: new Map([[pane.id, transport]]) } } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session is an any-typed bag; the draft path reads only the fields above. const session = bag as unknown as ConnectPanePtySession bindSettlePaneSerializer(session) + return { session, kinds } +} + +/** The pane route: the pane's transport pastes the draft once the agent's composer is ready. */ +async function paneRouteKinds(): Promise { + const { session, kinds } = createPaneDraftSession() session.observeStartupDraftPasteReadiness('\x1b[?2004h') await vi.advanceTimersByTimeAsync(STARTUP_DRAFT_PASTE_QUIET_MS) await vi.runAllTimersAsync() @@ -85,6 +99,7 @@ async function helperRouteKinds(): Promise { describe('a created worktree’s startup draft', () => { beforeEach(() => { vi.useFakeTimers() + onTimeout.mockClear() }) afterEach(() => { @@ -110,4 +125,17 @@ describe('a created worktree’s startup draft', () => { await expect(submitted).resolves.toBe(true) expect(new Set(kinds)).toEqual(new Set(['driving'])) }) + + it('never delivers a pane-owned Codex draft after readiness times out', async () => { + const { session, kinds } = createPaneDraftSession('codex') + session.observeStartupDraftPasteReadiness('\x1b[?2004hWaiting for Codex...') + await vi.advanceTimersByTimeAsync(resolveDraftPasteReadyTimeoutMs('codex')) + + expect(onTimeout).toHaveBeenCalledOnce() + expect(kinds).toEqual([]) + session.observeStartupDraftPasteReadiness('\x1b[2K› ') + await vi.runAllTimersAsync() + expect(kinds).toEqual([]) + expect(onTimeout).toHaveBeenCalledOnce() + }) }) diff --git a/src/shared/codex-composer-ready-scanner.test.ts b/src/shared/codex-composer-ready-scanner.test.ts new file mode 100644 index 00000000000..0caaa420488 --- /dev/null +++ b/src/shared/codex-composer-ready-scanner.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' + +const MODES = '\x1b[?2004h\x1b[?1049h' +const BEGIN = '\x1b[?2026h\x1b[?25l' +const END = '\x1b[37;3H\x1b[?25h\x1b[?2026l' +// Geometry from the 0.160.1 startup capture, with synthetic labels. +const PROVISIONAL = `${MODES}${BEGIN}\x1b[37;1H›\x1b[40;3H? for shortcuts${END}` +const LIVE = `${BEGIN}\x1b[39;3HModel high · folder${END}` + +describe('Codex fullscreen composer paste gate', () => { + it('waits for the live footer, then accepts cursor-only redraws', () => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + expect(scanner.observe(PROVISIONAL).ready).toBe(false) + expect(scanner.observe(LIVE).ready).toBe(true) + expect(scanner.observe(`${BEGIN}${END}`).ready).toBe(true) + }) + + it('does not resolve in the middle of a synchronized frame', () => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + scanner.observe(PROVISIONAL) + expect(scanner.observe(`${BEGIN}\x1b[39;3HModel · folder`).ready).toBe(false) + expect(scanner.observe(END).ready).toBe(true) + }) + + it.each([']', 'P', 'X', '^', '_'])('ignores a footer inside ESC %s control strings', (kind) => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + scanner.observe(PROVISIONAL) + expect(scanner.observe(`\x1b${kind}0;\x1b[39;3HModel · folder\x1b\\`).ready).toBe(false) + expect(scanner.observe(LIVE).ready).toBe(true) + }) + + it.each([']', 'P', 'X', '^', '_'])('keeps split ESC %s control strings hidden', (kind) => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + scanner.observe(PROVISIONAL) + const payload = `\x1b${kind}${'x'.repeat(1024)}\x1b[39;3HModel · folder\x1b\\` + for (const char of payload) { + expect(scanner.observe(char).ready).toBe(false) + } + expect(scanner.observe(LIVE).ready).toBe(true) + }) + + it.each(['\x1b[?2004l', '\x1b[?1049l'])( + 'withdraws the composer when its terminal mode ends (%s)', + (leave) => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + expect(scanner.observe(PROVISIONAL + LIVE + leave).ready).toBe(false) + expect(scanner.observe('› shell prompt\x1b[?25h').ready).toBe(false) + } + ) + + it('does not mistake the shortcut hint for a footer after multiline early input', () => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + scanner.observe(PROVISIONAL) + expect( + scanner.observe(`${BEGIN}\x1b[38;3Hcontinued note\x1b[38;17H\x1b[?25h\x1b[?2026l`).ready + ).toBe(false) + }) + + it('keeps the reserved footer row when early multiline input contains footer glyphs', () => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + scanner.observe(PROVISIONAL) + expect( + scanner.observe( + `${BEGIN}\x1b[35;1H› first line\x1b[36;3H? second line\x1b[37;3Hthird · line\x1b[35;3H\x1b[?25h\x1b[?2026l` + ).ready + ).toBe(false) + expect(scanner.observe(LIVE).ready).toBe(true) + }) +}) diff --git a/src/shared/codex-composer-ready-scanner.ts b/src/shared/codex-composer-ready-scanner.ts new file mode 100644 index 00000000000..d3adc33294f --- /dev/null +++ b/src/shared/codex-composer-ready-scanner.ts @@ -0,0 +1,96 @@ +import { createTerminalEscapeScanner } from './terminal-escape-scanner' + +/** The fullscreen loading frame has a composer too; only the live chat paints its footer. */ +export function createCodexComposerReadyScanner(): { + observe: (data: string) => { ready: boolean } +} { + let bracketedPaste = false + let altScreen = false + let leftAltScreen = false + let synchronized = false + let sawSynchronizedFrame = false + let promptSeen = false + let promptRow: number | null = null + let footerRow: number | null = null + let hintRow: number | null = null + let row: number | null = null + let cursorShown = false + const paintedRows = new Set() + + const resetLayout = (): void => { + promptSeen = false + promptRow = null + footerRow = null + hintRow = null + row = null + cursorShown = false + paintedRows.clear() + } + const scanner = createTerminalEscapeScanner({ + onCsi: (privateMarker, params, final) => { + if (privateMarker === '?' && (final === 'h' || final === 'l')) { + const set = final === 'h' + for (const mode of params.split(';')) { + if (mode === '2004') { + bracketedPaste = set + if (!set) { + resetLayout() + } + } else if (mode === '1049') { + altScreen = set + leftAltScreen = !set + resetLayout() + } else if (mode === '2026') { + synchronized = set + sawSynchronizedFrame = true + } else if (mode === '25') { + cursorShown = set + } + } + } else if (final === 'H' || final === 'f') { + row = Math.max(1, Number(params.split(';')[0] || '1')) + } else if (final === 'J' && params === '2') { + resetLayout() + } + }, + onText: (text) => { + for (const char of text) { + if (row !== null && !/\s/.test(char) && paintedRows.size < 128) { + paintedRows.add(row) + } + if (char === '\n' && row !== null) { + row += 1 + } else if (char === '›' && (bracketedPaste || altScreen)) { + promptSeen = true + promptRow = row + } else if (char === '·' && promptRow !== null && row !== null && row > promptRow) { + footerRow = row + } else if (char === '?' && promptRow !== null && row !== null && row > promptRow) { + hintRow = Math.max(hintRow ?? row, row) + } + } + } + }) + + return { + observe: (data) => { + scanner.observe(data) + const liveFullscreenComposer = + altScreen && + !synchronized && + cursorShown && + promptRow !== null && + row !== null && + row >= promptRow && + hintRow !== null && + // Single-item status lines have no separator; their reserved row is below the input padding. + ((footerRow !== null && footerRow === hintRow - 1 && row < footerRow) || + (row + 2 < hintRow && paintedRows.has(hintRow - 1))) + // Older inline builds do not paint synchronized fullscreen frames. + const legacyComposer = promptSeen && !leftAltScreen && (!altScreen || !sawSynchronizedFrame) + return { + ready: bracketedPaste && (liveFullscreenComposer || legacyComposer) + } + } + } +} diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index bcda84ef185..532de328b70 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -1,12 +1,12 @@ import type { DraftPasteReadySignal, TuiAgentConfig } from './tui-agent-config' import { createOpenCodeAgentRowScanner } from './opencode-agent-row-scanner' +import { createCodexComposerReadyScanner } from './codex-composer-ready-scanner' // Why: agents enable bracketed paste (DECSET 2004) before their composer is // actually mounted/focused. These markers let the scanner detect the real // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const DECRST_BRACKETED_PASTE = '\x1b[?2004l' -const CODEX_COMPOSER_PROMPT = '›' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, // analogous to Codex's prompt glyph. It fires ~2s after bracketed paste is @@ -42,23 +42,18 @@ type DraftPasteReadySignalSpec = { } // Signals that read the screen's structure, not one marker; a Record so none lacks its scanner. -type StructuralSignal = 'opencode-agent-row' +type StructuralSignal = 'opencode-agent-row' | 'codex-composer-prompt' const STRUCTURAL_SCANNERS: Record< StructuralSignal, - () => { observe: (data: string) => { ready: boolean; readyAfterMs: number | null } } + () => { observe: (data: string) => { ready: boolean; readyAfterMs?: number | null } } > = { - 'opencode-agent-row': createOpenCodeAgentRowScanner + 'opencode-agent-row': createOpenCodeAgentRowScanner, + 'codex-composer-prompt': createCodexComposerReadyScanner } type SingleSignal = Exclude const DRAFT_PASTE_READY_SIGNALS: Record = { - 'codex-composer-prompt': { - markerAnchor: DECSET_BRACKETED_PASTE, - markerAnchorEnd: null, - marker: CODEX_COMPOSER_PROMPT, - quietAnchor: null - }, 'render-cursor-after-bracketed-paste': { markerAnchor: DECSET_BRACKETED_PASTE, // Why: the launching shell's prompt turns bracketed paste on and back off before exec @@ -115,7 +110,7 @@ const ANCHOR_CARRY_CHARS = 7 /** Whether the signal has a composer marker, rather than only a quiet window after its anchor. */ export function draftPasteReadySignalHasMarker(readySignal: DraftPasteReadySignal): boolean { - return DRAFT_PASTE_READY_SIGNALS[readySignal].marker !== null + return !isSingleSignal(readySignal) || DRAFT_PASTE_READY_SIGNALS[readySignal].marker !== null } export type DraftPasteReadyScanResult = { @@ -149,9 +144,8 @@ export function resolvePasteReadySignal( * and return types differ. * * Per agent signal: - * - `codex-composer-prompt`: ready when the `›` glyph renders after DECSET - * 2004, or when DECSET follows a glyph rendered while Codex owns the - * alternate screen; never arms the quiet window. + * - `codex-composer-prompt`: fullscreen waits for the live footer and composer + * cursor at a completed frame; older inline builds keep their glyph signal. * - `render-cursor-after-bracketed-paste`: ready when DECTCEM show-cursor * (`\x1b[?25h`) renders while DECSET 2004 is held; `\x1b[?2004l` revokes it. * Like Codex it does NOT arm the quiet window: opencode stays silent for up @@ -217,11 +211,8 @@ function createSingleSignalScanner(readySignal: SingleSignal): { let recent = '' let postAnchorRecent = '' let anchorCarry = '' - let codexCarry = '' let sawMarkerAnchor = false let sawQuietAnchor = false - let codexAltScreen = false - let sawCodexPromptInAltScreen = false const { markerAnchor, @@ -274,37 +265,6 @@ function createSingleSignalScanner(readySignal: SingleSignal): { return false } - const scanCodexPreAnchorPrompt = (data: string): void => { - const window = codexCarry + data - codexCarry = window.slice(-ANCHOR_CARRY_CHARS) - let cursor = 0 - while (cursor < window.length) { - const enterIndex = window.indexOf(DECSET_ALT_SCREEN, cursor) - const leaveIndex = window.indexOf(DECRST_ALT_SCREEN, cursor) - const promptIndex = window.indexOf(CODEX_COMPOSER_PROMPT, cursor) - const nextIndex = Math.min( - ...[enterIndex, leaveIndex, promptIndex].filter((index) => index !== -1) - ) - if (!Number.isFinite(nextIndex)) { - return - } - if (nextIndex === enterIndex) { - codexAltScreen = true - sawCodexPromptInAltScreen = false - cursor = nextIndex + DECSET_ALT_SCREEN.length - } else if (nextIndex === leaveIndex) { - codexAltScreen = false - sawCodexPromptInAltScreen = false - cursor = nextIndex + DECRST_ALT_SCREEN.length - } else { - if (codexAltScreen) { - sawCodexPromptInAltScreen = true - } - cursor = nextIndex + CODEX_COMPOSER_PROMPT.length - } - } - } - return { observe(data: string): DraftPasteReadyScanResult { const combined = recent + data @@ -312,9 +272,6 @@ function createSingleSignalScanner(readySignal: SingleSignal): { if (!sawQuietAnchor && quietAnchor !== null && combined.includes(quietAnchor)) { sawQuietAnchor = true } - if (readySignal === 'codex-composer-prompt' && !sawMarkerAnchor) { - scanCodexPreAnchorPrompt(data) - } if (signalMarker !== null && markerAnchor !== null) { if (markerAnchorEnd !== null) { // Why: carry only the bytes an anchor could straddle, so already-scanned @@ -329,9 +286,6 @@ function createSingleSignalScanner(readySignal: SingleSignal): { const anchorIndex = combined.indexOf(markerAnchor) if (anchorIndex !== -1) { sawMarkerAnchor = true - if (readySignal === 'codex-composer-prompt' && sawCodexPromptInAltScreen) { - return { ready: true, armQuietTimer: false } - } const postAnchorChunk = combined.slice(anchorIndex + markerAnchor.length) if (postAnchorChunk.includes(signalMarker)) { return { ready: true, armQuietTimer: false } diff --git a/src/shared/opencode-agent-row-scanner.ts b/src/shared/opencode-agent-row-scanner.ts index ad5050031a5..50a5c1e14ec 100644 --- a/src/shared/opencode-agent-row-scanner.ts +++ b/src/shared/opencode-agent-row-scanner.ts @@ -1,3 +1,5 @@ +import { createTerminalEscapeScanner } from './terminal-escape-scanner' + /** * OpenCode's submit readiness: the ` · ` row along the bottom of its input box is * painted only once its agent list has loaded from its server, and until then it drops an Enter. @@ -14,25 +16,10 @@ * screen or turning bracketed paste off withdraws what it established. */ -const ESC = '\x1b' const AGENT_ROW_SEPARATOR = '\u00b7' const BOX_BOTTOM_LEFT = '\u2579' -// Why: a narrow pane never paints the row (OpenCode 2 drops the agent below 44 columns), and nor -// does a terminal that does not forward the alternate screen; a theme whose raised background is -// transparent paints no `╹`, so its starts always wait out the grace. Once the box's cursor has shown, the -// earlier box rule takes over after this grace, so those starts still get their task, as before. -// 5 s because the row trailed the box by at most 1.8 s in every recorded start, loaded or not. +// Narrow panes and transparent themes omit the row; retain the earlier box signal after a grace. export const OPENCODE_AGENT_ROW_GRACE_MS = 5_000 -// Longest unfinished escape carried into the next read; anything longer is noise, not a split. -const MAX_CARRIED_ESCAPE_CHARS = 512 - -/* oxlint-disable no-control-regex -- these match terminal escape sequences, which start with ESC */ -const CSI_RE = /^\x1b\[([?>=<]?)([0-9;]*)([ -/]*[@-~])/ -const UNFINISHED_CSI_RE = /^\x1b\[[?>=<]?[0-9;]*[ -/]*$/ -// OSC and DCS strings (titles, queries) end at BEL or ST; their text is never painted. -const STRING_RE = /^\x1b[\]P][^\x07\x1b]*(?:\x07|\x1b\\)/ -const STRING_START_RE = /^\x1b[\]P]/ -const STRING_END_RE = /\x07|\x1b\\/ export type OpenCodeAgentRowScan = { /** The row exists and the box's cursor is shown: Enter will be taken. */ @@ -44,7 +31,6 @@ export type OpenCodeAgentRowScan = { export function createOpenCodeAgentRowScanner(): { observe: (data: string) => OpenCodeAgentRowScan } { - let carry = '' let altScreen = false // Once a full-screen app has left, a box cursor outside the alternate screen is the shell's. let leftAltScreen = false @@ -118,33 +104,10 @@ export function createOpenCodeAgentRowScanner(): { } } + const scanner = createTerminalEscapeScanner({ onCsi, onText }) return { observe(data: string): OpenCodeAgentRowScan { - const input = carry + data - carry = '' - let index = 0 - while (index < input.length) { - const escapeAt = input.indexOf(ESC, index) - if (escapeAt === -1) { - onText(input.slice(index)) - break - } - onText(input.slice(index, escapeAt)) - const rest = input.slice(escapeAt) - const sequence = CSI_RE.exec(rest) ?? STRING_RE.exec(rest) - if (sequence) { - if (sequence[0][1] === '[') { - onCsi(sequence[1], sequence[2], sequence[3]) - } - index = escapeAt + sequence[0].length - continue - } - if (isUnfinishedEscape(rest)) { - carry = rest - break - } - index = escapeAt + 2 - } + scanner.observe(data) // Leaving the alternate screen clears rowPainted, so it only holds while OpenCode is drawn. const graceApplies = boxCursorShown && !rowPainted && (altScreen || !leftAltScreen) return { @@ -154,14 +117,3 @@ export function createOpenCodeAgentRowScanner(): { } } } - -function isUnfinishedEscape(rest: string): boolean { - if (rest.length > MAX_CARRIED_ESCAPE_CHARS) { - return false - } - return ( - rest === ESC || - UNFINISHED_CSI_RE.test(rest) || - (STRING_START_RE.test(rest) && !STRING_END_RE.test(rest)) - ) -} diff --git a/src/shared/terminal-escape-scanner.ts b/src/shared/terminal-escape-scanner.ts new file mode 100644 index 00000000000..323eac90684 --- /dev/null +++ b/src/shared/terminal-escape-scanner.ts @@ -0,0 +1,78 @@ +const ESC = '\x1b' +// Longest unfinished escape carried into the next read; anything longer is noise, not a split. +const MAX_CARRIED_ESCAPE_CHARS = 512 + +/* oxlint-disable no-control-regex -- these match terminal escape sequences, which start with ESC */ +const CSI_RE = /^\x1b\[([?>=<]?)([0-9;]*)([ -/]*[@-~])/ +const UNFINISHED_CSI_RE = /^\x1b\[[?>=<]?[0-9;]*[ -/]*$/ +// Terminal control strings (titles, graphics, queries) never paint their payload text. +const STRING_RE = /^\x1b[\]PX^_][\s\S]*?(?:\x07|\x1b\\)/ +const STRING_START_RE = /^\x1b[\]PX^_]/ +const STRING_END_RE = /\x07|\x1b\\/ + +type TerminalEscapeObserver = { + onCsi: (privateMarker: string, params: string, final: string) => void + onText: (text: string) => void +} + +/** Incremental painted-text/CSI reader; terminal string payloads are never painted text. */ +export function createTerminalEscapeScanner(observer: TerminalEscapeObserver): { + observe: (data: string) => void +} { + let carry = '' + let stringPending = false + return { + observe(data: string): void { + const input = carry + data + carry = '' + let index = 0 + if (stringPending) { + const end = STRING_END_RE.exec(input) + if (!end) { + carry = input.endsWith(ESC) ? ESC : '' + return + } + stringPending = false + index = end.index + end[0].length + } + while (index < input.length) { + const escapeAt = input.indexOf(ESC, index) + if (escapeAt === -1) { + observer.onText(input.slice(index)) + break + } + observer.onText(input.slice(index, escapeAt)) + const rest = input.slice(escapeAt) + const sequence = CSI_RE.exec(rest) ?? STRING_RE.exec(rest) + if (sequence) { + if (sequence[0][1] === '[') { + observer.onCsi(sequence[1], sequence[2], sequence[3]) + } + index = escapeAt + sequence[0].length + continue + } + if (STRING_START_RE.test(rest)) { + stringPending = true + carry = rest.endsWith(ESC) ? ESC : '' + break + } + if (isUnfinishedEscape(rest)) { + carry = rest + break + } + index = escapeAt + 2 + } + } + } +} + +function isUnfinishedEscape(rest: string): boolean { + if (rest.length > MAX_CARRIED_ESCAPE_CHARS) { + return false + } + return ( + rest === ESC || + UNFINISHED_CSI_RE.test(rest) || + (STRING_START_RE.test(rest) && !STRING_END_RE.test(rest)) + ) +}