diff --git a/.omp-sweep-heads.txt b/.omp-sweep-heads.txt new file mode 100644 index 000000000..098923b91 --- /dev/null +++ b/.omp-sweep-heads.txt @@ -0,0 +1,121 @@ +3864 5c503ebfeb1e7fb4685785084d1cc13538c6bffc +4498 7795d12f60296a76645b8ccd0d096424bd9bb9a3 +6187 c8fb3fb6ed6a7cbde7b37bdbfe45c354d593a575 +6475 160149467fe3a71fe8f91dda0b6e53ef76fc3778 +6476 9de8694c3f8c0a13aa3715d164190386e491ddc7 +6548 8ed9646c255092f8b9d9dbd5cdebbf07f05c85b2 +6632 81a84e6fa6f048442bf2b7cad839556ab53bde1b +6949 d921ab104ed5dc1f15a9e20ce7fd6b8c9ed468a6 +7210 19b491b83f27678b8d3a8d83bb40baa1db316c36 +7492 0d51dd17dce766dcbabc314aad1f282a3925a0e8 +7521 5ae1216994c9a6216a28b2209a7660f8ffec6dc1 +7560 47701e97806fc0588283a222b84317a496ae7935 +7585 6a49b8171bc655e4e88d9f01bfed74610e371830 +7620 19713e4c2d148d352d5d7135200e66eeb320f2ec +7682 787497984861ebcece1314176ad02d40c2294b7b +7683 323be8815d2d761698ef7fccb508c2af580e39af +7685 d5c62c1f08a8385c10cb7d010742cb6a6aefa8fc +7687 a084a7ef9d07f6c7505ee7933bda60558906ee43 +7913 73e3f5a74dbd6792c53b95d567f4f0041124843a +7938 65b4604285473fea129952af2fc9b153e972ff2a +7977 8be73c6cb23acd9dd58b78cb379d77d5b92356cd +7992 8fad15fdfd6037e7eaf6c35a1caf08010f2ea4d1 +8016 c45241bb07751c47d03559cb7a05d88228106b94 +8040 9e87d432bd8d3eb7908bd978620f474a832f3acb +8049 2fa1c0f3b7d233e940ad07b4422d566f92de8025 +8067 cd0050a62e9ad8480491f238a5d9e58eb450c955 +8068 7b6548f182ca2f0e1ad7b9dde31e12a021a21f28 +8072 4cc9725037b3d8ae005eb70acb5045e21d6d5cfd +8076 121bcb366373047caf6a913c6ee089092ce35fe9 +8080 ffd9d5c8ae3683297e71a93b30f1abe93e32268d +8083 1604ad644dba9d62d9358f5d5a99f5f6aa3f2465 +8085 fd78aa1abc233798119d449977d9fa8bd6d8bd09 +8088 254057b75c9b23d172e218deeeb012dfd3f00185 +8090 1910177879924353bcbb61948aafd076fd84d743 +8092 ebe04143124727c23ebff03f319676a5c72b592d +8094 8823e91585973927335db27af7a2645c7ed432bb +8097 a67da14e4e1bef3b37b3e742e5a815ff803d230a +8101 5d92336e198a7d2317bf758ced06961f0b38564c +8106 c77dcb1dd7b7004db9903ad8de43019a6aa45772 +8108 aea961ef5db6c0b71a815626a0b18f648cd56123 +8109 f0726d29dd9798fcfc5a190e0be14869a8fa44b2 +8122 39920d73a1b97fff332eb7506b96a2cb6f63c691 +8123 dc47d73e9c28d545e44cedc34563a9b11c508ec4 +8124 6d17c8532319a491c26a1a1f0b4c2bb9c5c7c98d +8125 4fcab2c7896efcc4a02c78f471985847d575114a +8128 836ee9f12018b9fd6bb36cb70732198176773f10 +8129 7c29b725ee330a5ed3c82d874d853f231c5d2e13 +8135 492cf010f1c4a1a3831575ca4e3a6e4389e18fa9 +8136 6043e55932695d1bdcdcc27267473f538095d5c8 +8138 af31ae48853ddd52566f925be1474a669a673aef +8144 05aea7e8b73859fde8e50302f5d9e4b44044eb22 +8151 7681935f5762812f96522e737b61792ee69f3dc0 +8155 bfd0f339222813729a3f965c4533433f0244c443 +8157 a0833ddcf48600c72e0f6e4c1ed5aa167553d760 +8159 2a0774fafd33e5c970fc2d1b3a7abb96ee8d2dca +8163 1fa80a89ad0629a6b8191cab044ae96915875ce6 +8166 33848c9d7762889eccbf488ab7b9ad147ea7e65b +8167 1268015ae1a4c76e93d193a071cb4a97aea83ce6 +8169 1451f9a1b9be4618cd506a8a1b3223b525c9efc1 +8170 60abf7ed32fdea918115539bf029ac5458e9ace7 +8180 be257b24ee31b419f607b2fc13ca2bbd27796398 +8182 78d7327841f9bb8b48c2c96d46771627ef79a993 +8185 ec411d60b6837590777a9f3adfae17aebe11e504 +8186 1f2fca50352e61f3ad77ca0a1287bf5b38be4c03 +8187 6479649b1827f5bd65baa8c44a88b436a60c8567 +8196 3cbb13d31dd36179ef0863106f3a2f7bd37314be +8199 8b8e1931a42cb50fdb02ebd3699ed578d2b2594f +8202 f59a966aab0b42d0ea4701a8f4f30ec81c7b47ca +8204 6153f8ad217c6a036af77c682e2707243a5402ce +8211 0d6889e1341d2de21288baec580b24e6f3c0a9a9 +8215 e3e5a9d2acd31e5fd2bbaf2d3831b94f04c12b65 +8221 dc12c4c5243f50547288bc042a163c965fc8cf46 +8225 1d27b47c058a2156989f436870b709b554a1458c +8232 49e45a08300f1ed454f5d87e390c62f996a9b36c +8236 133e42e34b727991d662786dc4103c7e71547357 +8237 b5078b2cea35b9d9fdb9b1b58457f1c1c9b8413e +8242 8c9d26c82d6916ecc7de6acb2451abe6a8f68853 +8244 f13ee010d038d6ebe876b9d4fad7b29e0e988198 +8245 ce293f5c511fa85e8d0d8afb92755b8a8f1e0af2 +8253 a7c23c7187aae8c5b94262ae33acd05786178c1a +8258 13448c0edbb271903f62d65b5d2fb1d75f6413f0 +8263 281c612ec1bbc93fd73b29416cf10ad1b6988d60 +8265 b245be11c51cb3c7c5e5e491d61407d368aa7351 +8266 d1741f04e66bc4fb47fc1634d26721cf2472c01f +8270 e087fefb75feac45ede6b4774fd85d8eac27cfe4 +8274 c87895bd5dcdbf845f63d3f92095322ad7a4cc71 +8275 0380f25b8c247b8abede9cdc1cbc68aa66f3e0f5 +8277 99e8322424f05601a62599831fe7e3abfcb4d68a +8283 a224dedc0da7dac4f81a5abdf936d6cc4c5b4442 +8284 f9d647b31ea0aca4e4506513749fa8c8ab2ba1fb +8285 89186c006815b8e14c647fd8ae3ba262eb02ca7f +8286 2c7c421d15833986bef87431dcbd89d968652f46 +8288 80a02d6f242e5911fdaf4d6f22f052469ff0299c +8301 b498f7e8de32f8a1fc70d46bdd96dd29eca3b613 +8304 88bbd031b2230fc51ebc0ab38a953cefc722e4e8 +8307 6ad4b02a8e4171a25f0a00ef397aa1b0d444f735 +8309 bb5d00667f057e5cbd2a736be1d49896a9e6a6ec +8313 3d35bda89d247b86dc550a784b2d12772a297043 +8314 9c0432addd8d5d4e2811ee063f380060296da906 +8316 75a233a0001d9a3ec710e8d6f2f40f6ac4d43a4a +8317 100d2ef54787571459d3e896a175bb8df910273e +8319 0a679284d871d1c3f3b4be6610ca10cce764e13e +8320 4af147cd42ef8d326e89254fdc4844d46f6c9acf +8325 d4832c8c047d473c90f3736073e15df3b5d9b401 +8327 a69ce173986dcd2df37ce48463ff4d2e1592e0c8 +8329 adb9928dbb2cfa61ba1f83984a55d65ab8efeb68 +8330 86862b5739533b75d67f25c77d6ae00df87b50ff +8332 c1ffdca35151afc7ad447cda234d57c91d707d8c +8333 62ccb64dbe3fa9d1cd99774e81ba0c976b27b223 +8335 28115cfc1f95c7a53972a964bcadac82d24e9bd0 +8336 6a419f6abeb198d53958a1a77438695ff7af296f +8337 990984f19bac779f2bef2976c6b0c0068ebcd4dd +8338 89ac581ab12355fff6fed9354b9dec02330c1ca5 +8339 8be57a0ce61573ee2e8f2f949e4ca1a39f5aca0b +8343 67f9d510e433f326ef9e237f9b200709a76e81ce +8344 ccc98019fa8b78b833b708a6a4c8a9be8f29de1d +8348 830d0a4deacc214fbb1bf4058f54f8576fa98e6a +8352 eacde4852e85101b0924e53f4a5314d4c3006037 +8356 41684f178595194af3de4eeb3351ace015c621b1 +8357 732986aeaffb9d4fcfd052e7d2e9e583d6f8fe36 +8359 753b7e7b15937e7bb7241b44858df364c6fccab1 diff --git a/.omp-sweep-tally.md b/.omp-sweep-tally.md new file mode 100644 index 000000000..03c138e9a --- /dev/null +++ b/.omp-sweep-tally.md @@ -0,0 +1,125 @@ +# Sweep tally — baseline 06aecdd51f07e689e970ceaa180abe2be0c14bbb + +- pr #3864: REJECT — one-turn-lag policy + structural conflicts; owner call +- pr #4498: REJECT [owner-may-reconsider] — new LongCat provider; works but stale branch, structural conflicts, catalog drift; needs rebase+regen if accepted +- pr #6187: REJECT — fix does not pass its own repro on head +- pr #6475: REJECT — regresses equal-height settlements + typed opt-in missing +- pr #6476: REJECT — incomplete: hashline writeText still drops surrogates; 5-file structural conflicts +- pr #6548: REJECT — new setting + Gemini tool-guidance loss + revival gap +- pr #6632: REJECT — unused helper, no consumer, issue NOT_PLANNED +- pr #6949: REJECT — drops provenance / nested-fence bugs +- pr #7210: REJECT — docs contradict quote-aware matcher; security preset tiers = product call +- pr #7492: REJECT — no caller, wontfix motivation, regresses Windows PID safety +- pr #7521: REJECT — focus-steal + sentinel bypass + count-0 no-op +- pr #7560: REJECT [owner-may-reconsider] — new setting + default change for nested isolation; schema doc mismatch +- pr #7585: REJECT — refactor churn that breaks public export + changes tilde semantics + missed callers +- pr #7620: REJECT — UX default change + timeout fabricates answers; structural conflicts +- pr #7682: REJECT — manifest hygiene, no value +- pr #7683: REJECT — superseded: targeted vendored crates deleted by builtins unification +- pr #7685: REJECT — cleanup that breaks public wrap API + structural conflict +- pr #7687: REJECT — cosmetic refactor churn; no behavioral value +- pr #7913: REJECT — superseded by ed820703a7 + 706371bb91 on main +- pr #7938: REJECT — unreproduced + bundles Windows-wide cwd policy change +- pr #7977: REJECT [owner-may-reconsider] — useful cost cap but new settings + truncation policy + branch hygiene issues +- pr #7992: REJECT — fix real but regex CJS scan regresses valid ESM (proven top-level-await failure) +- pr #8016: MERGE — fix env-dependent session-manager test failure; clean +- pr #8040: MERGE — O(N^2) streaming hot paths; cherry-pick 1ee1ca5874 +- pr #8049: MERGE — arktype command API compat; cherry-pick f357f298ad +- pr #8067: REJECT [owner-may-reconsider] — manual /shake retention policy change +- pr #8068: MERGE — fit-check retry fallback; cherry-pick 6e268b5c7d +- pr #8072: MERGE — scrollback across mux width changes; clean; large but verified +- pr #8076: MERGE — advisor fallback role ownership (fixes #8075); clean +- pr #8080: MERGE — forward retry fallback events to extensions (fixes #8079); clean +- pr #8083: REJECT — not reproducible on baseline 50/50 runs +- pr #8085: REJECT [owner-may-reconsider] — 5-7x default reflect budget + new public setting; policy call +- pr #8088: MERGE — CMAKE_POLICY_VERSION_MINIMUM for audiopus_sys (fixes #8219); clean +- pr #8090: REJECT — disproportionate convenience + partial-failure state divergence +- pr #8092: REJECT — glob-matching defect + doc mismatch +- pr #8094: REJECT — new extension API + trust-model change; owner call +- pr #8097: MERGE — actionable auth-broker startup error (fixes #8096); clean +- pr #8101: MERGE — mermaid edge-label masking; test-file conflict (union both tests) +- pr #8106: MERGE — OpenCode JSONC configs (fixes #8104); cherry-pick f57af65e8d +- pr #8108: REJECT — 9.5k-line persona feature + spawn-policy defect + conflicts +- pr #8109: REJECT [owner-may-reconsider] — opt-in third-party BES/cache endpoint; ownership/privacy call +- pr #8122: MERGE — rearm broker idle shutdown (fixes #8110); clean +- pr #8123: MERGE — reject cold revive after lifecycle dispose (fixes #8114); clean +- pr #8124: MERGE — close stale initial MCP connections (fixes #8112); clean +- pr #8125: MERGE — drain runInTerminal stdout (fixes #8111); cherry-pick d2381a978b +- pr #8128: MERGE — /ssh add hint scope; clean +- pr #8129: REJECT — RBE infra: cross-host toolchain broken + credential custody +- pr #8135: MERGE — responsive transcript rebuilds (fixes #8133); test-file conflict; cherry-pick ac8d577a0d +- pr #8136: MERGE — ship omp:// docs to npm consumers (fixes #8134); cherry-pick 57dc8d8a5e +- pr #8138: MERGE — inline /skill in /plan|/vibe (fixes #8137); STRUCTURAL conflicts (interactive-mode, builtin-modes); cherry-pick d4bcb7a83c; careful resolution +- pr #8144: MERGE — legacy TypeBox unsafe schemas + install validation (fixes #8143); cherry-pick 0b36991a5c +- pr #8151: REJECT — changes abort-on-close request contract (#8113 wontfix) + structural conflict +- pr #8155: REJECT — default UX/theme change + numeric-0 theme token defect +- pr #8157: MERGE — perplexity OTP cookie replay (fixes #8156); clean +- pr #8159: REJECT [owner-may-reconsider] — correct but silently re-scopes mixed-case hindsight banks +- pr #8163: MERGE — restore legacy shim guards (fixes #8161); clean +- pr #8166: MERGE — collab relay self-hosting docs (fixes #8165); clean +- pr #8167: REJECT [owner-may-reconsider] — MCP 2026-07-28; works but 2.5k lines, new setting, stale vs SSE resumption +- pr #8169: MERGE — strip snapcompact archive from compact result (fixes #8168); cherry-pick 52354650df +- pr #8170: REJECT — superseded Muse fix + unsafe substring window fallback + entangled commits +- pr #8180: REJECT — superseded by runner-cache restage on main +- pr #8182: REJECT — superseded: baseline already fixes dry-run; PR version buggier +- pr #8185: MERGE — wire-name alias resolution restores apply_patch preview (fixes #8184); clean +- pr #8186: REJECT [owner-may-reconsider] — ty as built-in Python LSP; works end-to-end, default-selection call +- pr #8187: REJECT — incomplete: chat-redirect abandonment leaks ask/goal tools + structural conflicts +- pr #8196: REJECT — 52k-line protocol surface + parity defects +- pr #8199: MERGE — gate prompt tool mentions; STRUCTURAL conflicts in 4 prompt files +- pr #8202: MERGE — array model overrides in dashboard (fixes #8201); clean +- pr #8204: REJECT — new provider + broken toggle-only efforts + catalog churn +- pr #8211: REJECT — incomplete: mixed-source project settings revert after reload +- pr #8215: REJECT — incomplete: footer paint still crashes; entangled OSC133 slice already on main +- pr #8221: REJECT [owner-may-reconsider] — pasted-image preview; works, new setting/UX surface +- pr #8225: REJECT — large capability; hook-ordering + resume defects +- pr #8232: REJECT [owner-may-reconsider] — advisor CLI flags; works, new CLI contract + structural conflicts +- pr #8236: MERGE — standalone profile aliases resolve to omp (fixes #8233); clean; no eval commits +- pr #8237: REJECT [owner-may-reconsider] — ask in vibe mode; default toolset change + structural conflicts +- pr #8242: REJECT [owner-may-reconsider] — hideSkills setting; works, product call +- pr #8244: REJECT — fix real but entangled with 927-line unrelated models.json refresh +- pr #8245: REJECT — incomplete: suppresses diagnostics, loses thinking suffix, bundles models-listing behavior change +- pr #8253: MERGE — force refresh command-backed api key on 401; clean +- pr #8258: REJECT [owner-may-reconsider] — new public setting + accounting change; #8251 unresolved; async undercount fix worth splitting +- pr #8263: MERGE — answerless hosted search; clean; cherry-pick 51bd63e6af +- pr #8265: REJECT [owner-may-reconsider] — UX default change + false footer hint +- pr #8266: MERGE — false reset fireworks; clean; no eval commits +- pr #8270: REJECT [owner-may-reconsider] — approval notify; works, default-on behavior call +- pr #8274: REJECT [owner-may-reconsider] — permanent promo copy, no opt-out; owner may reconsider +- pr #8275: REJECT — default-on UX policy + prompt-only gate + Python API loss +- pr #8277: REJECT — incomplete: unbounded shake retries + violates autoContinue=false +- pr #8283: REJECT [owner-may-reconsider] — fix real but breaks message_end mutation contract +- pr #8284: MERGE — release provider permit before completion; clean +- pr #8285: REJECT — no baseline repro; test passes on baseline too +- pr #8286: REJECT [owner-may-reconsider] — key bundles; works, new auth/security surface +- pr #8288: MERGE — Windows open browser windowsHide; clean +- pr #8301: REJECT — product change + stack overflow + export leaks +- pr #8304: REJECT [owner-may-reconsider] — Daybreak models; correct but catalog/product call + unrelated commit +- pr #8307: MERGE — qwen3.8-max DashScope images; clean; no eval commits +- pr #8309: REJECT [owner-may-reconsider] — new DeepInfra provider; works, but provider adoption is owner call +- pr #8313: MERGE — suppress circular plan-yolo note (fixes #8312); clean; no eval commits +- pr #8314: REJECT — default-behavior change + shell-selection bypass + completion defects +- issue #8315: REJECT — existing PR #8317 +- pr #8316: MERGE — MCP config source in failures; clean; cherry-pick 3ee2827e2b +- pr #8317: REJECT [owner-may-reconsider] — real p1 startup-hang fix (#8315) but universal 10s discovery default = policy call; recommend accept +- pr #8319: REJECT — incomplete: wrapLeakedThinkingStream path still drops prefix +- pr #8320: MERGE — OSC66 heading rows on repaint (fixes #8318); clean; cherry-pick c7036daf23 +- pr #8325: MERGE — xAI omitted creditUsagePercent; clean; cherry-pick f799e499c8 +- pr #8327: REJECT — incomplete: IRC aside resume path still races vibe teardown +- pr #8329: REJECT — duplicate of #8333 (identical runtime change) +- pr #8330: REJECT [owner-may-reconsider] — third-party client endorsement; accurate but policy call +- pr #8332: REJECT — 13.6k-line feature + frame_too_large defect +- pr #8333: MERGE — together login via /v1/models; clean; no eval commits +- pr #8335: MERGE — DeepSeek effort ladder on Ollama Cloud (fixes #8334/#8303); clean +- pr #8336: REJECT — reduce-motion incomplete: flag parsing broken, gaps in live/setup paths +- pr #8337: MERGE — authoritative OpenCode Go quotas; clean; cherry-pick 55f5f3e3f4 +- pr #8338: REJECT — incomplete: cold-revive collision drops rebuilt built-in +- pr #8339: MERGE — WebP normalize for STB models; clean +- issue #8341: REJECT — existing PR #8343 +- pr #8343: MERGE — stop detached internal shell jobs (fixes #8341); clean +- pr #8344: REJECT — new public API + asides survive resetSessionContext +- pr #8348: MERGE — await parallel()/pipeline() in py eval; clean; no eval commits +- pr #8352: MERGE — contain mnemopi lifecycle hook failures (fixes #8351); clean; no eval commits +- pr #8356: MERGE — MSYS drive-path normalization in utility builtins (fixes #8355, supersedes #8357); cherry-pick cd9a5225df +- pr #8357: REJECT — incomplete: UTF-8 corruption + sed bypasses; prefer #8356 +- pr #8359: MERGE — docs fix; clean; no eval commits diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index baf5f7471..a676ecdcc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -164,6 +164,10 @@ - Fixed profile aliases generated by standalone binaries invoking Bun's embedded virtual script instead of the installed `omp` command ([#8233](https://github.com/can1357/oh-my-pi/issues/8233)). +### Fixed + +- Fixed large-session restore and `/tree` navigation blocking input while rebuilding the transcript by chunking idle rebuilds and terminal paints across event-loop turns ([#8133](https://github.com/can1357/oh-my-pi/issues/8133)). + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/collab/guest.ts b/packages/coding-agent/src/collab/guest.ts index db8aa640b..26b7dde57 100644 --- a/packages/coding-agent/src/collab/guest.ts +++ b/packages/coding-agent/src/collab/guest.ts @@ -452,7 +452,7 @@ export class CollabGuestLink { this.#assistantStreamSynced = false; setSessionTerminalTitle(pending.state.sessionName ?? pending.header.title, pending.state.cwd); this.#ctx.chatContainer.clear(); - this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.#ctx.reloadTodos(); this.#updateStatusSegment(); this.#readOnly = pending.readOnly; @@ -749,7 +749,7 @@ export class CollabGuestLink { this.#ctx.statusLine.resetActiveTime(); this.#ctx.ui.requestRender(); this.#ctx.updateEditorBorderColor(); - this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.#ctx.reloadTodos(); this.#ctx.ui.requestRender(true, { clearScrollback: true }); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5f9c515e2..8a33dc0aa 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -518,7 +518,7 @@ async function runInteractiveMode( // Every in-process session load also uses `clearTerminalHistory`; cold launch // follows the same clean-cutover path instead of preserving a previous run's // transcript above the fresh one. - mode.renderInitialMessages({ preserveExistingChat: true, clearTerminalHistory: true }); + await mode.renderInitialMessages({ preserveExistingChat: true, clearTerminalHistory: true }); for (const notify of notifs) { if (!notify) { diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index b434ad016..861a8e9e3 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1422,7 +1422,7 @@ export class CommandController { // Rebuild chat from the new session (which now contains the handoff document). this.ctx.clearTransientSessionUi(); - this.ctx.renderInitialMessages(); + await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); await this.ctx.reloadTodos(); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 4fe165950..1f26efbdf 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1908,7 +1908,7 @@ export class EventController { } else if (isHandoffAction) { this.ctx.clearTransientSessionUi(); this.ctx.lastAssistantUsage = undefined; - this.ctx.renderInitialMessages(); + await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 899fdad4c..1123ba668 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -203,7 +203,7 @@ export class ExtensionUiController { waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, @@ -246,7 +246,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); this.ctx.showStatus("Branched to new session"); @@ -260,7 +260,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -277,7 +277,7 @@ export class ExtensionUiController { return { cancelled: true }; } setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); return { cancelled: false }; }, @@ -436,7 +436,7 @@ export class ExtensionUiController { waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, @@ -476,7 +476,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); this.ctx.showStatus("Branched to new session"); @@ -490,7 +490,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -506,7 +506,7 @@ export class ExtensionUiController { if (!result) { return { cancelled: true }; } - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); return { cancelled: false }; }, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 0e6e67fd4..e7e5082be 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1152,7 +1152,7 @@ export class SelectorController { return; } - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); done(); this.ctx.showStatus("Branched to new session"); @@ -1325,7 +1325,7 @@ export class SelectorController { // Update UI — rebuild the display transcript for the new leaf (the // context from navigateTree is the LLM context, not the transcript). - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -1565,7 +1565,7 @@ export class SelectorController { this.ctx.statusLine.resetActiveTime(); this.ctx.ui.requestRender(); this.ctx.updateEditorBorderColor(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); return true; @@ -1599,7 +1599,7 @@ export class SelectorController { this.ctx.updateEditorBorderColor(); // Clear and re-render the chat - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session"); return true; diff --git a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts index 8a9ebb19f..0999e7544 100644 --- a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts +++ b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts @@ -104,7 +104,7 @@ export class SessionFocusController { await this.ctx.eventController.handleEvent(event); }); this.ctx.statusLine.setSession(target, this.#focusedAgentId); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); // Sync the run-state title to the attached target: a streaming target has no // agent_start incoming, so arm the loader/working title manually; an idle // target would otherwise inherit the previous session's stuck spinner, so diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 96563053d..eb736526a 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -4582,8 +4582,23 @@ export class InteractiveMode implements InteractiveModeContext { this.#uiHelpers.renderSessionContext(sessionContext, options); } - renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void { - this.#uiHelpers.renderInitialMessages(options); + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + async renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise { + for (const message of sessionContext.messages) { + this.noteDisplayableThinkingContent(message); + } + await this.#uiHelpers.renderSessionContextIncrementally(sessionContext, options, renderChunk); + } + + async renderInitialMessages(options?: { + preserveExistingChat?: boolean; + clearTerminalHistory?: boolean; + }): Promise { + await this.#uiHelpers.renderInitialMessages(options); } getUserMessageText(message: Message): string { @@ -5045,7 +5060,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#btwController.dispose(); this.#omfgController.dispose(); - this.renderInitialMessages({ clearTerminalHistory: true }); + await this.renderInitialMessages({ clearTerminalHistory: true }); this.updateEditorBorderColor(); this.showStatus( result.sessionFile ? `Branched /btw to ${path.basename(result.sessionFile)}` : "Branched /btw", diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 0fb879efe..baa50154e 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -323,7 +323,13 @@ export interface InteractiveModeContext { }, ): Component[]; renderSessionContext(sessionContext: SessionContext, options?: RenderSessionContextOptions): void; - renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void; + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise; + renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): Promise; getUserMessageText(message: Message): string; findLastAssistantMessage(): AssistantMessage | undefined; extractAssistantText(message: AssistantMessage): string; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index d97d8a41c..8c1c21331 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -70,6 +70,15 @@ interface RenderInitialMessagesOptions { clearTerminalHistory?: boolean; } +const TRANSCRIPT_RENDER_CHUNK_MESSAGES = 32; +const TRANSCRIPT_RENDER_CHUNK_MS = 8; + +function waitForImmediate(): Promise { + const { promise, resolve } = Promise.withResolvers(); + setImmediate(resolve); + return promise; +} + type QueuedMessages = { steering: string[]; followUp: string[]; @@ -312,6 +321,38 @@ export class UiHelpers { * @param options.populateHistory Add user messages to editor history */ renderSessionContext(sessionContext: SessionContext, options: RenderSessionContextOptions = {}): void { + const steps = this.#renderSessionContextSteps(sessionContext, options); + while (!steps.next().done) {} + } + + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + async renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise { + const steps = this.#renderSessionContextSteps(sessionContext, options); + let messagesSinceYield = 0; + let chunkStartedAt = performance.now(); + while (!steps.next().done) { + messagesSinceYield++; + if ( + messagesSinceYield < TRANSCRIPT_RENDER_CHUNK_MESSAGES && + performance.now() - chunkStartedAt < TRANSCRIPT_RENDER_CHUNK_MS + ) { + continue; + } + renderChunk?.(); + await waitForImmediate(); + messagesSinceYield = 0; + chunkStartedAt = performance.now(); + } + } + + *#renderSessionContextSteps( + sessionContext: SessionContext, + options: RenderSessionContextOptions = {}, + ): Generator { // Preserved: message_start handler owns this lifecycle (see #783) this.ctx.pendingTools.clear(); // Reseed the cache-invalidation baseline: this rebuild re-derives every @@ -402,6 +443,14 @@ export class UiHelpers { const messages = sessionContext.messages; const count = messages.length; for (let i = 0; i < count; i++) { + // Yield BEFORE each message (except the first) rather than after: the + // per-message body has several early `continue` paths (preserved live + // results, image-only and grouped `read` results), and a trailing yield + // is skipped by all of them. A large parallel-read batch is entirely + // such results, so an after-body yield never trips the chunk counter and + // the whole batch replays in one event-loop turn. Yielding at the top of + // the next iteration is reached no matter how the prior message exited. + if (i > 0) yield; const message = messages[i]!; if (message.role !== "toolResult") flushPendingUsage(); // Assistant messages need special handling for tool calls @@ -679,14 +728,14 @@ export class UiHelpers { this.ctx.ui.requestRender(); } - renderInitialMessages(options: RenderInitialMessagesOptions = {}): void { + async renderInitialMessages(options: RenderInitialMessagesOptions = {}): Promise { // This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI). // Clear existing rendered chat first to avoid duplicating the full session in the container. // On a non-preserving rebuild the existing blocks are discarded for good, so // dispose them (stopping any live timers/subscriptions) before clearing. When // preserving, the same instances are re-added below, so detach without dispose. const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined; - this.ctx.initialChatRendered = true; + const isInitialReplay = !this.ctx.initialChatRendered; if (preservedChatChildren) { this.ctx.chatContainer.clear(); } else { @@ -702,14 +751,60 @@ export class UiHelpers { // (focus attach/unfocus while a tool executes) keep dangling toolCalls so // the in-flight call re-renders as pending instead of vanishing; // renderSessionContext then keeps it in `pendingTools` for live routing. - const context = this.ctx.viewSession.buildTranscriptSessionContext({ + let terminalHistoryCleared = false; + const renderChunk = options.clearTerminalHistory + ? () => { + this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared }); + terminalHistoryCleared = true; + } + : undefined; + let context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: settings.get("display.collapseCompacted"), keepDanglingToolCalls: this.ctx.viewSession.isStreaming, }); - this.ctx.renderSessionContext(context, { + let replayEntryCount = this.ctx.viewSession.sessionManager.getEntries().length; + const renderOptions = { updateFooter: true, - populateHistory: !this.ctx.focusedAgentId, - }); + // A dirty initial replay may restart from a newer context. Populate + // history once from the stable context below instead of duplicating it + // on every attempt. + populateHistory: !this.ctx.focusedAgentId && !isInitialReplay, + }; + while (true) { + if (this.ctx.viewSession.isStreaming) { + // Live events mutate the same component maps; keep their replay atomic so + // a delta cannot land halfway through rebuilding its pending tool block. + this.ctx.renderSessionContext(context, renderOptions); + } else if (renderChunk) { + await this.ctx.renderSessionContextIncrementally(context, renderOptions, renderChunk); + } else { + await this.ctx.renderSessionContextIncrementally(context, renderOptions); + } + if (!isInitialReplay || this.ctx.viewSession.sessionManager.getEntries().length === replayEntryCount) { + break; + } + + // An extension persisted a display message while the initial replay + // yielded. The display callback stayed gated by initialChatRendered; + // discard the stale partial tree and replay the current session once + // more instead of letting a reentrant synchronous rebuild interleave. + this.ctx.resetTranscript(); + this.ctx.pendingBashComponents = []; + this.ctx.pendingPythonComponents = []; + terminalHistoryCleared = false; + context = this.ctx.viewSession.buildTranscriptSessionContext({ + collapseCompactedHistory: settings.get("display.collapseCompacted"), + keepDanglingToolCalls: this.ctx.viewSession.isStreaming, + }); + replayEntryCount = this.ctx.viewSession.sessionManager.getEntries().length; + } + if (isInitialReplay && !this.ctx.focusedAgentId) { + for (const message of context.messages) { + if (message.role !== "user" || message.synthetic) continue; + const text = this.getUserMessageText(message); + if (text) this.ctx.editor.addToHistory(text); + } + } // Show compaction info if session was compacted const allEntries = this.ctx.viewSession.sessionManager.getEntries(); @@ -724,7 +819,7 @@ export class UiHelpers { this.ctx.showStatus(`Session compacted ${times}`); } if (options.clearTerminalHistory) { - this.ctx.ui.requestRender(true, { clearScrollback: true }); + this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared }); } if (preservedChatChildren && preservedChatChildren.length > 0) { for (const child of preservedChatChildren) { @@ -732,6 +827,7 @@ export class UiHelpers { } this.ctx.ui.requestRender(); } + this.ctx.initialChatRendered = true; } clearEditor(): void { diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 69b504bd9..aa8430db5 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, test, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import { buildSessionContext, type SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { type Component, Container } from "@oh-my-pi/pi-tui"; @@ -38,10 +38,13 @@ function createInitialRenderHarness(): { ctx: InteractiveModeContext; helpers: U }, statusLine: { invalidate: vi.fn() }, updateEditorBorderColor: vi.fn(), - renderSessionContext: ( + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), addMessageToChat: (message: AgentMessage) => helpers.addMessageToChat(message), settings: { get: () => false }, session: { @@ -131,7 +134,7 @@ describe("InteractiveMode.showStatus", () => { const { ctx, helpers } = createInitialRenderHarness(); helpers.showWarning("startup notification probe"); - helpers.renderInitialMessages({ preserveExistingChat: true }); + await helpers.renderInitialMessages({ preserveExistingChat: true }); expect(renderContainer(ctx.chatContainer)).toContain("startup notification probe"); } finally { diff --git a/packages/coding-agent/test/issue-4348-repro.test.ts b/packages/coding-agent/test/issue-4348-repro.test.ts index 03f69b633..5beac90f5 100644 --- a/packages/coding-agent/test/issue-4348-repro.test.ts +++ b/packages/coding-agent/test/issue-4348-repro.test.ts @@ -22,7 +22,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import type { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { Container } from "@oh-my-pi/pi-tui"; @@ -93,10 +93,13 @@ function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContex }, addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => helpers.addMessageToChat(message, options), - renderSessionContext: ( + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), showStatus: vi.fn(), } as unknown as InteractiveModeContext; helpers = new UiHelpers(ctx); @@ -155,7 +158,7 @@ describe("issue #4348: cursor exec-channel tool results pair with synthesized to const transcript = transcriptWith(cursorTurn()); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); // Component structure: an assistant message, then a bash // ToolExecutionComponent for the synthesized bash block, then a @@ -205,7 +208,7 @@ describe("issue #4348: cursor exec-channel tool results pair with synthesized to ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("Running command:"); diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index c82cfd07a..ae677aa58 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -13,13 +13,13 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Usage } from "@oh-my-pi/pi-ai"; import { kStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import type { SessionContext, StrippedToolCallsMarker } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -61,11 +61,11 @@ function makeCtx(): { ctx: InteractiveModeContext; transcriptSpy: Mock<(options?: { collapseCompactedHistory?: boolean }) => SessionContext>; llmContextSpy: Mock<() => SessionContext>; - renderSessionContextSpy: Mock<(...args: unknown[]) => void>; + renderSessionContextSpy: Mock<(...args: unknown[]) => Promise>; } { const transcriptSpy = vi.fn(() => makeEmptyContext()); const llmContextSpy = vi.fn(() => makeEmptyContext()); - const renderSessionContextSpy = vi.fn(); + const renderSessionContextSpy = vi.fn(async () => {}); const ctx = { chatContainer: { clear: vi.fn(), addChild: vi.fn() }, @@ -87,7 +87,7 @@ function makeCtx(): { getEntries: vi.fn(() => []), getCwd: vi.fn(() => "/tmp"), }, - renderSessionContext: renderSessionContextSpy, + renderSessionContextIncrementally: renderSessionContextSpy, showStatus: vi.fn(), ui: { requestRender: vi.fn() }, resetTranscript: () => ctx.chatContainer.clear(), @@ -184,6 +184,12 @@ function makeRenderCtx( sessionManager: { getEntries: vi.fn(() => []), getCwd: vi.fn(() => "/tmp"), + putBlobSync: vi.fn(() => ({ + hash: "hash", + path: "/tmp/hash", + displayPath: "/tmp/hash.png", + ref: "blob:sha256:hash", + })), }, }, sessionManager: { @@ -198,10 +204,14 @@ function makeRenderCtx( }, addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => helpers.addMessageToChat(message, options), - renderSessionContext: ( + getUserMessageText: (message: Message) => helpers.getUserMessageText(message), + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), showStatus: vi.fn(), } as unknown as InteractiveModeContext; helpers = new UiHelpers(ctx); @@ -215,13 +225,13 @@ describe("UiHelpers.renderInitialMessages — transcript source", () => { const transcript = makeEmptyContext(); transcriptSpy.mockReturnValue(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(transcriptSpy).toHaveBeenCalledWith({ collapseCompactedHistory: true }); expect(llmContextSpy).not.toHaveBeenCalled(); expect(renderSessionContextSpy).toHaveBeenCalledWith(transcript, { updateFooter: true, - populateHistory: true, + populateHistory: false, }); }); }); @@ -230,14 +240,14 @@ describe("UiHelpers.renderInitialMessages — clearTerminalHistory", () => { it("requests a scrollback-clearing repaint when clearTerminalHistory is set", async () => { await Settings.init({ inMemory: true }); const { ctx } = makeCtx(); - new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); + await new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); expect(ctx.ui.requestRender).toHaveBeenCalledWith(true, { clearScrollback: true }); }); it("never clears scrollback when clearTerminalHistory is unset", async () => { await Settings.init({ inMemory: true }); const { ctx } = makeCtx(); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const clearedCall = (ctx.ui.requestRender as Mock<(...a: unknown[]) => void>).mock.calls.find( ([force, opts]) => force === true && (opts as { clearScrollback?: boolean } | undefined)?.clearScrollback, ); @@ -245,6 +255,76 @@ describe("UiHelpers.renderInitialMessages — clearTerminalHistory", () => { }); }); +describe("UiHelpers.renderInitialMessages — responsiveness", () => { + // Count the chunk boundaries an idle rebuild produces: each boundary calls + // `renderChunk` and then awaits a macrotask, so a positive count proves the + // rebuild handed control back to the event loop mid-replay instead of + // running as one uninterruptible turn. Drives `renderSessionContextIncrementally` + // directly (the layer that owns the chunk counter) so the assertion is + // deterministic and never races a timer. + async function countRebuildChunks(messages: AgentMessage[]): Promise { + const transcript = transcriptWith(messages); + const { ctx } = makeRenderCtx(transcript); + let chunks = 0; + await new UiHelpers(ctx).renderSessionContextIncrementally( + transcript, + { updateFooter: true, populateHistory: true }, + () => { + chunks++; + }, + ); + return chunks; + } + + it("splits a large plain transcript rebuild across event-loop turns", async () => { + await Settings.init({ inMemory: true }); + const messages: AgentMessage[] = Array.from({ length: 256 }, (_, index) => ({ + role: "user", + content: `message ${index}`, + timestamp: index, + })); + expect(await countRebuildChunks(messages)).toBeGreaterThan(0); + }); + + it("yields across a large parallel read-result batch", async () => { + // Regression: a single assistant turn whose results are all grouped `read` + // toolResults replays entirely through the `isReadGroupResult` early + // `continue`. A trailing per-message yield is skipped by every one of + // those results, so the whole batch would rebuild in one uninterruptible + // event-loop turn and the chunk counter would never trip (zero chunks). + // The top-of-loop yield must still hand control back between results. + await Settings.init({ inMemory: true }); + const readCalls = Array.from({ length: 128 }, (_, index) => ({ + type: "toolCall" as const, + id: `read-${index}`, + name: "read", + arguments: { path: `src/file-${index}.ts` }, + })); + const assistant: AssistantMessage = { + role: "assistant", + content: readCalls, + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet", + usage: emptyUsage, + stopReason: "toolUse", + timestamp: 1, + }; + const messages: AgentMessage[] = [assistant]; + for (let index = 0; index < 128; index++) { + messages.push({ + role: "toolResult", + toolCallId: `read-${index}`, + toolName: "read", + content: [{ type: "text", text: `contents ${index}` }], + isError: false, + timestamp: index + 2, + }); + } + expect(await countRebuildChunks(messages)).toBeGreaterThan(0); + }); +}); + describe("UiHelpers.renderInitialMessages — image replay", () => { it("restores read tool image blocks onto the rebuilt assistant transcript", async () => { await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } }); @@ -262,7 +342,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(true); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("Read sample.png"); @@ -289,7 +369,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(true); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("display image 1: 1x1"); @@ -311,7 +391,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript, false); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(false); const assistant = chatContainer.children.find( @@ -338,7 +418,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript, true, true); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(false); const assistant = chatContainer.children.find( @@ -383,7 +463,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { const transcript = reloaded.buildSessionContext({ transcript: true }); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); + await new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); expect(countImageComponents(chatContainer)).toBe(2); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("Read reopened.png"); @@ -392,7 +472,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { }); describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { - it("hides replayed tool cards without discarding them from the persisted transcript", () => { + it("hides replayed tool cards without discarding them from the persisted transcript", async () => { const toolCallId = "replayed-hidden-tool"; const toolArgumentMarker = "REPLAYED TOOL ARGUMENT MARKER"; const toolResultMarker = "REPLAYED TOOL RESULT MARKER"; @@ -427,7 +507,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { ]); const hidden = makeRenderCtx(transcript, true, true); - new UiHelpers(hidden.ctx).renderInitialMessages(); + await new UiHelpers(hidden.ctx).renderInitialMessages(); const hiddenRender = Bun.stripANSI(hidden.chatContainer.render(120).join("\n")); expect(hiddenRender).toContain(narrationMarker); expect(hiddenRender).toContain(finalMarker); @@ -435,7 +515,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { expect(hiddenRender).not.toContain(toolResultMarker); const visible = makeRenderCtx(transcript, true, false); - new UiHelpers(visible.ctx).renderInitialMessages(); + await new UiHelpers(visible.ctx).renderInitialMessages(); const visibleRender = Bun.stripANSI(visible.chatContainer.render(120).join("\n")); expect(visibleRender).toContain(toolArgumentMarker); expect(visibleRender).toContain(toolResultMarker); @@ -506,7 +586,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { expect(Bun.stripANSI(visible.chatContainer.render(120).join("\n"))).toContain("TODO_WARNING_MARKER"); }); - it("hides the stripped-tool-calls placeholder with tool activity and restores it on reveal", () => { + it("hides the stripped-tool-calls placeholder with tool activity and restores it on reveal", async () => { const strippedAssistant: AgentMessage & StrippedToolCallsMarker = { role: "assistant", content: [{ type: "text", text: "narration" }], @@ -521,7 +601,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { const transcript = transcriptWith([strippedAssistant]); const hidden = makeRenderCtx(transcript, true, true); - new UiHelpers(hidden.ctx).renderInitialMessages(); + await new UiHelpers(hidden.ctx).renderInitialMessages(); expect(Bun.stripANSI(hidden.chatContainer.render(120).join("\n"))).not.toContain( "elided — no result on this branch", ); @@ -567,7 +647,7 @@ describe("UiHelpers.renderSessionContext — error-stop tool calls", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("synthetic assistant stop error"); @@ -609,7 +689,7 @@ describe("UiHelpers.renderSessionContext — mid-stream tool call rebuild", () = ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("GROWN_TAIL_SENTINEL"); diff --git a/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts b/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts index db19625cc..f161c28e6 100644 --- a/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts +++ b/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts @@ -10,7 +10,7 @@ import type { } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { ExtensionUiController } from "@oh-my-pi/pi-coding-agent/modes/controllers/extension-ui-controller"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import { buildSessionContext, type SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import type { CustomMessageEntry, SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries"; @@ -117,6 +117,7 @@ function createHarness(): Harness { transcriptMessageComponents: new WeakMap(), pendingTools: new Map(), ui: { requestRender: vi.fn() }, + resetTranscript: () => ctx.chatContainer.clear(), isBackgrounded: false, initialChatRendered: false, statusLine: { invalidate: vi.fn() }, @@ -144,8 +145,13 @@ function createHarness(): Harness { handleInput: vi.fn(), getText: () => "", }, - renderSessionContext: (c: SessionContext, o?: { updateFooter?: boolean; populateHistory?: boolean }) => - helpers.renderSessionContext(c, o), + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( + context: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), addMessageToChat: (m: AgentMessage) => helpers.addMessageToChat(m), rebuildChatFromMessages: () => { ctx.chatContainer.clear(); @@ -212,7 +218,7 @@ describe("issue #1955 — sendMessage(display:true) during session_start", () => // Mirror main.ts: after `mode.init()` returns, the host renders the // initial transcript while preserving anything previously added to chat. - harness.helpers.renderInitialMessages({ preserveExistingChat: true }); + await harness.helpers.renderInitialMessages({ preserveExistingChat: true }); const rendered = Bun.stripANSI(harness.ctx.chatContainer.render(120).join("\n")); const occurrences = countOccurrences(rendered, marker); @@ -226,7 +232,7 @@ describe("issue #1955 — sendMessage(display:true) during session_start", () => // Establish the initial render — the host's `renderInitialMessages` // flips `initialChatRendered` so subsequent extension sends can rebuild. - harness.helpers.renderInitialMessages({ preserveExistingChat: true }); + await harness.helpers.renderInitialMessages({ preserveExistingChat: true }); const actions = harness.getActions(); actions!.sendMessage( @@ -243,4 +249,39 @@ describe("issue #1955 — sendMessage(display:true) during session_start", () => const rendered = Bun.stripANSI(harness.ctx.chatContainer.render(120).join("\n")); expect(countOccurrences(rendered, marker)).toBe(1); }); + + test("defers display rebuilds that arrive during the incremental initial replay", async () => { + const initialMarker = "INITIAL_ENTRY_127_END"; + const lateMarker = "LATE_EXTENSION_MESSAGE_END"; + const harness = createHarness(); + for (let index = 0; index < 128; index++) { + harness.entries.push( + makeCustomEntry(index + 1, `INITIAL_ENTRY_${index}_END`, index === 0 ? null : `entry-${index}`), + ); + } + await harness.controller.initHooksAndCustomTools(); + const actions = harness.getActions(); + expect(actions).toBeDefined(); + + const initialReplay = harness.helpers.renderInitialMessages({ + preserveExistingChat: true, + clearTerminalHistory: true, + }); + expect(harness.ctx.initialChatRendered).toBe(false); + actions!.sendMessage( + { + customType: "issue-1955-probe", + content: [{ type: "text", text: lateMarker }], + display: true, + attribution: "agent", + }, + { deliverAs: "nextTurn" }, + ); + await initialReplay; + + const rendered = Bun.stripANSI(harness.ctx.chatContainer.render(120).join("\n")); + expect(countOccurrences(rendered, initialMarker)).toBe(1); + expect(countOccurrences(rendered, lateMarker)).toBe(1); + expect(rendered.indexOf(initialMarker)).toBeLessThan(rendered.indexOf(lateMarker)); + }); });