diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 9a28f0a243a..c7a87341c10 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -40,6 +40,15 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { 'opencode-1-18-32-timed-boot-slow': 16, 'opencode-1-18-32-timed-first-launch': 16, 'opencode-2-0-18-timed-boot-hidden-pane': 14, + // STA-9359 OpenCode 2 captures, serializer untouched: every background diff is the live pen's + // panel colour on restored cells, plus the rows kept below the source after a shrink. + 'opencode-2-0-14-timed-cold-standalone': 4, + 'opencode-2-0-21-timed-cold-standalone': 36, + 'opencode-2-0-21-timed-cold-standalone-hidden-pane': 6, + 'opencode-2-0-21-timed-natural-load-enter-dropped': 12, + 'opencode-cmd-2-0-21-timed-warm-server': 21, + // The same, plus the cursor one column short after a shrink and the pen's bold bit on a glyph. + 'opencode-2-0-21-timed-narrow-pane': 8, // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. 'qoder-no-account': 2, 'qoder-ready': 2, diff --git a/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.meta.json new file mode 100644 index 00000000000..57e46b8f667 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:18:50.064162Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.14 (Homebrew opencode-v2) on a macOS laptop, 120x40, alternate screen; a cross-version check for the agent-row signal. Recorded on the laptop with a Python PTY recorder that stamps each read with its ms since spawn; --standalone private server (cold), model opencode/muse-spark-1.3-contributor-free via OPENCODE_CONFIG_CONTENT, every ORCA_* variable stripped, no input. Box cursor at 1366 ms, agent/model row at 1408 ms. The footer path was replaced on the laptop with the same-length ~/projects/demo-01/... . .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs (the last read; the recorder stopped there).", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.timing.json new file mode 100644 index 00000000000..8629dd5ed05 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":1561,"chunks":[[307,280],[307,37],[307,8],[609,1024],[609,1024],[609,1024],[609,1024],[609,1024],[609,577],[1319,1024],[1319,1024],[1319,1024],[1319,1024],[1319,1024],[1320,817],[1325,13],[1366,900],[1366,900],[1366,358],[1376,100],[1408,388],[1425,527],[1442,663],[1459,666],[1476,696],[1493,661],[1510,627],[1527,486],[1544,412],[1561,378]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.txt b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.txt new file mode 100644 index 00000000000..6c038050dd2 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀/┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀~/projects/demo-01/opencode-scratchshift+tab agentsctrl+p commands2.0.14]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l/update to install v2.0.22[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l/upd┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l/update t┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25lpdate to in┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25le to install┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25linstall v2.┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25lall v2.0.22┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l2.0.22┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l22┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.meta.json new file mode 100644 index 00000000000..be46f1aaaf4 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:13:03.308914Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: the same cold --standalone start answered like a hidden Orca pane (responder 'headless': silent on OSC colour queries). No input typed.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.timing.json new file mode 100644 index 00000000000..055a5e4e4d1 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":11423.8,"chunks":[[241.4,312],[242.6,37],[242.8,1024],[243.1,1024],[243.3,1024],[243.6,1024],[243.9,1024],[244.1,1024],[244.4,141],[261.2,1024],[261.6,1024],[261.9,1024],[262.2,1024],[262.6,1024],[263.0,937],[1267.3,1024],[1268.0,1024],[1268.6,1024],[1269.2,1024],[1269.7,1024],[1270.1,1024],[1270.6,353],[1275.2,13],[1313.7,934],[1314.2,938],[1314.6,835],[1331.9,173],[1347.7,476],[1364.9,476],[1382.0,481],[1399.7,490],[1418.0,491],[1435.8,497],[1453.5,497],[1470.7,497],[1489.3,497]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.txt new file mode 100644 index 00000000000..7b064f2c5da --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.meta.json new file mode 100644 index 00000000000..08da9b6117a --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:12:27.181737Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: a cold start of a fresh private server (--standalone, as the opencode2 row launches); the agent/model row under the input box (' \u00b7 ') is painted 47 ms after the box. Responder 'xterm' (Orca's visible pane). No input typed.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.timing.json new file mode 100644 index 00000000000..05da882f867 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":15442.9,"chunks":[[294.0,280],[295.2,37],[296.0,16],[296.1,16],[296.2,16],[296.2,32],[296.2,24],[296.2,24],[296.3,24],[296.3,36],[296.4,72],[304.5,1024],[304.8,1024],[305.1,1024],[305.4,1024],[305.7,1024],[306.3,1024],[306.5,353],[308.7,13],[341.4,934],[341.8,938],[342.1,835],[356.6,173],[389.4,218],[407.9,314],[429.0,314],[447.9,319],[466.4,325],[484.7,326],[502.8,326],[520.8,326],[538.9,326],[746.5,265]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.txt new file mode 100644 index 00000000000..f89b181f7f1 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l·LongCat 2.5 Preview FreeOpenCode Zen[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25lFledge Alpha Free OpenCode Zen [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.meta.json new file mode 100644 index 00000000000..7c5d913c79f --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:49:55.163902Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane). One natural cold start, no other capture running; load average at start 2.76 / 4.63 / 7.28. --standalone with the user's own login and plugins read in place and the model opencode-go/deepseek-v4.1-flash set through OPENCODE_CONFIG_CONTENT (nothing on disk written by the recorder); OPENCODE_DISABLE_AUTOUPDATE=1, no ORCA_* vars; version and binary sha256 unchanged before and after. An 82-line, 6.6 KB brief was bracketed-pasted the moment the agent/model row painted (393 ms; box cursor at 273 ms) and Enter was sent 537 ms later (promptSentAtMs), Orca's submit delay of 500 ms plus paste time. OpenCode submitted it: the composer cleared and the brief and the reply were painted. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.timing.json new file mode 100644 index 00000000000..63d04fa32aa --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":15995.5,"chunks":[[211.0,280],[212.6,69],[221.4,1024],[221.7,1024],[222.0,1024],[222.3,1024],[222.5,1024],[222.8,1024],[223.0,581],[226.5,13],[239.8,1024],[240.4,1024],[240.7,1024],[241.0,1024],[241.2,1024],[241.5,1024],[242.2,353],[271.1,1024],[271.6,1024],[271.8,952],[272.1,928],[272.3,888],[272.5,412],[328.5,338],[358.1,1016],[358.6,1018],[359.0,935],[374.5,1016],[375.1,1018],[375.4,979],[392.9,1016],[393.2,1018],[393.4,1017],[393.8,236],[409.7,1016],[410.0,1018],[410.2,1016],[410.4,413],[426.4,1018],[426.7,1018],[426.9,1018],[427.1,672],[443.5,1018],[443.7,1018],[444.0,1018],[444.2,677],[460.1,1018],[460.4,1018],[460.6,1018],[460.8,683],[477.6,1016],[477.9,1018],[478.2,1016],[478.4,249],[495.5,1014],[495.8,1014],[496.1,363],[514.6,326],[532.2,326],[549.4,326],[583.7,369],[601.1,1014],[601.4,1014],[601.7,77],[623.0,1016],[623.4,1016],[623.7,542],[640.8,1016],[641.1,1016],[641.3,542],[657.5,1014],[657.8,1014],[658.1,77],[675.1,1016],[675.6,1018],[675.9,979],[693.8,1016],[694.1,1018],[694.3,979],[712.9,1016],[713.2,1016],[713.8,542],[729.6,1014],[729.9,1014],[730.2,77],[749.7,1016],[750.0,1018],[750.2,979],[768.1,1016],[768.4,1016],[768.6,512],[785.8,1016],[786.0,1016],[786.3,542],[804.2,1016],[804.5,1016],[804.8,542],[822.8,1012],[823.1,624],[840.6,1016],[840.9,1016],[841.2,542],[859.1,1014],[859.5,1014],[859.8,47],[877.9,1012],[878.4,624],[955.0,119],[966.9,13],[993.0,1016],[993.3,1018],[993.6,1016],[993.9,1018],[994.1,1018],[994.4,1016],[994.6,1018],[994.9,1016],[995.1,1018],[995.4,1015],[995.6,791],[995.8,87],[998.8,285],[1018.6,254],[1035.4,297],[1039.3,13],[1054.4,992],[1054.7,976],[1055.0,106],[1072.4,259],[1089.7,86],[1107.9,180],[1141.6,225],[1158.9,86],[1177.2,269],[1219.6,346],[1238.2,259],[1255.3,119],[1282.4,357],[1291.1,226],[1309.7,482],[1326.7,226],[1344.1,438],[1362.7,259],[1381.6,152],[1399.0,193],[1416.9,226],[1435.3,426],[1453.7,192],[1472.2,432],[1490.1,309],[1507.5,412],[1525.9,221],[1542.3,195],[1560.8,445],[1579.8,241],[1596.9,287],[1614.8,357],[1633.1,276],[1650.5,239],[1667.7,290],[1684.2,409],[1701.8,225],[1719.8,276],[1738.4,240],[1756.5,416],[1774.9,240],[1792.3,495],[1809.0,241],[1826.9,326],[1845.7,558],[1863.8,279],[1881.0,653],[1897.2,275],[1913.9,593],[1930.5,428],[1947.3,275],[1965.8,722],[1983.3,410],[2000.4,630],[2023.4,392],[2037.2,294],[2055.2,341],[2073.1,345],[2091.6,680],[2110.3,392],[2127.2,569],[2146.2,477],[2164.4,609],[2183.0,378],[2200.0,480],[2217.1,446],[2234.2,397],[2251.7,541],[2268.3,277],[2286.2,535],[2304.0,446],[2320.6,399],[2338.8,329],[2357.2,434],[2374.4,432],[2393.1,430],[2412.4,381],[2430.8,434],[2448.8,450],[2465.5,429],[2484.2,365],[2502.4,312],[2521.1,451],[2538.7,398],[2552.9,32],[2555.5,845],[2573.6,315],[2591.3,452],[2608.3,1024],[2609.0,1024],[2609.6,628],[2625.6,380],[2644.2,314],[2660.7,436],[2678.3,379],[2691.5,32],[2695.6,1022],[2695.9,1024],[2696.3,1023],[2696.8,261],[2713.8,773],[2729.8,929],[2747.9,722],[2764.2,806],[2782.8,722],[2801.5,755],[2820.0,634],[2838.8,315],[2857.3,608],[2875.9,429],[2894.5,555],[2913.7,484],[2930.8,513],[2948.7,412],[2967.4,599],[2985.8,433],[3004.5,414],[3028.3,410],[3042.8,414],[3060.6,313],[3079.4,313],[3098.1,263],[3133.6,263],[3170.8,263],[3189.4,263],[3208.3,263],[3225.7,263],[3245.0,263],[3262.9,263],[3282.4,263],[3297.2,263],[3315.3,263],[3334.1,263],[3353.0,263],[3371.1,263],[3387.3,263],[3405.7,263],[3422.4,263],[3439.8,263],[3458.2,263],[3476.7,263],[3495.2,263],[3512.6,263],[3531.0,263],[3548.0,263],[3566.2,263],[3584.6,263],[3602.8,263],[3621.3,263],[3639.8,263],[3658.1,263],[3694.1,263],[3730.1,263],[3747.1,263],[3763.8,263],[3891.9,182]],"promptSentAtMs":938.3} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.txt new file mode 100644 index 00000000000..a16f3a3bd64 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]0;OpenCode[?2026h[?25l                                        [?2026l[?2026h[?25lOrca rOrca rOrca rOrca rOrca rOrca rOrca rORCAPIOrca rORCAPI + New session   ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix broken tests" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀~/xxxxxxx/xxxxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l • ORCA[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORC[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA·DeepSeek V4.1 FlashOpenCode Go·high[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA┃┃[Pasted ~82 lines]  ┃┃Build╹[?25h[?2026l[?2026h[?25l • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • ORCAP • Orca  • ORCAP┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • ORCAP • Orca  • ORCAP┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • ORCAP • Orca  • ORCAP┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l • Or • Or • Or • Or • Or • Or • Or • OR • Or • OR┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l          [?25h[?2026l[?2026h[?25l • Or • Or • Or • Or • Or • Or • Or • OR • Or • OR[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Or • Or • Or • Or • Or • Or • Or • OR • Or • OR[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Or • Or • Or • Or • Or • Or • Or • OR • Or • OR[?25h[?2026l[?2026h[?25l • Orc  • Orc  • Orc  • Orc  • Orc  • Orc  • Orc  • ORCP • Orc  • ORCP[?25h[?2026l[?2026h[?25l • Ora • Ora • Ora • Ora • Ora • Ora • Ora • ORA • Ora • ORA[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • O • O • O • O • O • O • O • O • O • O[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Oa • Oa • Oa • Oa • Oa • Oa • Oa • OA • Oa • OA[?25h[?2026l[?2026h[?25l • O • O • O • O • O • O • O • O • O • O[?25h[?2026l[?2026h[?25lAsk anything… "Fix broken tests"[?25h[?2026l]0;OpenCode[?2026h[?25l┃ 52. filler line standing in for the dispatch preamble so the paste has its size. ┃ 53. filler line standing in for the dispatch preamble so the paste has its size. ┃ 54. filler line standing in for the dispatch preamble so the paste has its size. ┃ 55. filler line standing in for the dispatch preamble so the paste has its size. ┃ 56. filler line standing in for the dispatch preamble so the paste has its size. ┃ 57. filler line standing in for the dispatch preamble so the paste has its size. ┃ 58. filler line standing in for the dispatch preamble so the paste has its size. ┃ 59. filler line standing in for the dispatch preamble so the paste has its size. ┃ 60. filler line standing in for the dispatch preamble so the paste has its size. ┃ 61. filler line standing in for the dispatch preamble so the paste has its size. ┃ 62. filler line standing in for the dispatch preamble so the paste has its size. ┃ 63. filler line standing in for the dispatch preamble so the paste has its size. ┃ 64. filler line standing in for the dispatch preamble so the paste has its size. ┃ 65. filler line standing in for the dispatch preamble so the paste has its size. ┃ 66. filler line standing in for the dispatch preamble so the paste has its size. ┃ 67. filler line standing in for the dispatch preamble so the paste has its size. ┃ 68. filler line standing in for the dispatch preamble so the paste has its size. ┃ 69. filler line standing in for the dispatch preamble so the paste has its size. ┃ 70. filler line standing in for the dispatch preamble so the paste has its size. ┃ 71. filler line standing in for the dispatch preamble so the paste has its size. ┃ 72. filler line standing in for the dispatch preamble so the paste has its size. ┃ 73. filler line standing in for the dispatch preamble so the paste has its size. ┃ 74. filler line standing in for the dispatch preamble so the paste has its size. ┃ 75. filler line standing in for the dispatch preamble so the paste has its size. ┃ 76. filler line standing in for the dispatch preamble so the paste has its size. ┃ 77. filler line standing in for the dispatch preamble so the paste has its size. ┃ 78. filler line standing in for the dispatch preamble so the paste has its size. ┃ 79. filler line standing in for the dispatch preamble so the paste has its size. ┃ 80. filler line standing in for the dispatch preamble so the paste has its size. ┃ Task: reply with the single word ORCAPING and nothing else.  ┃ ┃ ┃ ┃ ┃ Build · DeepSeek V4.1 Flash OpenCode Go · high ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀~/xxxxxxx/xxxxxxxx/capture/workshift+tab agentsctrl+p commands[?25h[?2026l[?2026h[?25l ⠋+N [?25h[?2026l[?2026h[?25l ⠋ New + [?25h[?2026l[?2026h[?25l ⠋ New session  + [?25h[?2026l]0;OpenCode[?2026h[?25l ⠋ New session  + ┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃ ■⬝⬝⬝⬝⬝⬝⬝ esc interru [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠹ New session [?25h[?2026l[?2026h[?25l ⠸[?25h[?2026l[?2026h[?25l■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l⠸ New session [?25h[?2026l[?2026h[?25l ⠸⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l⠼ New session [?25h[?2026l[?2026h[?25l ⠼⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l ⠼ New session [?25h[?2026l[?2026h[?25l ⠼ [?25h[?2026l[?2026h[?25l⠼New session [?25h[?2026l[?2026h[?25l ⠴New session [?25h[?2026l[?2026h[?25l ⠴ ⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l⠴ew session [?25h[?2026l[?2026h[?25l ⠴ N⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l ⠦ New session [?25h[?2026l[?2026h[?25l Nw session ⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l ⠦Ne[?25h[?2026l[?2026h[?25lew session [?25h[?2026l[?2026h[?25l ⠦ New⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l⠧e session [?25h[?2026l[?2026h[?25lew ⬝⬝⬝⬝⬝⬝⬝■[?25h[?2026l[?2026h[?25l ⠧ Nwsession [?25h[?2026l[?2026h[?25l ew ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇w s[?25h[?2026l[?2026h[?25l⠇w s⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇ New sssion [?25h[?2026l[?2026h[?25l  se[?25h[?2026l[?2026h[?25l  se⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠏ses[?25h[?2026l[?2026h[?25l ⠏ses⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l⠏Nes[?25h[?2026l[?2026h[?25l ⠏ ess⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l⠋ess[?25h[?2026l[?2026h[?25l ⠋essi[?25h[?2026l[?2026h[?25l⠋ Nssi⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l wssi[?25h[?2026l[?2026h[?25l⠋ Nesio⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l ⠙sio[?25h[?2026l[?2026h[?25l⠙  io⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l ⠙Newion[?25h[?2026l[?2026h[?25l⠙ ion[?25h[?2026l[?2026h[?25l ⠙Nson ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l⠹ ew on [?25h[?2026l[?2026h[?25l Neon ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l⠹ ewsn  [?25h[?2026l[?2026h[?25l N n  [?25h[?2026l[?2026h[?25l⠸ ews  [?25h[?2026l[?2026h[?25lN se   [?25h[?2026l[?2026h[?25l ⠸ ews   ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l⠸Ne s   [?25h[?2026l[?2026h[?25l⠸wes   ■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠼ Ne si   [?25h[?2026l[?2026h[?25l ⠼wess   ■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l Ne s   [?25h[?2026l[?2026h[?25l⠼weo   ■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠴ sssi   [?25h[?2026l[?2026h[?25lNewen   [?25h[?2026l[?2026h[?25l⠴ ssso   ■⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ei   [?25h[?2026l[?2026h[?25lew sss    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠦Neion   [?25h[?2026l[?2026h[?25lw ss   ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lsei    [?25h[?2026l[?2026h[?25lesson    [?25h[?2026l[?2026h[?25l sei    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠧sson    [?25h[?2026l[?2026h[?25lwei    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lssson    [?25h[?2026l[?2026h[?25l io      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇essn    [?25h[?2026l[?2026h[?25lio     ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lsn     [?25h[?2026l[?2026h[?25lssio      [?25h[?2026l[?2026h[?25len      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l]0;OC | Orca readiness capture[?2026h[?25l⠏Orca readiess capture   $0.00[?25h[?2026l[?2026h[?25lnsp  ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ees ca  [?25h[?2026l[?2026h[?25ldinst  45678960612345678970712345678980Task: reply with he singlewd ORCAPING and nothingelse.     ⠋ Thinking ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠋es cp [?25h[?2026l[?2026h[?25l ansa [?25h[?2026l[?2026h[?25l ies cu⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠋dsapt[?25h[?2026l]0;OC | Orca readiness capture[?2026h[?25l • Orca readisapture 89606162634567897071727345678980Task: reply with he singlewd ORCAPING and nothingelse.     + Thought · 182ms   Build · DeepSeek V4.1 Flash · 1.7s · 12.8 tok/s~/xxxxxxx/xxxxxxxx/caure/work7.0K (1%) · [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture ORCAPING[?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture  [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture  [?25h[?2026l[?2026h[?25l • Orca readinss captur[?25h[?2026l[?2026h[?25l pture [?25h[?2026l[?2026h[?25l  Os capture [?25h[?2026l[?2026h[?25l Orca readinespture [?25h[?2026l[?2026h[?25l • Oscature [?25h[?2026l[?2026h[?25l •Orca readiness capture [?25h[?2026l[?2026h[?25l • rca readiness atue [?25h[?2026l[?2026h[?25l • ca readiness cpure [?25h[?2026l[?2026h[?25l • Orca readiness capture  [?25h[?2026l[?2026h[?25l • captue  [?25h[?2026l[?2026h[?25l • Ora readiness capture [?25h[?2026l[?2026h[?25l • Ora readiness capte [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l   [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.meta.json new file mode 100644 index 00000000000..0b76be346f9 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T06:50:40.510833Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 40, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 40x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane). One natural cold start, no other capture running; load average at start 59.44 / 56.29 / 68.90 from other work on the machine. --standalone, env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. Why this run: below 44 columns OpenCode 2 leaves the agent, and so the ' \u00b7 ' separator, out of the row under its input box, which shows the model alone. The box cursor showed at 11338 ms; the one-line task was bracketed-pasted 5136 ms later, at the agent-row signal's grace, and Enter sent 592 ms after that (promptSentAtMs). OpenCode submitted it: the reply was painted. The only separators are in the reply's footer, never on the row above the box's bottom corner. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.timing.json new file mode 100644 index 00000000000..d5012800d20 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":43535.8,"chunks":[[8942.4,317],[9527.3,1024],[9533.4,1024],[9539.5,1024],[9548.5,377],[9554.7,1024],[9611.7,1024],[9667.3,1024],[9684.9,261],[9742.1,24],[9744.4,1024],[9745.4,1024],[9758.2,1024],[9774.9,273],[9903.5,13],[11327.2,904],[11337.0,966],[11337.9,298],[12105.2,319],[12132.3,319],[12133.9,332],[12205.5,332],[12339.2,332],[12345.6,332],[16906.0,711],[17823.9,526],[18296.1,13],[19345.3,1016],[19359.4,1018],[19380.4,471],[19537.0,451],[19582.4,13],[20156.6,720],[20180.8,259],[20226.8,259],[20440.4,518],[20645.0,259],[20809.6,455],[20883.7,350],[20942.3,453],[21007.1,854],[21033.0,502],[21047.9,320],[21107.7,484],[21113.8,435],[21128.5,466],[21205.7,567],[21279.8,1020],[21339.9,100],[21446.7,1020],[21448.4,492],[21467.8,646],[21525.3,537],[21590.6,609],[21607.0,638],[21656.9,861],[21658.1,354],[21676.7,385],[21750.4,387],[21790.4,635],[21792.8,599],[21809.9,335],[21930.4,517],[21947.1,512],[21954.5,314],[21992.7,365],[21998.1,431],[22198.4,1020],[22201.1,212],[22209.3,399],[22377.1,582],[22398.5,632],[22419.2,588],[22443.5,601],[22480.0,453],[22531.5,417],[22578.2,600],[22607.7,665],[22617.8,567],[22633.6,435],[22655.4,601],[22716.9,587],[22723.5,721],[22871.2,372],[22886.5,681],[22983.1,737],[23002.9,639],[23178.7,609],[23186.5,465],[23210.5,368],[23222.3,348],[23260.7,447],[23266.8,381],[23289.4,315],[23307.7,282],[23324.8,413],[23341.7,314],[23362.2,249],[23468.6,614],[23544.4,531],[23566.4,600],[23973.6,1012],[23974.4,255],[23978.2,656],[24019.9,435],[24022.8,786],[24119.2,550],[24145.2,1006],[24162.9,28],[24218.8,952],[24253.0,661],[24300.5,1010],[24321.7,798],[24618.9,750],[24696.0,13],[25258.6,321],[25314.6,263],[25358.8,526],[25423.5,263],[25483.4,263],[25524.9,263],[25615.5,263],[25626.6,263],[25699.6,263],[25806.1,195],[26082.6,31],[26083.4,158],[26467.6,86]],"promptSentAtMs":17066.1} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.txt new file mode 100644 index 00000000000..5185562f8f7 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l█▀▀█ █▀▀█ █▀▀█ █▀▀▄█ █ █ █ █▀▀▀ █ █▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀██  █ █ █ █ █▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech sta ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/…/uuuuu/xxxxxxx/xxxxxxxx/capture/work]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25lTask: reply with the single word  ok. ┃ Fledge Alpha Free ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/…/uuuuu/xxxxxxx/xxxxxxxx/capture/work[?25h[?2026l[?2026h[?25lAsk anything… "What is the tech sta Fledge Alpha Free╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l]0;OpenCode[?2026h[?25l┃ ┃ Task: reply with the single word  ┃ ok. ┃              ┃ ┃ ┃ ┃ Fledge Alpha Free ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/…/uuuuu/xxxxxxx/xxxxxxxx/capture/work[?25h[?2026l[?2026h[?25l  Task: reply with the single word ok.┃ [?25h[?2026l]0;OpenCode[?2026h[?25l ⠋ New session  +   Task: reply with the single word ok.┃ [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l ⠙ New session [?25h[?2026l[?2026h[?25l ⠹ New session [?25h[?2026l[?2026h[?25l ⠹ New session [?25h[?2026l[?2026h[?25l ⠸ New session [?25h[?2026l[?2026h[?25l ⠸ Ne se[?25h[?2026l[?2026h[?25l ⠸ Nsesion [?25h[?2026l[?2026h[?25l ⠼ Neessi[?25h[?2026l[?2026h[?25l ⠼ Newsio[?25h[?2026l[?2026h[?25l ⠼ New ion[?25h[?2026l[?2026h[?25l⠴ sion[?25h[?2026l[?2026h[?25l ⠴ New on [?25h[?2026l[?2026h[?25l ⠴Neeon  [?25h[?2026l[?2026h[?25l ⠦ Nw sn  [?25h[?2026l[?2026h[?25l⠦ New ses   [?25h[?2026l[?2026h[?25l ⠧ New sessi   [?25h[?2026l[?2026h[?25l ⠧w ess   [?25h[?2026l[?2026h[?25l⠧ New session    [?25h[?2026l[?2026h[?25l⠇Nw session     [?25h[?2026l[?2026h[?25lew session      [?25h[?2026l[?2026h[?25l⠏ sion      [?25h[?2026l[?2026h[?25lsession        [?25h[?2026l[?2026h[?25l⠋ession         [?25h[?2026l[?2026h[?25lson         [?25h[?2026l[?2026h[?25lin       [?25h[?2026l[?2026h[?25ls        [?25h[?2026l[?2026h[?25l⠙on      [?25h[?2026l[?2026h[?25li         [?25h[?2026l[?2026h[?25l on              [?25h[?2026l[?2026h[?25l ⠹n             [?25h[?2026l[?2026h[?25l         [?25h[?2026l[?2026h[?25l ⠹            [?25h[?2026l[?2026h[?25l ⠸            [?25h[?2026l[?2026h[?25l ⠸      [?25h[?2026l[?2026h[?25l⠸ N      [?25h[?2026l[?2026h[?25l⠸ N        [?25h[?2026l[?2026h[?25l⠸ New            [?25h[?2026l[?2026h[?25l ⠼New s         [?25h[?2026l[?2026h[?25l ⠴ se    [?25h[?2026l[?2026h[?25l⠴  ses        [?25h[?2026l[?2026h[?25l ⠴ Nsess       [?25h[?2026l[?2026h[?25l ⠦ New essio    [?25h[?2026l[?2026h[?25l ⠧ Newsion   [?25h[?2026l[?2026h[?25l⠧New on  [?25h[?2026l[?2026h[?25l ⠧ Neon  [?25h[?2026l[?2026h[?25l ⠧ New ssn   [?25h[?2026l[?2026h[?25l ⠇ New ses     [?25h[?2026l[?2026h[?25l⠇ New ses   [?25h[?2026l[?2026h[?25l⠏ewsei   [?25h[?2026l[?2026h[?25l ⠏ Ne ssso   [?25h[?2026l[?2026h[?25l⠏ew sessi   [?25h[?2026l[?2026h[?25l⠋ Nw session   [?25h[?2026l[?2026h[?25lew s    [?25h[?2026l[?2026h[?25lNew session       [?25h[?2026l[?2026h[?25l⠙ ession         [?25h[?2026l[?2026h[?25l⠹session         [?25h[?2026l[?2026h[?25l ssion             [?25h[?2026l[?2026h[?25l ⠸n         [?25h[?2026l[?2026h[?25l          [?25h[?2026l[?2026h[?25l ⠸       [?25h[?2026l[?2026h[?25l ⠸          [?25h[?2026l[?2026h[?25l ⠼        [?25h[?2026l[?2026h[?25l ⠼      [?25h[?2026l[?2026h[?25l ⠼     [?25h[?2026l[?2026h[?25l ⠼         [?25h[?2026l[?2026h[?25l ⠴      [?25h[?2026l[?2026h[?25l⠴     [?25h[?2026l[?2026h[?25l⠴ Ne            [?25h[?2026l[?2026h[?25l⠦ New        [?25h[?2026l[?2026h[?25l ⠧New           [?25h[?2026l[?2026h[?25l ⠧ New session     ┃┃┃┃ ■⬝⬝⬝⬝⬝⬝⬝ esc interrupt [?25h[?2026l[?2026h[?25l ⠇ Nw s     ■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇ewses   [?25h[?2026l[?2026h[?25l⠇ New sei    ■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇ Ne sss   [?25h[?2026l[?2026h[?25l⠏ New session    ■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lNew session    ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25lew session     [?25h[?2026l[?2026h[?25l⠋w session        ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25lessn    ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l New session         Build · Fledge Alpha Free · 2.8s · 2.6 tok/s/…/uuuuu/orc...capture/work5.6K (1%)[?25h[?2026l]0;OpenCode[?2026h[?25l • New session o…/xxxxxxxx/[?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l   [?25h[?2026l]0;OC | Single-word reply: ok[?2026h[?25lSingl-word reply: ok[?25h[?2026l[?2026h[?25lok[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.meta.json new file mode 100644 index 00000000000..6f3ccfd7781 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-03T22:30:39Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 160, + "rows": 48, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 160x48, alternate screen. NOT the timed Python driver: a node-pty probe script (each read kept with its ms since spawn; it answers no terminal queries) used while investigating this bug. Recorded at natural load: other sessions on the machine had the load average at 4.39 / 30.38 / 44.19 (read 11 minutes later; this capture ran alone). The user's own login and plugins in place, OPENCODE_DISABLE_AUTOUPDATE=1, no ORCA_* vars. Orca's worker-start timing was replayed: an 82-line brief was bracketed-pasted at the box's show-cursor (3157 ms) and Enter sent 502 ms later (3659 ms, promptSentAtMs). The agent/model row only painted at 4141 ms; OpenCode dropped the Enter and the brief stayed in the composer as [Pasted ~82 lines]; OpenCode painted nothing more from 4164 ms until the probe stopped it at 40 s (its exit frames are cut). Scrubbed with same-length placeholders: checkout path and ticket id, and two earlier session titles in the tab strip. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.timing.json new file mode 100644 index 00000000000..4d0599cca3e --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":40000,"promptSentAtMs":3659,"chunks":[[1766,280],[1768,37],[2810,1024],[2811,1024],[2811,1024],[2812,1024],[2812,1024],[2813,1024],[2814,1024],[2815,1024],[2815,1024],[2816,497],[2824,13],[3153,1024],[3153,1024],[3154,1024],[3155,952],[3156,928],[3157,886],[3157,422],[3247,200],[3358,297],[3657,1016],[3657,1018],[3658,1018],[3658,1016],[3659,558],[3781,1016],[3783,1016],[3784,1016],[3785,771],[3790,1012],[3791,1010],[3791,422],[3825,1012],[3826,1010],[3826,598],[3830,747],[4141,1021],[4164,326]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.txt new file mode 100644 index 00000000000..1a5ee72fa09 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2026h[?25l                                                [?2026l]0;OpenCode[?2026h[?25lWhy dWe neWe neWe neSubagWe neXXXXWhy dWhy dWhy dXXXXX11RET63NET33SUR35NORFIVEG + New session   ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix broken tests" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀~/orca/workspaces/...xxx-xxxxxxxx-xxxxxxshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…workspa…/orca/xx-x[?25h[?2026l[?2026h[?25l • FIV[?25h[?2026l[?2026h[?25l • Why  • We n • We n • We n • Suba • We n • PONG  • Why  • Why  • Why  • NORE • 11RE • 63NE • 33SU • FIVExxxxxxxx-pe…[?25h[?2026l[?2026h[?25l • Why • We  • We  • We  • Sub • We  • PON • Why • Why • Why • NOR • 11R • 63N • 33S • FIV[?25h[?2026l[?2026h[?25l • h • e • e • e • u • e • O • h • h • h • O • 1 • 3 • 3 • I[?25h[?2026l[?2026h[?25l • W • W • W • W • S • W • P • W • W • W • N • 1 • 6 • 3 • F[Pasted ~82 lines]  [?25h[?2026l[?2026h[?25lWWWWSWPWWWN163F[?25h[?2026l[?2026h[?25lheeeueOhhhO133I·DeepSeek V4.1 FlashOpenCode Go·high[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.meta.json b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.meta.json new file mode 100644 index 00000000000..9ae8440a6b2 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:14:16.961893Z", + "platform": "darwin", + "command": ["opencode", "--server", "http://127.0.0.1:49917"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: the plain opencode command attaching to a server that already has its agent list loaded: an unregistered 'opencode serve' started for the capture (throwaway password, stopped by PID after; the registered background service was never used) and attached with --server after one priming attach, standing in for the shared service the opencode row attaches to. The agent/model row trails the box by 28 ms. No input typed.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.timing.json b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.timing.json new file mode 100644 index 00000000000..3e22360d489 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":8188.3,"chunks":[[57.0,280],[58.0,101],[64.6,1024],[64.9,1024],[65.2,1024],[65.4,1024],[65.6,1024],[65.9,1024],[66.2,549],[69.3,13],[101.7,1024],[102.3,1024],[103.1,958],[103.5,986],[103.8,958],[104.0,1016],[104.3,874],[104.5,1024],[104.7,1024],[105.0,486],[117.4,173],[133.3,476],[149.9,476],[166.6,481],[188.1,490],[205.9,491],[223.9,497],[241.9,497],[259.9,497],[278.0,497]]} diff --git a/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.txt b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.txt new file mode 100644 index 00000000000..46f22222c72 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]0;OpenCode[?2026h[?25l                  ▄   █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█  █ █ █ █ █▀▀▀ █ █ █  █ █ █ █ █▀▀▀  ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀    ┃   ┃ Ask anything… "What is the tech stack of this project?"   ┃   ┃   ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀  /Users/uuuuu/orca-...xxxxxx/capture/work shift+tab agents ctrl+p commands              2.0.21  ]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@agent.json new file mode 100644 index 00000000000..57316948a72 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-14: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "15-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-14: verdict=pending:closed wait=pending", + "15-29: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@unknown.json new file mode 100644 index 00000000000..8e14739f55c --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-14: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "15-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-14: verdict=pending:open wait=ready@poll", + "15-29: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@agent.json new file mode 100644 index 00000000000..f717a378362 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "22-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-21: verdict=pending:closed wait=pending", + "22-35: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@unknown.json new file mode 100644 index 00000000000..0289f95d468 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "22-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-21: verdict=pending:open wait=ready@poll", + "22-35: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@agent.json new file mode 100644 index 00000000000..6685798274b --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-17: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "18-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-17: verdict=pending:closed wait=pending", + "18-32: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@unknown.json new file mode 100644 index 00000000000..2c691e274e6 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-17: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "18-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-17: verdict=pending:open wait=ready@poll", + "18-32: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@agent.json new file mode 100644 index 00000000000..c06dea3a4ae --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@agent.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-8: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "9-208: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "209-280: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-8: verdict=pending:closed wait=pending", + "9-208: verdict=ready-weak wait=ready@start", + "209-280: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@unknown.json new file mode 100644 index 00000000000..25bb42e759f --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@unknown.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-8: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "9-208: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "209-280: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-8: verdict=pending:open wait=ready@poll", + "9-208: verdict=ready-weak wait=ready@start", + "209-280: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@agent.json new file mode 100644 index 00000000000..efa766b469b --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@agent.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 40x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-13: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "14-124: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "125-127: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-13: verdict=pending:closed wait=pending", + "14-124: verdict=ready-weak wait=ready@start", + "125-127: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@unknown.json new file mode 100644 index 00000000000..ea3223a4bf7 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@unknown.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 40x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-13: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "14-124: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "125-127: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-13: verdict=pending:open wait=ready@poll", + "14-124: verdict=ready-weak wait=ready@start", + "125-127: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@agent.json new file mode 100644 index 00000000000..efeb17cc774 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 160x48 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-11: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "12-39: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-11: verdict=pending:closed wait=pending", + "12-39: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@unknown.json new file mode 100644 index 00000000000..32e7ca23e8e --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 160x48 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-11: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "12-39: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-11: verdict=pending:open wait=ready@poll", + "12-39: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@agent.json new file mode 100644 index 00000000000..508c910ccbe --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-8: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "9-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-8: verdict=pending:closed wait=pending", + "9-29: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@unknown.json new file mode 100644 index 00000000000..035e7fd22ca --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-8: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "9-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-8: verdict=pending:open wait=ready@poll", + "9-29: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts index 8fbbf19d552..1674e587da8 100644 --- a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -4,6 +4,8 @@ * in its recorded reads (where timed) and in seeded random chunkings, and each must turn ready on * the read holding the offset a plain string walk finds. A regression guard for grok, DSH and * ZCode (their markers are one char, so the seam fix cannot move them), and the proof for OpenCode. + * OpenCode's agent-row signal reads screen structure no string walk expresses, so its reference is + * the scanner fed one char at a time; the transcript suites prove where that lands. */ import { readFileSync } from 'node:fs' @@ -31,15 +33,22 @@ const WALKS: Partial> = { } // Recorded zsh shape: the prompt enables bracketed paste and accept-line disables it before exec. -// The launcher's cursor toggle after that is synthetic, standing in for any spinner. +// The launcher's cursor toggle after that is synthetic, standing in for any spinner, and so is the +// `·` in the prompt, standing in for a theme that draws OpenCode's agent-row separator. const ZSH_LAUNCH_PROLOGUE = - '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + '\x1b[?2004h~ \u00b7 main % opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' const OPENCODE_TIMED = [ 'opencode-1-18-32-timed-boot-slow', 'opencode-1-18-32-timed-boot-hidden-pane', 'opencode-1-18-32-timed-first-launch', - 'opencode-2-0-18-timed-boot-hidden-pane' + 'opencode-2-0-18-timed-boot-hidden-pane', + 'opencode-2-0-21-timed-cold-standalone', + 'opencode-2-0-21-timed-cold-standalone-hidden-pane', + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode-2-0-21-timed-enter-after-agent-row', + 'opencode-2-0-14-timed-cold-standalone', + 'opencode-cmd-2-0-21-timed-warm-server' ] type Case = { name: string; signal: DraftPasteReadySignal; data: string; reads?: string[] } @@ -74,18 +83,20 @@ const CASES: Case[] = [ signal: 'render-cursor-after-bracketed-paste', data: readPtyTranscript('opencode.txt') }, - ...OPENCODE_TIMED.flatMap((name): Case[] => { - const { chunks } = readTimedRuntimeFixture(name) - return [ - { name, signal: 'render-cursor-after-bracketed-paste', data: chunks.join(''), reads: chunks }, - { - name: `${name} behind a zsh launch`, - signal: 'render-cursor-after-bracketed-paste', - data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), - reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] - } - ] - }) + ...(['render-cursor-after-bracketed-paste', 'opencode-agent-row'] as const).flatMap((signal) => + OPENCODE_TIMED.flatMap((name): Case[] => { + const { chunks } = readTimedRuntimeFixture(name) + return [ + { name: `${name} (${signal})`, signal, data: chunks.join(''), reads: chunks }, + { + name: `${name} behind a zsh launch (${signal})`, + signal, + data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), + reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] + } + ] + }) + ) ] /** End offset of the first marker seen while the anchor is held, walking the whole string. */ @@ -162,10 +173,16 @@ function chunkings(testCase: Case): Map { describe('draft-paste readiness does not depend on how the stream is chunked', () => { it.each(CASES.map((testCase) => [testCase.name, testCase] as const))('%s', (_, testCase) => { const walk = WALKS[testCase.signal] + const expected = walk + ? walkReadyEnd(testCase.data, walk) + : (scanReadyRead( + testCase.signal, + splitAt(testCase.data, () => 1) + )?.[1] ?? null) if (!walk) { - throw new Error(`no reference walk for ${testCase.signal}`) + expect(testCase.signal).toBe('opencode-agent-row') + expect(expected, 'every OpenCode capture paints its agent row').not.toBeNull() } - const expected = walkReadyEnd(testCase.data, walk) for (const [label, reads] of chunkings(testCase)) { const readyRead = scanReadyRead(testCase.signal, reads) if (expected === null) { @@ -187,5 +204,13 @@ describe('draft-paste readiness does not depend on how the stream is chunked', ( const alone = walkReadyEnd(data, walk) expect(alone).not.toBeNull() expect(walkReadyEnd(ZSH_LAUNCH_PROLOGUE + data, walk)).toBe(ZSH_LAUNCH_PROLOGUE.length + alone!) + const agentRowEnd = (stream: string): number | undefined => + scanReadyRead( + 'opencode-agent-row', + splitAt(stream, () => 1) + )?.[1] + expect(agentRowEnd(ZSH_LAUNCH_PROLOGUE + data)).toBe( + ZSH_LAUNCH_PROLOGUE.length + agentRowEnd(data)! + ) }) }) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts index ad336e93f9f..cf748cf5aba 100644 --- a/src/main/runtime/launched-agent-composer-readiness.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -1,7 +1,10 @@ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + createDraftPasteReadyScanner, + resolvePasteReadySignal +} from '../../shared/draft-paste-ready-scanner' import type { TuiAgent } from '../../shared/tui-agent' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' @@ -77,7 +80,8 @@ const EXPECTED_LANES: Record = { const FIXTURES = join(__dirname, '__fixtures__') const OPENCODE_PLACEHOLDER = 'Ask anything' -const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' +const AGENT_ROW = /\u00b7 \S/ +const BOX_BOTTOM_LEFT = '\u2579' const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) => (row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const) @@ -91,11 +95,11 @@ function readSignal(agent: string) { if (!isTuiAgent(agent)) { throw new Error(`${agent} is not a TuiAgent`) } - const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal - if (!signal) { + if (!TUI_AGENT_CONFIG[agent].draftPasteReadySignal) { throw new Error(`${agent} cites composer-ready captures but has no draftPasteReadySignal`) } - return signal + // Worker start always presses Enter after its paste. + return resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], true) } /** Index of the first read the scanner reports ready on, or -1. */ @@ -163,35 +167,39 @@ describe('every capture a row cites proves its input-box marker', () => { expect(firstReadyRead(agent, readReads(capture))).toBeGreaterThanOrEqual(0) }) - it.each( - CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'render-cursor-after-bracketed-paste') - )( - '%s: the first ready read in %s is the one that shows the input box', + it.each(CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'opencode-agent-row'))( + '%s: the first ready read in %s is no earlier than the agent row under the box', async (agent, capture) => { const meta: { cols: number; rows: number } = JSON.parse( readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') ) const { chunks } = readTimedRuntimeFixture(capture) - const data = chunks.join('') - // OpenCode paints its box in one synchronized update, shown when that update ends. - const boxEnd = data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) let boxRead = -1 - for (let index = 0, end = 0; index < chunks.length && boxRead === -1; index += 1) { - end += chunks[index].length - boxRead = end >= boxEnd + SYNCHRONIZED_UPDATE_END.length ? index : -1 - } - let placeholderRead = -1 + let rowRead = -1 let read = 0 for await (const frame of replayTranscript(chunks, meta.cols, meta.rows)) { - if (frame.screenLines.some((line) => line.includes(OPENCODE_PLACEHOLDER))) { - placeholderRead = read + // A brief pasted before the row replaces the placeholder in the box. + const box = frame.screenLines.findIndex( + (line) => line.includes(OPENCODE_PLACEHOLDER) || line.includes('[Pasted ~') + ) + if (box !== -1 && boxRead === -1) { + boxRead = read + } + // The row inside the box's last line reads ` · `; a read can paint the + // separator and model before the agent. + const corner = frame.screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) + if (box !== -1 && corner > box && AGENT_ROW.test(frame.screenLines[corner - 1])) { + rowRead = read break } read += 1 } - expect(placeholderRead).toBeGreaterThanOrEqual(0) - expect(boxRead).toBeGreaterThanOrEqual(placeholderRead) - expect(firstReadyRead(agent, chunks)).toBe(boxRead) + expect(boxRead).toBeGreaterThanOrEqual(0) + expect(rowRead).toBeGreaterThanOrEqual(boxRead) + // OpenCode 1 paints the row just before the box's cursor in one frame; 2 paints it later. + const cursorRead = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + const boxCursorRead = chunks.findIndex((chunk) => cursorRead.observe(chunk).ready) + expect(firstReadyRead(agent, chunks)).toBe(Math.max(rowRead, boxCursorRead)) } ) }) diff --git a/src/main/runtime/opencode-agent-row-enter-transcript.test.ts b/src/main/runtime/opencode-agent-row-enter-transcript.test.ts new file mode 100644 index 00000000000..8ead466a915 --- /dev/null +++ b/src/main/runtime/opencode-agent-row-enter-transcript.test.ts @@ -0,0 +1,73 @@ +/** + * Proof that the agent-row signal is the moment OpenCode 2 can submit, from two natural cold + * `--standalone` starts that replayed Orca's worker-start timing (paste at readiness, Enter 500 ms + * plus paste time later, at promptSentAtMs): one pasted on the agent row, one on the box's cursor + * while a loaded machine kept the agent list from arriving. A third, in a pane too narrow for the + * row, pasted at the signal's grace instead. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { DraftPasteReadySignal } from '../../shared/tui-agent-config' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' + +const HELD_PASTE = '[Pasted ~' +const AGENT_ROW_SIGNAL = TUI_AGENT_CONFIG.opencode2.submitPasteReadySignal! +// The box's show-cursor, the rule OpenCode used before the agent-row signal. +const BOX_CURSOR_SIGNAL: DraftPasteReadySignal = 'render-cursor-after-bracketed-paste' + +function readyAtMs(name: string, signal: DraftPasteReadySignal): number { + const { chunks, times } = readTimedRuntimeFixture(name) + const scanner = createDraftPasteReadyScanner(signal) + return times[chunks.findIndex((chunk) => scanner.observe(chunk).ready)] +} + +async function finalScreen(name: string): Promise { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') + ) + let lastScreen: string[] = [] + for await (const frame of replayTranscript( + readTimedRuntimeFixture(name).chunks, + meta.cols, + meta.rows + )) { + lastScreen = frame.screenLines + } + return lastScreen.join('\n') +} + +describe('OpenCode 2 submits an Enter sent after the agent row and drops one sent before it', () => { + it('submits the brief when Orca pastes on the agent row', async () => { + const name = 'opencode-2-0-21-timed-enter-after-agent-row' + const { promptSentAtMs } = readTimedRuntimeFixture(name) + expect(readyAtMs(name, AGENT_ROW_SIGNAL)).toBeLessThan(promptSentAtMs!) + const screen = await finalScreen(name) + expect(screen).not.toContain(HELD_PASTE) + expect(screen).toContain('Task: reply with the single word') + }) + + it('drops the Enter of a brief pasted on the box cursor while the agent list is late', async () => { + const name = 'opencode-2-0-21-timed-natural-load-enter-dropped' + const { promptSentAtMs } = readTimedRuntimeFixture(name) + const boxCursorAt = readyAtMs(name, BOX_CURSOR_SIGNAL) + const agentRowAt = readyAtMs(name, AGENT_ROW_SIGNAL) + // The old rule fired over half a second before the row, so Orca's Enter landed in the gap. + expect(agentRowAt - boxCursorAt).toBeGreaterThanOrEqual(500) + expect(promptSentAtMs!).toBeGreaterThan(boxCursorAt) + expect(promptSentAtMs!).toBeLessThan(agentRowAt) + expect(await finalScreen(name)).toContain(HELD_PASTE) + }) + + it('submits the brief Orca pastes at the grace in a pane too narrow for the agent row', async () => { + const name = 'opencode-2-0-21-timed-narrow-pane' + const { promptSentAtMs } = readTimedRuntimeFixture(name) + expect(readyAtMs(name, BOX_CURSOR_SIGNAL)).toBeLessThan(promptSentAtMs!) + const screen = await finalScreen(name) + expect(screen).not.toContain(HELD_PASTE) + expect(screen).toContain('Task: reply with the single word') + }) +}) diff --git a/src/main/runtime/opencode-agent-row-structure-transcript.test.ts b/src/main/runtime/opencode-agent-row-structure-transcript.test.ts new file mode 100644 index 00000000000..b5b25becd1f --- /dev/null +++ b/src/main/runtime/opencode-agent-row-structure-transcript.test.ts @@ -0,0 +1,87 @@ +/** + * The agent-row signal keys on where OpenCode paints its separator, not on the character: a `·` in + * the footer path under the box or in a session tab title is drawn before the row in these real + * starts, and must not move the read the signal fires on. Each is written into a recorded capture + * in place of characters of the same length, so every read keeps its recorded size. + */ + +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' + +const OPENCODE_2_STARTS = [ + 'opencode-2-0-14-timed-cold-standalone', + 'opencode-2-0-18-timed-boot-hidden-pane', + 'opencode-2-0-21-timed-cold-standalone', + 'opencode-2-0-21-timed-cold-standalone-hidden-pane', + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode-2-0-21-timed-enter-after-agent-row', + 'opencode-cmd-2-0-21-timed-warm-server' +] +// Only these starts paint a session tab strip before the row. +const TAB_STRIP_STARTS = [ + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode-2-0-21-timed-enter-after-agent-row' +] +const ALT_SCREEN_ENTER = '\x1b[?1049h' +const SGR = '(?:\\x1b\\[[0-9;:]*m)*' +const FOOTER_DIRECTORY = 'col·lecció' + +function readyRead(chunks: readonly string[]): number { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + return chunks.findIndex((chunk) => scanner.observe(chunk).ready) +} + +/** Overwrites `data` at `offset` with `text`, then cuts it back into the recorded read sizes. */ +function overwrite(chunks: readonly string[], offset: number, text: string): string[] { + const data = chunks.join('') + const edited = data.slice(0, offset) + text + data.slice(offset + text.length) + let start = 0 + return chunks.map((chunk) => edited.slice(start, (start += chunk.length))) +} + +function readAt(chunks: readonly string[], offset: number): number { + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) > offset) +} + +/** Offset of the footer path's first directory, on the line under the box's bottom corner. */ +function footerDirectoryOffset(data: string): number { + const alt = data.indexOf(ALT_SCREEN_ENTER) + const corner = new RegExp(`\\x1b\\[(\\d+);\\d+H${SGR}\\u2579`, 'g') + corner.lastIndex = alt + const cornerRow = Number(corner.exec(data)![1]) + const path = new RegExp(`\\x1b\\[${cornerRow + 1};\\d+H${SGR}(?:~|/[^/\\x1b]+)/`, 'g') + path.lastIndex = alt + const match = path.exec(data)! + return match.index + match[0].length +} + +/** Offset of the first character a session tab title paints on the top line. */ +function tabTitleCharOffset(data: string): number { + const title = new RegExp(`\\x1b\\[1;\\d+H${SGR}(?=[A-Za-z])`, 'g') + title.lastIndex = data.indexOf(ALT_SCREEN_ENTER) + const match = title.exec(data)! + return match.index + match[0].length +} + +describe('a separator OpenCode draws off the agent row does not make it ready', () => { + it.each(OPENCODE_2_STARTS)('%s: a footer path with a `·` in it', (name) => { + const { chunks } = readTimedRuntimeFixture(name) + const data = chunks.join('') + const at = footerDirectoryOffset(data) + // Only painted path text is replaced. + expect(data.slice(at, at + FOOTER_DIRECTORY.length)).toMatch(/^[\w./-]+$/) + const edited = overwrite(chunks, at, FOOTER_DIRECTORY) + expect(readAt(edited, at)).toBeLessThan(readyRead(chunks)) + expect(readyRead(edited)).toBe(readyRead(chunks)) + }) + + it.each(TAB_STRIP_STARTS)('%s: a session tab title with a `·` in it', (name) => { + const { chunks } = readTimedRuntimeFixture(name) + const at = tabTitleCharOffset(chunks.join('')) + const edited = overwrite(chunks, at, '·') + expect(readAt(edited, at)).toBeLessThan(readyRead(chunks)) + expect(readyRead(edited)).toBe(readyRead(chunks)) + }) +}) diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts index 52aa2f4da86..e49254defdd 100644 --- a/src/main/runtime/opencode-worker-composer-transcript.test.ts +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -4,10 +4,14 @@ * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' import type { TuiAgent } from '../../shared/tui-agent' import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' -import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' +import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' vi.mock('electron', () => ({ @@ -28,8 +32,16 @@ const RUNS: [string, TuiAgent][] = [ ['opencode-1-18-32-timed-boot-slow', 'opencode'], ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], ['opencode-1-18-32-timed-first-launch', 'opencode'], - ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'] + ['opencode-cmd-2-0-21-timed-warm-server', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'], + ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] ] +const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) +const AGENT_ROW = /\u00b7 \S/ +const BOX_BOTTOM_LEFT = '\u2579' /** The read that ends the synchronized update drawing OpenCode's input box. */ function boxRead(chunks: string[]): number { @@ -41,6 +53,22 @@ function boxRead(chunks: string[]): number { return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) } +/** The first read whose screen shows `· ` on the box's last line, above its corner. */ +async function agentRowRead(name: string, chunks: string[]): Promise { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') + ) + let read = 0 + for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) { + const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) + if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) { + return read + } + read += 1 + } + return -1 +} + async function replay(name: string, agent: TuiAgent) { const { chunks, times } = readTimedRuntimeFixture(name) const { runtime, handle } = await createTranscriptPane({ @@ -52,13 +80,15 @@ async function replay(name: string, agent: TuiAgent) { }) vi.useFakeTimers() runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) - const settledAt: { composer: number | null; tuiIdle: number | null } = { - composer: null, - tuiIdle: null - } + const settledAt: { composer: number | null; composerMs: number | null; tuiIdle: number | null } = + { composer: null, composerMs: null, tuiIdle: null } let read = -1 + const startedAt = Date.now() const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) - void composer.then(() => (settledAt.composer ??= read)) + void composer.then(() => { + settledAt.composer ??= read + settledAt.composerMs ??= Date.now() - startedAt + }) const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) void tuiIdle.then( () => (settledAt.tuiIdle ??= read), @@ -73,23 +103,61 @@ async function replay(name: string, agent: TuiAgent) { runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) await vi.advanceTimersByTimeAsync(0) } - return { settledAt, composer, boxRead: boxRead(chunks) } + return { + settledAt, + composer, + boxRead: boxRead(chunks), + agentRowRead: await agentRowRead(name, chunks) + } } -describe('an OpenCode worker gets its task only once its input box exists', () => { +describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { afterEach(() => { vi.useRealTimers() }) it.each(RUNS)( - '%s: the worker-start lane settles on the read that shows the box', + '%s: the worker-start lane settles once the box and the agent row are both painted', async (name, agent) => { - const { settledAt, composer, boxRead } = await replay(name, agent) + const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent) await expect(composer).resolves.toMatchObject({ satisfied: true }) + // OpenCode 1 paints the row inside the box's frame, so the box read is the later one there. + expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead)) + } + ) + + it.each(OPENCODE_1_RUNS)( + '%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) expect(settledAt.composer).toBe(boxRead) } ) + it('never settles on the box while a slow agent list leaves the row unpainted', async () => { + const { settledAt, boxRead } = await replay( + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode2' + ) + expect(settledAt.composer).toBeGreaterThan(boxRead) + }) + + it('takes the box after the grace in a pane too narrow for the agent row', async () => { + const name = 'opencode-2-0-21-timed-narrow-pane' + const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) + const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)] + const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2') + expect(agentRowRead).toBe(-1) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + // Fake timers drop the recorded times' fraction of a millisecond. + expect( + Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS)) + ).toBeLessThan(1) + // The recorder pasted at the grace and pressed Enter after it, and OpenCode took it. + expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!) + }) + it.each(RUNS)( '%s: the bare-name tui-idle wait main used would have settled before the box', async (name, agent) => { diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index f0959855f5f..36e05550fd5 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -213,6 +213,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM { timeoutMs, requireComposerMarker, + // Every caller pastes and then presses Enter: a worker brief or a launch prompt. + submit: true, signal: stop.signal, isShellInFront: async (ownerPtyId) => (await this.readLaunchedAgentForeground(ownerPtyId, agent)) === 'shell', diff --git a/src/main/runtime/readiness-census-transcript-catalog.ts b/src/main/runtime/readiness-census-transcript-catalog.ts index 42b489a15cc..875f9e38aae 100644 --- a/src/main/runtime/readiness-census-transcript-catalog.ts +++ b/src/main/runtime/readiness-census-transcript-catalog.ts @@ -45,6 +45,8 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [ ['hermes-', { agent: 'hermes', foregroundProcess: 'hermes' }], ['muse-', { agent: 'muse', foregroundProcess: 'muse' }], ['omp-', { agent: 'omp', foregroundProcess: 'omp' }], + // The plain `opencode` command running an OpenCode 2 binary, as the opencode row launches it. + ['opencode-cmd-', { agent: 'opencode', foregroundProcess: 'opencode' }], ['opencode-2-', { agent: 'opencode2', foregroundProcess: 'opencode' }], ['opencode-1-', { agent: 'opencode', foregroundProcess: 'opencode' }], ['prime-agent-', { agent: 'prime-agent', foregroundProcess: 'prime-agent' }], diff --git a/src/main/runtime/runtime-worktree-startup-readiness.test.ts b/src/main/runtime/runtime-worktree-startup-readiness.test.ts index e96a072f7ed..0155cb11d7d 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.test.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' import { waitForWorktreeStartupDraft, type WorktreeStartupReadinessHost @@ -69,3 +70,302 @@ describe('fresh worker composer readiness', () => { expect(h.unsubscribe).toHaveBeenCalledOnce() }) }) + +describe('OpenCode submit readiness', () => { + afterEach(() => vi.useRealTimers()) + + // OpenCode 2.0.21's box with its cursor and bottom corner, then the agent row directly above it. + const BOX = '\x1b[?1049h\x1b[?2004h\x1b[24;24H┃\x1b[25;24H╹\x1b[22;27H\x1b[?25h' + const AGENT_ROW = '\x1b[24;27HBuild\x1b[24;33H\u00b7\x1b[24;35HSome Model' + + function fixture() { + let listener = (_data: string): void => {} + const host: WorktreeStartupReadinessHost = { + getPtyId: () => 'pty-1', + getForegroundProcess: async () => 'opencode', + subscribeToData: (_ptyId, onData) => { + listener = onData + return () => {} + }, + readRecentOutput: () => undefined, + write: vi.fn() + } + return { host, emit: (data: string) => listener(data) } + } + + function settle(pending: Promise): { value: string | null | undefined } { + const result: { value: string | null | undefined } = { value: undefined } + void pending.then((value) => (result.value = value)) + return result + } + + it('a draft that is not submitted is pasted on the box cursor, as before', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle(waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2')) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBe('pty-1') + }) + + it('a submitted task waits for the agent row', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 60_000, + requireComposerMarker: true, + submit: true + }) + ) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(1000) + expect(result.value).toBeUndefined() + h.emit(AGENT_ROW) + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBe('pty-1') + expect(vi.getTimerCount()).toBe(0) + }) + + it('takes the box after the grace when the row never comes, whatever repaints', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 60_000, + requireComposerMarker: true, + submit: true + }) + ) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1) + h.emit('\x1b[22;27H\x1b[?25h') + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBe('pty-1') + expect(vi.getTimerCount()).toBe(0) + }) + + it('takes the box at the deadline when the grace would outlast the budget', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 60_000, + requireComposerMarker: true, + submit: true + }) + ) + await vi.advanceTimersByTimeAsync(59_000) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(999) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBe('pty-1') + expect(vi.getTimerCount()).toBe(0) + }) + + it('withdraws the grace when OpenCode turns bracketed paste off', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 60_000, + requireComposerMarker: true, + submit: true + }) + ) + h.emit(BOX) + h.emit('\x1b[?2004l\x1b[?1049l') + await vi.advanceTimersByTimeAsync(59_999) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBeNull() + }) + + describe('through the settle checks a ready signal gets', () => { + function waitWith( + h: ReturnType, + checks: { + accept?: () => boolean + isShellInFront?: () => Promise + timeoutMs?: number + signal?: AbortSignal + } + ) { + return settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: checks.timeoutMs ?? 60_000, + requireComposerMarker: true, + submit: true, + ...(checks.accept ? { accept: checks.accept } : {}), + ...(checks.isShellInFront ? { isShellInFront: checks.isShellInFront } : {}), + ...(checks.signal ? { signal: checks.signal } : {}) + }) + ) + } + + it('does not settle a grace while a shell is proven in front', async () => { + vi.useFakeTimers() + const h = fixture() + let shellInFront = true + const result = waitWith(h, { isShellInFront: async () => shellInFront }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS) + expect(result.value).toBeUndefined() + // The next read that still shows the box asks again, and now OpenCode is in front. + shellInFront = false + h.emit('\x1b[22;27H\x1b[?25h') + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBe('pty-1') + }) + + it('lets a dialog on screen veto a grace', async () => { + vi.useFakeTimers() + const h = fixture() + const accept = vi.fn(() => false) + const result = waitWith(h, { accept }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS) + expect(accept).toHaveBeenCalledWith('pty-1') + expect(result.value).toBeUndefined() + }) + + it('refuses the deadline inside a pending grace while a shell is in front', async () => { + vi.useFakeTimers() + const h = fixture() + const result = waitWith(h, { isShellInFront: async () => true, timeoutMs: 3000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + expect(result.value).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('takes the box at the deadline inside a pending grace with the agent in front', async () => { + vi.useFakeTimers() + const h = fixture() + const result = waitWith(h, { isShellInFront: async () => false, timeoutMs: 3000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + expect(result.value).toBe('pty-1') + expect(vi.getTimerCount()).toBe(0) + }) + + it('withdraws a pending grace at a shell hand-off', async () => { + vi.useFakeTimers() + const h = fixture() + const result = waitWith(h, { isShellInFront: async () => false, timeoutMs: 8000 }) + h.emit(BOX) + // The agent exits to its shell, which turns bracketed paste off as it runs a command. + h.emit('\x1b[?2004l') + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(8000 - OPENCODE_AGENT_ROW_GRACE_MS) + expect(result.value).toBeNull() + }) + + // A foreground read the test answers by hand, as an SSH host would. + function pendingRead() { + let answer = (_inFront: boolean): void => {} + let fail = (_error: Error): void => {} + const read = vi.fn( + () => + new Promise((resolve, reject) => { + answer = resolve + fail = reject + }) + ) + return { read, answer: (inFront: boolean) => answer(inFront), fail: (e: Error) => fail(e) } + } + + it('refuses the deadline when a shell hand-off lands during its check', async () => { + vi.useFakeTimers() + const h = fixture() + const check = pendingRead() + const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + h.emit('\x1b[?2004l') + check.answer(false) + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBeNull() + }) + + it('refuses the deadline when its check fails', async () => { + vi.useFakeTimers() + const h = fixture() + const check = pendingRead() + const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + check.fail(new Error('ssh channel closed')) + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBeNull() + }) + + it('resolves once, with null, when aborted during the deadline check', async () => { + vi.useFakeTimers() + const h = fixture() + const check = pendingRead() + const abort = new AbortController() + const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 3000, + requireComposerMarker: true, + submit: true, + isShellInFront: check.read, + signal: abort.signal + }) + const resolved = vi.fn() + void pending.then(resolved) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + abort.abort() + check.answer(false) + await vi.advanceTimersByTimeAsync(0) + expect(resolved.mock.calls).toEqual([[null]]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('takes the box at the deadline after a refused grace once the agent is in front', async () => { + vi.useFakeTimers() + const h = fixture() + let shellInFront = true + const result = waitWith(h, { isShellInFront: async () => shellInFront, timeoutMs: 8000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS) + expect(result.value).toBeUndefined() + shellInFront = false + await vi.advanceTimersByTimeAsync(8000 - OPENCODE_AGENT_ROW_GRACE_MS) + expect(result.value).toBe('pty-1') + }) + + it('gives up when the deadline check never answers', async () => { + vi.useFakeTimers() + const h = fixture() + const check = pendingRead() + const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + expect(check.read).toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1999) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('restarts the grace at a hand-off rather than keeping the one from before it', async () => { + vi.useFakeTimers() + const h = fixture() + const result = waitWith(h, { isShellInFront: async () => false }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + // A hand-off whose chunk goes on to show a cursor under bracketed paste again. + h.emit('\x1b[?2004l\x1b[?2004h\x1b[?25h') + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBe('pty-1') + }) + }) +}) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index fed0371836f..29f8d01d8b4 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -1,6 +1,9 @@ import { isShellProcess } from '../../shared/agent-detection' import { isExpectedAgentProcess } from '../../shared/agent-process-recognition' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + createDraftPasteReadyScanner, + resolvePasteReadySignal +} from '../../shared/draft-paste-ready-scanner' import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' @@ -16,6 +19,9 @@ const BRACKETED_PASTE_QUIET_MS = 1500 // Why: an interactive shell turns bracketed paste on at its prompt and off when it runs the typed // command (`zsh-prompt-runs-command.txt`), so a 2004 before the last `?2004l` is the shell's. const DECRST_BRACKETED_PASTE = '\x1b[?2004l' +// Why: the deadline's last settle check is one foreground read, which over SSH could otherwise hold +// a past-due wait for the channel's 30 s timeout. +const DEADLINE_SETTLE_CHECK_MS = 2_000 export type WorktreeStartupReadinessHost = { getPtyId: (handle: string) => string | null @@ -103,6 +109,8 @@ export type StartupDraftReadinessOptions = { * proven in front: the launch line has not run yet, or the agent exited. */ isShellInFront?: (ptyId: string) => Promise + /** Enter follows the paste, so the agent's submit signal is waited for instead of its draft one. */ + submit?: boolean } export function waitForWorktreeStartupDraft( @@ -115,14 +123,18 @@ export function waitForWorktreeStartupDraft( if (!ptyId || options.signal?.aborted) { return Promise.resolve(null) } - const signal = - TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const signal = resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], options.submit === true) const isShellInFront = options.isShellInFront return new Promise((resolve) => { let settled = false let scanner = createDraftPasteReadyScanner(signal) let quietTimer: NodeJS.Timeout | null = null + // A submit signal's fallback: once its grace elapses, every read that still asks for it is a + // ready signal, settled like any other. + let graceTimer: NodeJS.Timeout | null = null + let graceElapsed = false let hardTimer: NodeJS.Timeout | null = null + let deadlineCheckTimer: NodeJS.Timeout | null = null let unsubscribe: (() => void) | null = null // Bumped at each shell hand-off: a check begun before one must not settle the wait after it. let handoffs = 0 @@ -139,21 +151,33 @@ export function waitForWorktreeStartupDraft( if (quietTimer) { clearTimeout(quietTimer) } + clearGrace() if (hardTimer) { clearTimeout(hardTimer) } + if (deadlineCheckTimer) { + clearTimeout(deadlineCheckTimer) + } unsubscribe?.() options.signal?.removeEventListener('abort', onAbort) resolve(value) } + const clearGrace = (): void => { + if (graceTimer) { + clearTimeout(graceTimer) + graceTimer = null + } + graceElapsed = false + } + /** The screen holds nothing the input must not answer and no shell is proven in front. */ + const passesSettleChecks = async (): Promise => + (!options.accept || (await options.accept(ptyId))) && + !(isShellInFront && (await isShellInFront(ptyId))) /** Settles a fired signal once its screen is clear and no shell is proven in front. */ const settleSignal = async (signalHandoffs: number): Promise => { checking = true try { - if (options.accept && !(await options.accept(ptyId))) { - return - } - if (isShellInFront && (await isShellInFront(ptyId))) { + if (!(await passesSettleChecks())) { return } if (signalHandoffs === handoffs) { @@ -192,6 +216,7 @@ export function waitForWorktreeStartupDraft( clearTimeout(quietTimer) quietTimer = null } + clearGrace() return window.slice(handoff + DECRST_BRACKETED_PASTE.length) } const observe = (chunk: string): void => { @@ -203,6 +228,19 @@ export function waitForWorktreeStartupDraft( if (result.ready) { return onSignal() } + if (result.readyAfterMs === null) { + clearGrace() + } else if (typeof result.readyAfterMs === 'number') { + if (graceElapsed) { + onSignal() + } else if (!graceTimer) { + graceTimer = setTimeout(() => { + graceTimer = null + graceElapsed = true + onSignal() + }, result.readyAfterMs) + } + } if (result.armQuietTimer && !options.requireComposerMarker) { if (quietTimer) { clearTimeout(quietTimer) @@ -212,10 +250,19 @@ export function waitForWorktreeStartupDraft( } options.signal?.addEventListener('abort', onAbort) unsubscribe = host.subscribeToData(ptyId, observe) - hardTimer = setTimeout( - () => finish(null), - options.timeoutMs ?? resolveDraftPasteReadyTimeoutMs(agent) - ) + // A deadline inside a pending grace is the box rule's verdict, so it gets one last settle check. + const onDeadline = (): void => { + if (!graceTimer && !graceElapsed) { + return finish(null) + } + const deadlineHandoffs = handoffs + deadlineCheckTimer = setTimeout(() => finish(null), DEADLINE_SETTLE_CHECK_MS) + void passesSettleChecks().then( + (passes) => finish(passes && deadlineHandoffs === handoffs ? ptyId : null), + () => finish(null) + ) + } + hardTimer = setTimeout(onDeadline, options.timeoutMs ?? resolveDraftPasteReadyTimeoutMs(agent)) const replay = host.readRecentOutput(ptyId) if (replay) { observe(replay) diff --git a/src/renderer/src/lib/agent-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index fe0ce62c4a9..bfc11476ca6 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -4,6 +4,7 @@ import { clearPreHandlerPtyState, drainPreHandlerPtyData } from '@/components/terminal-pane/pty-pre-handler-buffer' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../../shared/opencode-agent-row-scanner' import { waitForAgentDraftInputReady } from './agent-draft-readiness' const testState = vi.hoisted(() => ({ @@ -25,12 +26,15 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ const PTY_ID = 'pty-buffered-codex' const CODEX_COMPOSER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +// OpenCode's box with its cursor and bottom corner, before the agent row under it (2.0.21 shape). +const OPENCODE_BOX = '\x1b[?1049h\x1b[?2004h\x1b[24;24H┃\x1b[25;24H╹\x1b[22;27H\x1b[?25h' describe('waitForAgentDraftInputReady', () => { afterEach(() => { clearPreHandlerPtyState(PTY_ID) testState.observer = null testState.unsubscribe.mockReset() + vi.unstubAllGlobals() vi.useRealTimers() }) @@ -52,4 +56,52 @@ describe('waitForAgentDraftInputReady', () => { ]) expect(vi.getTimerCount()).toBe(0) }) + + it('falls back to the box after the grace when OpenCode never paints its agent row', async () => { + vi.useFakeTimers() + vi.stubGlobal('window', globalThis) + let settled: boolean | null = null + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + (ready) => (settled = ready) + ) + testState.observer!(OPENCODE_BOX) + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1) + // More box frames do not restart the grace. + testState.observer!('\x1b[22;27H\x1b[?25h') + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it('takes the box at the deadline when the grace would outlast the budget', async () => { + vi.useFakeTimers() + vi.stubGlobal('window', globalThis) + let settled: boolean | null = null + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + (ready) => (settled = ready) + ) + await vi.advanceTimersByTimeAsync(19_000) + testState.observer!(OPENCODE_BOX) + await vi.advanceTimersByTimeAsync(999) + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it('withdraws the grace when OpenCode turns bracketed paste off', async () => { + vi.useFakeTimers() + vi.stubGlobal('window', globalThis) + let settled: boolean | null = null + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + (ready) => (settled = ready) + ) + testState.observer!(OPENCODE_BOX) + testState.observer!('\x1b[?2004l') + await vi.advanceTimersByTimeAsync(19_999) + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(false) + }) }) diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index 25be94193da..22e9c2e8535 100644 --- a/src/renderer/src/lib/agent-draft-readiness.ts +++ b/src/renderer/src/lib/agent-draft-readiness.ts @@ -28,6 +28,7 @@ export function waitForAgentDraftInputReady( let settled = false const scanner = createDraftPasteReadyScanner(readySignal) let quietTimer: number | null = null + let graceTimer: number | null = null let hardTimer: number | null = null let unsubscribe: (() => void) | null = null @@ -42,6 +43,9 @@ export function waitForAgentDraftInputReady( if (quietTimer !== null) { window.clearTimeout(quietTimer) } + if (graceTimer !== null) { + window.clearTimeout(graceTimer) + } unsubscribe?.() resolve(value) } @@ -54,11 +58,17 @@ export function waitForAgentDraftInputReady( } const observeData = (data: string): void => { - const { ready, armQuietTimer: shouldArm } = scanner.observe(data) + const { ready, armQuietTimer: shouldArm, readyAfterMs } = scanner.observe(data) if (ready) { finish(true) return } + if (readyAfterMs === null && graceTimer !== null) { + window.clearTimeout(graceTimer) + graceTimer = null + } else if (typeof readyAfterMs === 'number' && graceTimer === null) { + graceTimer = window.setTimeout(() => finish(true), readyAfterMs) + } if (shouldArm) { armQuietTimer() } @@ -87,7 +97,8 @@ export function waitForAgentDraftInputReady( } if (!settled) { - hardTimer = window.setTimeout(() => finish(false), timeoutMs) + // A pending grace means the box was seen: take it, as the box rule would have. + hardTimer = window.setTimeout(() => finish(graceTimer !== null), timeoutMs) } }) } diff --git a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts index 66511d1fc4c..32b0b88c753 100644 --- a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { pasteDraftToAgentPtyWhenReady } from './agent-paste-draft' +import { pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady } from './agent-paste-draft' const testState = vi.hoisted(() => ({ waitForReady: vi.fn(), @@ -9,7 +9,8 @@ const testState = vi.hoisted(() => ({ vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ settings: {}, tabsByWorktree: {} }) + getState: () => ({ settings: {}, tabsByWorktree: {}, ptyIdsByTabId: { 'tab-1': ['pty-1'] } }), + subscribe: () => () => {} } })) @@ -95,6 +96,45 @@ describe('pty-bound agent draft readiness budget', () => { expect(onUnconfirmedDelivery).not.toHaveBeenCalled() }) + it.each(['opencode', 'opencode2'] as const)( + 'waits for the %s agent row only when Enter follows the paste', + async (agent) => { + const promise = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: 'task', + agent, + submit: true, + forcePaste: true + }) + + await vi.advanceTimersByTimeAsync(12_000) + + await expect(promise).resolves.toBe(true) + expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row', {}) + } + ) + + it.each([ + ['opencode', true, 'opencode-agent-row'], + ['opencode2', true, 'opencode-agent-row'], + ['opencode', false, 'render-cursor-after-bracketed-paste'], + ['opencode2', false, 'render-cursor-after-bracketed-paste'] + ] as const)('a tab-bound %s paste with submit %s waits on %s', async (agent, submit, signal) => { + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: 'task', + agent, + submit, + forcePaste: true + }) + + await vi.advanceTimersByTimeAsync(12_000) + + await expect(promise).resolves.toBe(true) + expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, signal, {}) + }) + it('flags a blind paste when only the opencode process, not its composer, was seen', async () => { // Regression (#22479): ConPTY never forwards DECSET 2004, so on Windows this is the only // path opencode can take. Callers must be able to tell it apart from a real delivery. diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index de115c10f45..f0fc547e358 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -4,6 +4,7 @@ import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { AGENT_PROMPT_POST_PASTE_SUBMIT_DELAY_MS } from '../../../shared/agent-prompt-injection' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' +import { resolvePasteReadySignal } from '../../../shared/draft-paste-ready-scanner' import { useAppStore } from '@/store' import { inspectRuntimeTerminalProcess, @@ -99,7 +100,7 @@ export async function pasteDraftWhenAgentReady(args: { return false } - const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const readySignal = resolvePasteReadySignal(agentConfig, submit === true) const settings = getSettingsForAgentTabRuntimeOwner(tabId) const readinessTimeoutMs = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) const readiness = await waitForAgentDraftInputReadyOnTab({ @@ -127,9 +128,9 @@ export async function pasteDraftWhenAgentReady(args: { onTimeout?.() return false } - // Why: the process merely exists -- its composer was never observed. On Windows this is - // the ONLY path: ConPTY does not forward DECSET 2004, so no 2004-anchored ready signal - // can ever fire. Callers must be able to tell this blind write apart from a real delivery. + // Why: the process merely exists -- its composer was never observed (e.g. the readiness + // budget expired mid-startup, #22479). Callers must be able to tell this blind write apart + // from a real delivery. onUnconfirmedDelivery?.() } @@ -173,7 +174,7 @@ export async function pasteDraftToAgentPtyWhenReady(args: { } const settings = getSettingsForAgentTabRuntimeOwner(tabId) - const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const readySignal = resolvePasteReadySignal(agentConfig, submit === true) const budget = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings) if (!ready) { diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index feaa40fa260..9383034064b 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' +import { createDraftPasteReadyScanner, resolvePasteReadySignal } from './draft-paste-ready-scanner' +import { OPENCODE_AGENT_ROW_GRACE_MS } from './opencode-agent-row-scanner' +import { TUI_AGENT_CONFIG } from './tui-agent-config' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const DECRST_BRACKETED_PASTE = '\x1b[?2004l' @@ -120,6 +122,135 @@ describe('createDraftPasteReadyScanner', () => { }) }) + describe('opencode-agent-row', () => { + // Frame shapes from the OpenCode 2.0.21 cold-start capture: the box with its bottom-left + // corner `╹` on row 25, then a later frame painting ` · ` on row 24 above it. + const frame = (body: string): string => + `\x1b[?2026h${HIDE_CURSOR}${body}\x1b[22;27H${SHOW_CURSOR}\x1b[?2026l` + const BOX = `${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${frame( + '\x1b[21;24H┃\x1b[22;24H┃\x1b[23;24H┃\x1b[24;24H┃\x1b[25;24H╹' + )}` + const AGENT_ROW = frame('\x1b[24;27HBuild\x1b[24;33H\u00b7\x1b[24;35HSome Model') + const GRACE = { ready: false, armQuietTimer: false, readyAfterMs: OPENCODE_AGENT_ROW_GRACE_MS } + + it('is not ready on the input box and its cursor alone, and asks for the grace timer', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX)).toEqual(GRACE) + }) + + it('is ready once the row directly above the box corner paints its separator', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + scanner.observe(BOX) + expect(scanner.observe(AGENT_ROW)).toEqual({ + ready: true, + armQuietTimer: false, + readyAfterMs: null + }) + }) + + it('is ready when OpenCode 1 paints the row before the corner in the same frame', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + const opencode1Box = frame('\x1b[23;27HBuild\x1b[23;33H\u00b7\x1b[24;24H╹') + expect( + scanner.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${opencode1Box}`).ready + ).toBe(true) + }) + + it('pairs a separator with a later corner only inside the same synchronized frame', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + const separatorFrame = frame('\x1b[23;33H\u00b7') + const cornerFrame = frame('\x1b[24;24H╹') + expect( + scanner.observe( + `${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${separatorFrame}${cornerFrame}` + ).ready + ).toBe(false) + }) + + it('ignores a separator in the footer path under the box', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + scanner.observe(BOX) + expect(scanner.observe(frame('\x1b[26;24H~/col\u00b7lecció/work'))).toEqual(GRACE) + }) + + it('ignores a separator in a session tab title', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + scanner.observe(BOX) + expect(scanner.observe(frame('\x1b[1;4Hx\x1b[1;5H\u00b7\x1b[1;6Hy'))).toEqual(GRACE) + }) + + it('ignores a separator a shell prompt draws before OpenCode enters the alternate screen', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect( + scanner.observe( + `${DECSET_BRACKETED_PASTE}~ \u00b7 main % opencode${DECRST_BRACKETED_PASTE}` + ) + ).toEqual({ ready: false, armQuietTimer: false, readyAfterMs: null }) + expect(scanner.observe(BOX).ready).toBe(false) + }) + + it('does not keep a row from an earlier alternate-screen session', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX + AGENT_ROW).ready).toBe(true) + expect(scanner.observe(ALT_SCREEN_LEAVE).ready).toBe(false) + expect(scanner.observe(BOX)).toEqual(GRACE) + }) + + it('does not latch a part an earlier session established and then revoked', () => { + // The review probe's shapes: another alternate-screen app drew a separator and left; a shell + // showed its cursor under bracketed paste and turned it off. + const leftApp = createDraftPasteReadyScanner('opencode-agent-row') + leftApp.observe(`${ALT_SCREEN_ENTER}x \u00b7 y${ALT_SCREEN_LEAVE}`) + expect(leftApp.observe(BOX)).toEqual(GRACE) + const shell = createDraftPasteReadyScanner('opencode-agent-row') + shell.observe(`${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}${DECRST_BRACKETED_PASTE}`) + expect( + shell.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}footer \u00b7 path`).ready + ).toBe(false) + }) + + it('withdraws the grace when OpenCode leaves the alternate screen before its row', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX)).toEqual(GRACE) + const shellPrompt = `${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}` + expect(scanner.observe(`${ALT_SCREEN_LEAVE}${shellPrompt}`)).toEqual({ + ready: false, + armQuietTimer: false, + readyAfterMs: null + }) + // A later OpenCode start in the same pane gets the grace again. + expect(scanner.observe(BOX)).toEqual(GRACE) + }) + + it('withdraws readiness and the grace timer when bracketed paste is turned off', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX + AGENT_ROW).ready).toBe(true) + expect(scanner.observe(DECRST_BRACKETED_PASTE)).toEqual({ + ready: false, + armQuietTimer: false, + readyAfterMs: null + }) + const graced = createDraftPasteReadyScanner('opencode-agent-row') + expect(graced.observe(BOX).readyAfterMs).toBe(OPENCODE_AGENT_ROW_GRACE_MS) + expect(graced.observe(DECRST_BRACKETED_PASTE).readyAfterMs).toBeNull() + }) + + it('needs the box cursor shown while bracketed paste is held, not one a shell showed', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + scanner.observe(`${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}${DECRST_BRACKETED_PASTE}`) + const rowWithoutCursor = '\x1b[25;24H╹\x1b[24;33H\u00b7' + expect( + scanner.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${rowWithoutCursor}`).ready + ).toBe(false) + }) + + it('never arms the quiet window', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX).armQuietTimer).toBe(false) + expect(scanner.observe('more frames').armQuietTimer).toBe(false) + }) + }) + describe('codex-composer-prompt', () => { it('is ready on the composer glyph after bracketed paste and never arms the quiet timer', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') @@ -344,3 +475,20 @@ describe('createDraftPasteReadyScanner', () => { }) }) }) + +describe('resolvePasteReadySignal', () => { + it.each(['opencode', 'opencode2'] as const)( + '%s: only a paste that Enter follows waits for the agent row', + (agent) => { + expect(resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], false)).toBe( + 'render-cursor-after-bracketed-paste' + ) + expect(resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], true)).toBe('opencode-agent-row') + } + ) + + it('falls back to the draft signal, then the quiet window', () => { + expect(resolvePasteReadySignal(TUI_AGENT_CONFIG.codex, true)).toBe('codex-composer-prompt') + expect(resolvePasteReadySignal(null, true)).toBe('render-quiet-after-bracketed-paste') + }) +}) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index f95d04b4a85..bcda84ef185 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -1,4 +1,5 @@ -import type { DraftPasteReadySignal } from './tui-agent-config' +import type { DraftPasteReadySignal, TuiAgentConfig } from './tui-agent-config' +import { createOpenCodeAgentRowScanner } from './opencode-agent-row-scanner' // Why: agents enable bracketed paste (DECSET 2004) before their composer is // actually mounted/focused. These markers let the scanner detect the real @@ -40,7 +41,18 @@ type DraftPasteReadySignalSpec = { quietAnchor: string | null } -const DRAFT_PASTE_READY_SIGNALS: Record = { +// Signals that read the screen's structure, not one marker; a Record so none lacks its scanner. +type StructuralSignal = 'opencode-agent-row' +const STRUCTURAL_SCANNERS: Record< + StructuralSignal, + () => { observe: (data: string) => { ready: boolean; readyAfterMs: number | null } } +> = { + 'opencode-agent-row': createOpenCodeAgentRowScanner +} + +type SingleSignal = Exclude + +const DRAFT_PASTE_READY_SIGNALS: Record = { 'codex-composer-prompt': { markerAnchor: DECSET_BRACKETED_PASTE, markerAnchorEnd: null, @@ -111,6 +123,22 @@ export type DraftPasteReadyScanResult = { ready: boolean /** Caller should (re)arm the quiet-window fallback timer for this chunk. */ armQuietTimer: boolean + /** Ready after this many ms unless the signal fires first; null withdraws an armed one. */ + readyAfterMs?: number | null +} + +export type DraftPasteReadyScanner = { observe: (data: string) => DraftPasteReadyScanResult } + +/** The signal to wait for before a paste; one that Enter follows waits for the submit signal. */ +export function resolvePasteReadySignal( + agentConfig: Pick | null, + submit: boolean +): DraftPasteReadySignal { + return ( + (submit ? agentConfig?.submitPasteReadySignal : undefined) ?? + agentConfig?.draftPasteReadySignal ?? + 'render-quiet-after-bracketed-paste' + ) } /** @@ -156,13 +184,34 @@ export type DraftPasteReadyScanResult = { * mounted — so the quiet window alone never settles and a launch draft would wait out * the whole hard timeout, exactly as grok did. Same alt-screen anchoring and * revocation as grok, because a powerline shell prompt can draw `╭` too. + * - `opencode-agent-row`: OpenCode's submit signal (see opencode-agent-row-scanner.ts): the + * box's show-cursor as above plus the agent/model row painted directly above the box's + * bottom corner. OpenCode 2 draws the box before its agent list loads and drops an Enter + * sent in between. No quiet window; while the box shows without the row it asks for a + * grace timer (`readyAfterMs`) instead. * - `render-quiet-after-bracketed-paste` (default): no signal marker; arms the * quiet window once DECSET 2004 is seen. * * A 512-byte ring (`recent` / `postAnchorRecent`) covers escape sequences * split across chunk boundaries without retaining terminal scrollback. */ -export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal): { +export function createDraftPasteReadyScanner( + readySignal: DraftPasteReadySignal +): DraftPasteReadyScanner { + if (isSingleSignal(readySignal)) { + return createSingleSignalScanner(readySignal) + } + const scanner = STRUCTURAL_SCANNERS[readySignal]() + return { + observe: (data) => ({ ...scanner.observe(data), armQuietTimer: false }) + } +} + +function isSingleSignal(signal: DraftPasteReadySignal): signal is SingleSignal { + return !(signal in STRUCTURAL_SCANNERS) +} + +function createSingleSignalScanner(readySignal: SingleSignal): { observe: (data: string) => DraftPasteReadyScanResult } { let recent = '' @@ -298,7 +347,7 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) } // Why: the Codex glyph and opencode show-cursor signals must NOT arm the // quiet window (they carry no quiet anchor). opencode goes silent for - // Up to ~3.9s between enabling bracketed paste and mounting its composer, so a + // up to ~3.9s between enabling bracketed paste and mounting its composer, so a // quiet window would fire during that gap — before the composer exists — // and pre-empt the marker. Those signals wait for their marker, bounded // only by the caller's hard timeout (and its best-effort diff --git a/src/shared/opencode-agent-row-scanner.ts b/src/shared/opencode-agent-row-scanner.ts new file mode 100644 index 00000000000..ad5050031a5 --- /dev/null +++ b/src/shared/opencode-agent-row-scanner.ts @@ -0,0 +1,167 @@ +/** + * 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. + * + * Read off the stream by structure, never by names or colours: the row's separator `·` (U+00B7) + * sits on the line directly above the box's bottom-left corner `╹` (U+2579), both placed by + * absolute cursor addressing inside OpenCode's alternate screen. The footer path below the box and + * the session tab strip above it can hold `·` too; neither is on that line. OpenCode 2 paints the + * corner first and the row in a later frame; OpenCode 1 paints the row just before the corner in + * the same synchronized frame, so a separator waits for its corner until that frame ends. + * + * Ready once the row exists and the box's cursor is shown while bracketed paste is held (the + * earlier box rule), so neither version is delivered to sooner than before. Leaving the alternate + * 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. +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. */ + ready: boolean + /** Box shown, no row yet: ready after this many ms unless the row comes first; null withdraws. */ + readyAfterMs: number | null +} + +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 + let bracketedPaste = false + let boxCursorShown = false + let row: number | null = null + let boxBottomRow: number | null = null + let rowPainted = false + // Separator rows seen in the open synchronized frame, for OpenCode 1's row-before-corner order. + let framePendingRows = new Set() + + const resetLayout = (): void => { + row = null + boxBottomRow = null + rowPainted = false + framePendingRows = new Set() + } + + const onPrivateMode = (mode: string, set: boolean): void => { + if (mode === '1049') { + altScreen = set + resetLayout() + if (!set) { + leftAltScreen = true + boxCursorShown = false + } + } else if (mode === '2004') { + bracketedPaste = set + if (!set) { + boxCursorShown = false + } + } else if (mode === '25' && set && bracketedPaste) { + boxCursorShown = true + } else if (mode === '2026') { + framePendingRows = new Set() + } + } + + const onCsi = (privateMarker: string, params: string, final: string): void => { + if (privateMarker === '?' && (final === 'h' || final === 'l')) { + for (const mode of params.split(';')) { + onPrivateMode(mode, final === 'h') + } + } else if (final === 'H' || final === 'f') { + row = Math.max(1, Number(params.split(';')[0] || '1')) + } else if (final === 'J' && params === '2') { + boxBottomRow = null + framePendingRows = new Set() + } + } + + const onText = (text: string): void => { + if (!altScreen || row === null) { + return + } + for (const char of text) { + if (char === '\n') { + row += 1 + } else if (char === AGENT_ROW_SEPARATOR) { + if (boxBottomRow === row + 1) { + rowPainted = true + } else { + framePendingRows.add(row) + } + } else if (char === BOX_BOTTOM_LEFT) { + boxBottomRow = row + if (framePendingRows.has(row - 1)) { + rowPainted = true + } + } + } + } + + 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 + } + // Leaving the alternate screen clears rowPainted, so it only holds while OpenCode is drawn. + const graceApplies = boxCursorShown && !rowPainted && (altScreen || !leftAltScreen) + return { + ready: rowPainted && boxCursorShown, + readyAfterMs: graceApplies ? OPENCODE_AGENT_ROW_GRACE_MS : null + } + } + } +} + +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/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index c340cc22b1d..587d4df84d7 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -6,7 +6,8 @@ export type AgentPromptInjectionMode = | 'hermes-query' | 'stdin-after-start' -export type DraftPasteReadySignal = +/** Signals a single marker settles; draft paste and the serializer read only these. */ +export type DraftPasteMarkerSignal = | 'render-quiet-after-bracketed-paste' | 'codex-composer-prompt' | 'render-cursor-after-bracketed-paste' @@ -14,6 +15,9 @@ export type DraftPasteReadySignal = | 'dsh-composer-prompt' | 'zcode-composer-prompt' +/** Submit signals may also ask for a grace (`readyAfterMs`); only the paste waiters honour it. */ +export type DraftPasteReadySignal = DraftPasteMarkerSignal | 'opencode-agent-row' + export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl' export type TuiAgentConfig = { @@ -40,10 +44,12 @@ export type TuiAgentConfig = { /** Trust Orca pre-writes at PTY spawn (agent-workspace-trust.ts) so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste. */ preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' | 'qoder-cn' /** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */ - draftPasteReadySignal?: DraftPasteReadySignal + draftPasteReadySignal?: DraftPasteMarkerSignal + /** Signal before a paste that Enter follows, for agents that show their composer before it can submit; defaults to `draftPasteReadySignal`. */ + submitPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number - /** Captured boots proving the composer marker used for fresh worker dispatch. */ + /** Captured boots proving the composer marker used for fresh worker dispatch (the submit signal where set). */ composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 24bf46f16c4..8e94ce870fe 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -4,6 +4,7 @@ import type { TuiAgentConfig } from './tui-agent-config-types' export type { AgentPromptInjectionMode, + DraftPasteMarkerSignal, DraftPasteReadySignal, TuiAgentConfig, TuiAgentDetectionRuntime @@ -93,6 +94,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'flag-prompt', // Why: opencode enables bracketed paste before its composer mounts; wait for the post-\x1b[?2004h show-cursor so paste lands. draftPasteReadySignal: 'render-cursor-after-bracketed-paste', + // Why: OpenCode 2 draws its input box before its agent list loads and drops an Enter sent + // in between; the agent/model row under the box is the first moment it can submit. + submitPasteReadySignal: 'opencode-agent-row', // Why 20s: measured on two Windows hosts (ConPTY dll backend, as pinned by // local-pty-utils), opencode does not enable bracketed paste until ~4.8s and its // composer is not ready until ~10s — so the 8s default expired first and the draft @@ -102,19 +106,26 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { composerReadyCaptures: [ 'opencode-1-18-32-timed-boot-slow', 'opencode-1-18-32-timed-boot-hidden-pane', - 'opencode-1-18-32-timed-first-launch' + 'opencode-1-18-32-timed-first-launch', + 'opencode-cmd-2-0-21-timed-warm-server' ] }, - // Why: opencode2 installs as a separate binary and uses the same prompt flags. - // Its @opentui composer keeps the same cursor-gated paste signal. + // Why: opencode2 installs as a separate binary and uses the same prompt flags and paste signals. opencode2: { detectCmd: 'opencode2', // The private server inherits this pane's hook endpoint and identity. launchCmd: 'opencode2 --standalone', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', + submitPasteReadySignal: 'opencode-agent-row', draftPasteReadyTimeoutMs: 20_000, - composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'] + composerReadyCaptures: [ + 'opencode-2-0-18-timed-boot-hidden-pane', + 'opencode-2-0-21-timed-cold-standalone', + 'opencode-2-0-21-timed-cold-standalone-hidden-pane', + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode-2-0-14-timed-cold-standalone' + ] }, 'mimo-code': { detectCmd: 'mimo',