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:
can1357
2026-08-13 01:16:23 +02:00
17 changed files with 563 additions and 69 deletions
+121
View File
@@ -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
+125
View File
@@ -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
+4
View File
@@ -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
+2 -2
View File
@@ -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 });
}
+1 -1
View File
@@ -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",
+7 -1
View File
@@ -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));
});
});