Merge PR #8135: fix(tui): keep large transcript rebuilds responsive (@roboomp)
# Conflicts: # packages/coding-agent/test/modes/utils/render-initial-messages.test.ts
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
for (const message of sessionContext.messages) {
|
||||
this.noteDisplayableThinkingContent(message);
|
||||
}
|
||||
await this.#uiHelpers.renderSessionContextIncrementally(sessionContext, options, renderChunk);
|
||||
}
|
||||
|
||||
async renderInitialMessages(options?: {
|
||||
preserveExistingChat?: boolean;
|
||||
clearTerminalHistory?: boolean;
|
||||
}): Promise<void> {
|
||||
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",
|
||||
|
||||
@@ -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<void>;
|
||||
renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): Promise<void>;
|
||||
getUserMessageText(message: Message): string;
|
||||
findLastAssistantMessage(): AssistantMessage | undefined;
|
||||
extractAssistantText(message: AssistantMessage): string;
|
||||
|
||||
@@ -70,6 +70,15 @@ interface RenderInitialMessagesOptions {
|
||||
clearTerminalHistory?: boolean;
|
||||
}
|
||||
|
||||
const TRANSCRIPT_RENDER_CHUNK_MESSAGES = 32;
|
||||
const TRANSCRIPT_RENDER_CHUNK_MS = 8;
|
||||
|
||||
function waitForImmediate(): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
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<void> {
|
||||
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<void, void, void> {
|
||||
// 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<void> {
|
||||
// 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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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<void>>;
|
||||
} {
|
||||
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<number> {
|
||||
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");
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user