@oh-my-pi/pi-coding-agent 15.10.9 → 15.10.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/dist/cli.js +23087 -0
  3. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  4. package/dist/types/async/job-manager.d.ts +18 -0
  5. package/dist/types/cli/args.d.ts +1 -1
  6. package/dist/types/cli/dry-balance-cli.d.ts +1 -1
  7. package/dist/types/cli/gallery-cli.d.ts +1 -1
  8. package/dist/types/cli/gallery-fixtures/types.d.ts +1 -1
  9. package/dist/types/cli/usage-cli.d.ts +72 -0
  10. package/dist/types/commands/launch.d.ts +1 -1
  11. package/dist/types/commands/read.d.ts +1 -1
  12. package/dist/types/commands/usage.d.ts +25 -0
  13. package/dist/types/config/append-only-context-mode.d.ts +2 -1
  14. package/dist/types/config/model-discovery.d.ts +55 -0
  15. package/dist/types/config/model-registry.d.ts +20 -219
  16. package/dist/types/config/model-resolver.d.ts +16 -10
  17. package/dist/types/config/model-roles.d.ts +28 -0
  18. package/dist/types/config/models-config-schema.d.ts +523 -42
  19. package/dist/types/config/models-config.d.ts +385 -0
  20. package/dist/types/config/settings-schema.d.ts +12 -16
  21. package/dist/types/config/settings.d.ts +1 -1
  22. package/dist/types/debug/log-viewer.d.ts +1 -1
  23. package/dist/types/debug/raw-sse.d.ts +1 -1
  24. package/dist/types/debug/terminal-info.d.ts +0 -1
  25. package/dist/types/eval/backend.d.ts +0 -2
  26. package/dist/types/eval/idle-timeout.d.ts +0 -4
  27. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  28. package/dist/types/export/html/template.generated.d.ts +1 -1
  29. package/dist/types/extensibility/extensions/types.d.ts +3 -3
  30. package/dist/types/hindsight/mental-models.d.ts +17 -8
  31. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  32. package/dist/types/internal-urls/types.d.ts +1 -1
  33. package/dist/types/lsp/edits.d.ts +9 -0
  34. package/dist/types/lsp/index.d.ts +2 -2
  35. package/dist/types/lsp/types.d.ts +2 -0
  36. package/dist/types/lsp/utils.d.ts +3 -0
  37. package/dist/types/mcp/json-rpc.d.ts +5 -0
  38. package/dist/types/mnemopi/state.d.ts +11 -1
  39. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  40. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  41. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  42. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  43. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  44. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  45. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  46. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  47. package/dist/types/modes/components/footer.d.ts +1 -1
  48. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  49. package/dist/types/modes/components/hook-input.d.ts +4 -0
  50. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  51. package/dist/types/modes/components/model-selector.d.ts +1 -1
  52. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  53. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  54. package/dist/types/modes/components/session-selector.d.ts +1 -1
  55. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  56. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  57. package/dist/types/modes/components/transcript-container.d.ts +31 -26
  58. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  59. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  60. package/dist/types/modes/components/user-message.d.ts +2 -1
  61. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  62. package/dist/types/modes/components/welcome.d.ts +19 -3
  63. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  64. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  65. package/dist/types/modes/interactive-mode.d.ts +1 -1
  66. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  67. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  68. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  69. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  70. package/dist/types/modes/types.d.ts +2 -1
  71. package/dist/types/session/agent-session.d.ts +1 -1
  72. package/dist/types/session/auth-broker-config.d.ts +4 -0
  73. package/dist/types/session/session-manager.d.ts +1 -1
  74. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  75. package/dist/types/ssh/connection-manager.d.ts +8 -0
  76. package/dist/types/task/discovery.d.ts +1 -2
  77. package/dist/types/task/parallel.d.ts +2 -2
  78. package/dist/types/task/worktree.d.ts +2 -0
  79. package/dist/types/tiny/title-client.d.ts +1 -1
  80. package/dist/types/tools/ask.d.ts +4 -0
  81. package/dist/types/tools/conflict-detect.d.ts +16 -0
  82. package/dist/types/tools/github-cache.d.ts +7 -0
  83. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  84. package/dist/types/tools/todo.d.ts +2 -0
  85. package/dist/types/tui/output-block.d.ts +3 -3
  86. package/dist/types/utils/changelog.d.ts +8 -0
  87. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  88. package/dist/types/web/scrapers/types.d.ts +12 -0
  89. package/dist/types/web/search/providers/codex.d.ts +1 -1
  90. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  91. package/examples/extensions/tools.ts +5 -4
  92. package/package.json +14 -11
  93. package/scripts/build-binary.ts +18 -23
  94. package/scripts/bundle-dist.ts +81 -0
  95. package/scripts/{dev-launch → omp} +1 -1
  96. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  97. package/src/async/job-manager.ts +57 -3
  98. package/src/autoresearch/dashboard.ts +1 -1
  99. package/src/autoresearch/prompt-setup.md +6 -6
  100. package/src/autoresearch/prompt.md +6 -6
  101. package/src/capability/fs.ts +10 -0
  102. package/src/cli/args.ts +1 -1
  103. package/src/cli/auth-gateway-cli.ts +1 -3
  104. package/src/cli/dry-balance-cli.ts +1 -1
  105. package/src/cli/gallery-cli.ts +1 -1
  106. package/src/cli/gallery-fixtures/fs.ts +1 -1
  107. package/src/cli/gallery-fixtures/types.ts +5 -1
  108. package/src/cli/list-models.ts +7 -12
  109. package/src/cli/usage-cli.ts +603 -0
  110. package/src/cli-commands.ts +1 -0
  111. package/src/cli.ts +69 -5
  112. package/src/commands/complete.ts +1 -1
  113. package/src/commands/launch.ts +1 -1
  114. package/src/commands/read.ts +6 -3
  115. package/src/commands/usage.ts +35 -0
  116. package/src/commit/agentic/agent.ts +1 -1
  117. package/src/commit/model-selection.ts +1 -1
  118. package/src/config/append-only-context-mode.ts +6 -12
  119. package/src/config/model-discovery.ts +554 -0
  120. package/src/config/model-registry.ts +308 -1025
  121. package/src/config/model-resolver.ts +113 -156
  122. package/src/config/model-roles.ts +74 -0
  123. package/src/config/models-config-schema.ts +57 -8
  124. package/src/config/models-config.ts +129 -0
  125. package/src/config/settings-schema.ts +18 -14
  126. package/src/config/settings.ts +37 -1
  127. package/src/dap/client.ts +124 -37
  128. package/src/dap/session.ts +259 -158
  129. package/src/debug/log-viewer.ts +1 -1
  130. package/src/debug/raw-sse.ts +1 -1
  131. package/src/debug/terminal-info.ts +0 -3
  132. package/src/edit/diff.ts +95 -18
  133. package/src/edit/hashline/block-resolver.ts +20 -1
  134. package/src/edit/hashline/diff.ts +36 -1
  135. package/src/edit/hashline/execute.ts +8 -2
  136. package/src/edit/index.ts +16 -1
  137. package/src/edit/modes/patch.ts +52 -0
  138. package/src/edit/modes/replace.ts +56 -22
  139. package/src/edit/notebook.ts +22 -2
  140. package/src/edit/renderer.ts +36 -10
  141. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  142. package/src/eval/backend.ts +0 -2
  143. package/src/eval/completion-bridge.ts +2 -1
  144. package/src/eval/idle-timeout.ts +2 -9
  145. package/src/eval/js/context-manager.ts +6 -8
  146. package/src/eval/js/executor.ts +6 -2
  147. package/src/eval/js/index.ts +0 -2
  148. package/src/eval/js/shared/helpers.ts +5 -6
  149. package/src/eval/js/shared/local-module-loader.ts +1 -1
  150. package/src/eval/js/shared/prelude.txt +62 -1
  151. package/src/eval/js/shared/rewrite-imports.ts +49 -23
  152. package/src/eval/js/shared/runtime.ts +1 -1
  153. package/src/eval/py/index.ts +0 -2
  154. package/src/eval/py/kernel.ts +19 -0
  155. package/src/eval/py/runner.py +107 -3
  156. package/src/exec/bash-executor.ts +3 -1
  157. package/src/export/html/template.generated.ts +1 -1
  158. package/src/export/html/template.js +3 -1
  159. package/src/extensibility/extensions/types.ts +3 -2
  160. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  161. package/src/hindsight/mental-models.ts +59 -12
  162. package/src/hindsight/state.ts +6 -1
  163. package/src/internal-urls/artifact-protocol.ts +11 -2
  164. package/src/internal-urls/docs-index.generated.ts +10 -10
  165. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  166. package/src/internal-urls/router.ts +1 -1
  167. package/src/internal-urls/types.ts +1 -1
  168. package/src/lib/xai-http.ts +1 -1
  169. package/src/lsp/client.ts +118 -38
  170. package/src/lsp/clients/biome-client.ts +101 -39
  171. package/src/lsp/edits.ts +143 -95
  172. package/src/lsp/index.ts +31 -22
  173. package/src/lsp/render.ts +1 -1
  174. package/src/lsp/types.ts +2 -0
  175. package/src/lsp/utils.ts +28 -10
  176. package/src/main.ts +165 -17
  177. package/src/mcp/json-rpc.ts +35 -5
  178. package/src/mcp/transports/stdio.ts +7 -1
  179. package/src/memories/index.ts +2 -1
  180. package/src/mnemopi/backend.ts +25 -3
  181. package/src/mnemopi/state.ts +38 -2
  182. package/src/modes/components/agent-dashboard.ts +10 -7
  183. package/src/modes/components/assistant-message.ts +19 -13
  184. package/src/modes/components/bash-execution.ts +1 -1
  185. package/src/modes/components/copy-selector.ts +1 -1
  186. package/src/modes/components/diff.ts +13 -2
  187. package/src/modes/components/dynamic-border.ts +12 -3
  188. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  189. package/src/modes/components/extensions/extension-list.ts +1 -1
  190. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  191. package/src/modes/components/footer.ts +1 -1
  192. package/src/modes/components/history-search.ts +1 -1
  193. package/src/modes/components/hook-editor.ts +8 -0
  194. package/src/modes/components/hook-input.ts +8 -0
  195. package/src/modes/components/hook-selector.ts +2 -2
  196. package/src/modes/components/model-selector.ts +66 -54
  197. package/src/modes/components/plan-review-overlay.ts +1 -1
  198. package/src/modes/components/session-observer-overlay.ts +2 -2
  199. package/src/modes/components/session-selector.ts +1 -1
  200. package/src/modes/components/settings-selector.ts +5 -1
  201. package/src/modes/components/status-line/component.ts +1 -1
  202. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  203. package/src/modes/components/transcript-container.ts +373 -141
  204. package/src/modes/components/tree-selector.ts +3 -3
  205. package/src/modes/components/user-message-selector.ts +1 -1
  206. package/src/modes/components/user-message.ts +17 -5
  207. package/src/modes/components/visual-truncate.ts +1 -1
  208. package/src/modes/components/welcome.ts +108 -26
  209. package/src/modes/controllers/command-controller.ts +10 -3
  210. package/src/modes/controllers/event-controller.ts +73 -49
  211. package/src/modes/controllers/input-controller.ts +5 -5
  212. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  213. package/src/modes/controllers/selector-controller.ts +1 -5
  214. package/src/modes/controllers/streaming-reveal.ts +85 -18
  215. package/src/modes/interactive-mode.ts +5 -19
  216. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  217. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  218. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  219. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  220. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  221. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  222. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  223. package/src/modes/types.ts +2 -1
  224. package/src/prompts/agents/explore.md +2 -2
  225. package/src/prompts/agents/librarian.md +1 -2
  226. package/src/prompts/agents/oracle.md +1 -1
  227. package/src/prompts/agents/plan.md +5 -5
  228. package/src/prompts/agents/task.md +5 -5
  229. package/src/prompts/ci-green-request.md +5 -7
  230. package/src/prompts/goals/goal-budget-limit.md +2 -2
  231. package/src/prompts/goals/goal-continuation.md +4 -4
  232. package/src/prompts/goals/goal-mode-active.md +1 -1
  233. package/src/prompts/memories/read-path.md +1 -1
  234. package/src/prompts/memories/stage_one_system.md +2 -2
  235. package/src/prompts/review-custom-request.md +1 -1
  236. package/src/prompts/system/agent-creation-architect.md +2 -2
  237. package/src/prompts/system/auto-continue.md +1 -1
  238. package/src/prompts/system/background-tan-dispatch.md +1 -1
  239. package/src/prompts/system/btw-user.md +2 -2
  240. package/src/prompts/system/commit-message-system.md +13 -1
  241. package/src/prompts/system/custom-system-prompt.md +1 -1
  242. package/src/prompts/system/eager-todo.md +2 -2
  243. package/src/prompts/system/irc-incoming.md +1 -1
  244. package/src/prompts/system/manual-continue.md +1 -1
  245. package/src/prompts/system/omfg-user.md +3 -4
  246. package/src/prompts/system/orchestrate-notice.md +9 -9
  247. package/src/prompts/system/plan-mode-active.md +4 -4
  248. package/src/prompts/system/plan-mode-subagent.md +4 -5
  249. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  250. package/src/prompts/system/project-prompt.md +2 -2
  251. package/src/prompts/system/subagent-system-prompt.md +4 -4
  252. package/src/prompts/system/system-prompt.md +15 -26
  253. package/src/prompts/system/title-system.md +2 -2
  254. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  255. package/src/prompts/system/workflow-notice.md +1 -1
  256. package/src/prompts/tools/ast-edit.md +1 -1
  257. package/src/prompts/tools/ast-grep.md +2 -2
  258. package/src/prompts/tools/bash.md +8 -10
  259. package/src/prompts/tools/browser.md +7 -7
  260. package/src/prompts/tools/debug.md +1 -1
  261. package/src/prompts/tools/eval.md +3 -3
  262. package/src/prompts/tools/find.md +0 -1
  263. package/src/prompts/tools/github.md +8 -7
  264. package/src/prompts/tools/goal.md +1 -1
  265. package/src/prompts/tools/image-gen.md +1 -1
  266. package/src/prompts/tools/inspect-image-system.md +1 -1
  267. package/src/prompts/tools/irc.md +15 -15
  268. package/src/prompts/tools/lsp.md +2 -2
  269. package/src/prompts/tools/patch.md +2 -2
  270. package/src/prompts/tools/read.md +3 -4
  271. package/src/prompts/tools/recall.md +1 -1
  272. package/src/prompts/tools/reflect.md +1 -1
  273. package/src/prompts/tools/render-mermaid.md +2 -2
  274. package/src/prompts/tools/replace.md +4 -10
  275. package/src/prompts/tools/rewind.md +2 -2
  276. package/src/prompts/tools/search-tool-bm25.md +1 -9
  277. package/src/prompts/tools/search.md +0 -1
  278. package/src/prompts/tools/ssh.md +0 -4
  279. package/src/prompts/tools/task.md +2 -3
  280. package/src/prompts/tools/todo.md +6 -2
  281. package/src/sdk.ts +23 -10
  282. package/src/session/agent-session.ts +44 -10
  283. package/src/session/auth-broker-config.ts +30 -1
  284. package/src/session/session-manager.ts +2 -2
  285. package/src/session/streaming-output.ts +23 -2
  286. package/src/slash-commands/builtin-registry.ts +20 -0
  287. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  288. package/src/ssh/connection-manager.ts +27 -0
  289. package/src/task/commands.ts +2 -1
  290. package/src/task/discovery.ts +17 -24
  291. package/src/task/executor.ts +61 -53
  292. package/src/task/index.ts +137 -60
  293. package/src/task/parallel.ts +3 -3
  294. package/src/task/render.ts +2 -2
  295. package/src/task/worktree.ts +64 -56
  296. package/src/thinking.ts +2 -1
  297. package/src/tiny/title-client.ts +32 -14
  298. package/src/tools/archive-reader.ts +30 -2
  299. package/src/tools/ask.ts +104 -21
  300. package/src/tools/ast-edit.ts +25 -5
  301. package/src/tools/auto-generated-guard.ts +20 -3
  302. package/src/tools/bash-interactive.ts +27 -7
  303. package/src/tools/bash.ts +54 -13
  304. package/src/tools/browser/launch.ts +11 -2
  305. package/src/tools/browser/readable.ts +19 -2
  306. package/src/tools/browser/registry.ts +4 -1
  307. package/src/tools/browser/render.ts +2 -2
  308. package/src/tools/browser/tab-supervisor.ts +55 -16
  309. package/src/tools/conflict-detect.ts +50 -4
  310. package/src/tools/debug.ts +1 -1
  311. package/src/tools/eval-render.ts +5 -5
  312. package/src/tools/eval.ts +0 -2
  313. package/src/tools/fetch.ts +33 -10
  314. package/src/tools/gh-cache-invalidation.ts +63 -8
  315. package/src/tools/gh-renderer.ts +1 -1
  316. package/src/tools/gh.ts +172 -29
  317. package/src/tools/github-cache.ts +70 -6
  318. package/src/tools/image-gen.ts +3 -9
  319. package/src/tools/irc.ts +5 -1
  320. package/src/tools/job.ts +1 -1
  321. package/src/tools/read.ts +202 -61
  322. package/src/tools/render-utils.ts +3 -3
  323. package/src/tools/resolve.ts +1 -1
  324. package/src/tools/search.ts +92 -29
  325. package/src/tools/sqlite-reader.ts +17 -5
  326. package/src/tools/ssh.ts +8 -8
  327. package/src/tools/todo.ts +51 -12
  328. package/src/tools/write.ts +118 -18
  329. package/src/tui/output-block.ts +4 -4
  330. package/src/utils/changelog.ts +27 -1
  331. package/src/utils/file-mentions.ts +2 -1
  332. package/src/web/scrapers/arxiv.ts +1 -1
  333. package/src/web/scrapers/go-pkg.ts +1 -1
  334. package/src/web/scrapers/iacr.ts +1 -1
  335. package/src/web/scrapers/readthedocs.ts +1 -1
  336. package/src/web/scrapers/twitter.ts +2 -1
  337. package/src/web/scrapers/types.ts +87 -8
  338. package/src/web/scrapers/wikipedia.ts +1 -1
  339. package/src/web/scrapers/youtube.ts +6 -1
  340. package/src/web/search/index.ts +1 -1
  341. package/src/web/search/providers/anthropic.ts +8 -2
  342. package/src/web/search/providers/codex.ts +2 -1
  343. package/src/web/search/providers/gemini.ts +2 -3
  344. package/src/web/search/render.ts +8 -6
  345. package/dist/types/config/model-equivalence.d.ts +0 -24
  346. package/dist/types/config/model-id-affixes.d.ts +0 -12
  347. package/dist/types/config/model-provider-priority.d.ts +0 -1
  348. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  349. package/src/config/model-equivalence.ts +0 -875
  350. package/src/config/model-id-affixes.ts +0 -81
  351. package/src/config/model-provider-priority.ts +0 -56
  352. package/src/exec/idle-timeout-watchdog.ts +0 -126
@@ -1,10 +1,16 @@
1
- import { type Component, Container, type NativeScrollbackLiveRegion, TERMINAL } from "@oh-my-pi/pi-tui";
1
+ import { type Component, Container, type NativeScrollbackLiveRegion, type RenderStablePrefix } from "@oh-my-pi/pi-tui";
2
2
 
3
- const kSnapshot = Symbol("transcript.frozenRender");
3
+ const kSnapshot = Symbol("transcript.liveDiffSnapshot");
4
4
 
5
- interface FrozenRender {
5
+ /**
6
+ * Per-block diff cache: the block's previous stripped contribution plus the
7
+ * derived append-only state. Purely an input to {@link deriveLiveCommitState}
8
+ * for still-live blocks — it is never replayed as render output. Every block
9
+ * renders its current content on every frame.
10
+ */
11
+ interface LiveDiffSnapshot {
6
12
  width: number;
7
- lines: string[];
13
+ lines: readonly string[];
8
14
  generation: number;
9
15
  appendOnly: boolean;
10
16
  /**
@@ -12,10 +18,25 @@ interface FrozenRender {
12
18
  * append-only status. `0` means the block is not under rewrite suspicion.
13
19
  */
14
20
  volatileCooldown: number;
21
+ /**
22
+ * Stable-prefix ratchet (see {@link deriveLiveCommitState}): leading rows
23
+ * promoted as commit-safe because they stayed visibly identical for
24
+ * {@link STABLE_PREFIX_COMMIT_FRAMES} consecutive frames, plus the in-flight
25
+ * candidate run and its age.
26
+ */
27
+ stablePrefixLength: number;
28
+ candidatePrefixLength: number;
29
+ candidatePrefixAge: number;
30
+ /**
31
+ * Topmost row index ever observed rewritten in place (see
32
+ * {@link deriveLiveCommitState}): the stable-prefix ratchet never promotes
33
+ * rows at/after it. `Infinity` until the first rewrite.
34
+ */
35
+ rewriteFloor: number;
15
36
  }
16
37
 
17
38
  interface SnapshotCarrier {
18
- [kSnapshot]?: FrozenRender;
39
+ [kSnapshot]?: LiveDiffSnapshot;
19
40
  }
20
41
 
21
42
  /**
@@ -45,7 +66,7 @@ function isPlainBlank(line: string): boolean {
45
66
  // Strip leading/trailing plain-blank rows so each block contributes only its
46
67
  // visible body; the container owns the gaps between blocks. Returns the input
47
68
  // array unchanged when there is nothing to trim (no allocation on the hot path).
48
- function stripPlainBlankEdges(lines: string[]): string[] {
69
+ function stripPlainBlankEdges(lines: readonly string[]): readonly string[] {
49
70
  let start = 0;
50
71
  let end = lines.length;
51
72
  while (start < end && isPlainBlank(lines[start]!)) start++;
@@ -53,9 +74,35 @@ function stripPlainBlankEdges(lines: string[]): string[] {
53
74
  return start === 0 && end === lines.length ? lines : lines.slice(start, end);
54
75
  }
55
76
 
77
+ /**
78
+ * One block's recorded contribution to the assembled transcript: the raw array
79
+ * reference its render() returned, the stripped contribution derived from it,
80
+ * and where those rows landed. Reference-compared on the next render — per the
81
+ * Component render contract, an identical raw reference proves the block's
82
+ * rows are byte-identical, so the stripped contribution and the assembled rows
83
+ * can be reused without re-deriving anything.
84
+ */
85
+ interface BlockSegment {
86
+ component: Component;
87
+ rawRef: readonly string[];
88
+ contribution: readonly string[];
89
+ width: number;
90
+ /** Frame row of this block's first emitted row (the separator when present). */
91
+ startRow: number;
92
+ /** Rows emitted: separator + contribution (0 for empty contributions). */
93
+ rowCount: number;
94
+ sep: number;
95
+ }
96
+
97
+ const EMPTY_SEGMENTS: BlockSegment[] = [];
98
+
56
99
  interface LiveCommitState {
57
100
  appendOnly: boolean;
58
101
  volatileCooldown: number;
102
+ stablePrefixLength: number;
103
+ candidatePrefixLength: number;
104
+ candidatePrefixAge: number;
105
+ rewriteFloor: number;
59
106
  safeLength: number;
60
107
  }
61
108
 
@@ -72,6 +119,38 @@ interface LiveCommitState {
72
119
  */
73
120
  const VOLATILE_REARM_FRAMES = 30;
74
121
 
122
+ /**
123
+ * Consecutive frames a leading row run must stay visibly identical before it
124
+ * is promoted as commit-safe even though the block's tail keeps rewriting.
125
+ * Append-only detection alone is all-or-nothing per block: one perpetually
126
+ * ticking row (a task tool's progress tree, per-agent cost/tool counters, a
127
+ * log line spinner) suspends commits for the WHOLE block forever, so once the
128
+ * block outgrows the viewport its static head — e.g. a task's prompt/context
129
+ * markdown — is neither committed to native scrollback nor on screen: the
130
+ * transcript reads as cut off for the entire (possibly minutes-long) run.
131
+ * The ratchet commits the settled head while only the genuinely volatile tail
132
+ * stays deferred. If a promoted row is later rewritten (a collapsing
133
+ * preview), the engine's committed-prefix audit re-anchors and recommits —
134
+ * duplication, never loss — and the ratchet retreats to the divergence.
135
+ */
136
+ const STABLE_PREFIX_COMMIT_FRAMES = 30;
137
+
138
+ /**
139
+ * Rows at a live block's tail treated as the volatile streaming edge. Real
140
+ * streaming is not strictly append-only at the bottom: the in-flight markdown
141
+ * paragraph re-wraps as words arrive (rewriting its last 1-2 visual rows), an
142
+ * unclosed token (`**bold`, a half-streamed link) re-renders when its closer
143
+ * arrives, and a wrap-shrink moves the last word onto a new row. Divergence
144
+ * confined to this zone is clean growth, and the zone itself is held back
145
+ * from the offered commit boundary — so a tolerated rewrite can never touch a
146
+ * row the engine may have committed. Width 4 covers the observed shapes (≤2
147
+ * rows) with margin for wide glyphs and multi-row token spans; the cost is
148
+ * only that the last 4 rows of a live block commit at finalization instead of
149
+ * mid-stream, which is invisible (they are on screen — the viewport is always
150
+ * taller than the holdback).
151
+ */
152
+ const TAIL_VOLATILITY_ROWS = 4;
153
+
75
154
  /**
76
155
  * Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
77
156
  * write framing, not content. A styled line's closing escape moves when the
@@ -90,22 +169,31 @@ function rowsVisiblyEqual(prev: string, cur: string): boolean {
90
169
  return prev === cur || normalizeRow(prev) === normalizeRow(cur);
91
170
  }
92
171
 
172
+ /**
173
+ * Whether `cur` is `prev` grown in place: the visible content of `prev` is a
174
+ * strict-or-equal prefix of `cur`'s (token streaming appending to the cursor
175
+ * row). Escape placement and pad drift are ignored, same as rowsVisiblyEqual.
176
+ */
177
+ function rowVisiblyGrew(prev: string, cur: string): boolean {
178
+ return normalizeRow(cur).startsWith(normalizeRow(prev));
179
+ }
180
+
93
181
  function hasValidSnapshot(
94
- snapshot: FrozenRender | undefined,
182
+ snapshot: LiveDiffSnapshot | undefined,
95
183
  width: number,
96
184
  generation: number,
97
- ): snapshot is FrozenRender {
185
+ ): snapshot is LiveDiffSnapshot {
98
186
  return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
99
187
  }
100
188
 
101
- function commonPrefixLength(prev: string[], cur: string[]): number {
189
+ function commonPrefixLength(prev: readonly string[], cur: readonly string[]): number {
102
190
  const limit = Math.min(prev.length, cur.length);
103
191
  let i = 0;
104
192
  while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++;
105
193
  return i;
106
194
  }
107
195
 
108
- function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
196
+ function commonSuffixLength(prev: readonly string[], cur: readonly string[], prefixLength: number): number {
109
197
  const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
110
198
  let i = 0;
111
199
  while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++;
@@ -113,16 +201,25 @@ function commonSuffixLength(prev: string[], cur: string[], prefixLength: number)
113
201
  }
114
202
 
115
203
  function deriveLiveCommitState(
116
- previous: FrozenRender | undefined,
117
- current: string[],
204
+ previous: LiveDiffSnapshot | undefined,
205
+ current: readonly string[],
118
206
  width: number,
119
207
  generation: number,
120
208
  ): LiveCommitState {
121
209
  let appendOnly = false;
122
210
  let volatileCooldown = 0;
211
+ let stablePrefixLength = 0;
212
+ let candidatePrefixLength = 0;
213
+ let candidatePrefixAge = 0;
214
+ let rewriteFloor = Number.POSITIVE_INFINITY;
215
+ let trailingRowGrowth = false;
123
216
  if (hasValidSnapshot(previous, width, generation)) {
124
217
  appendOnly = previous.appendOnly;
125
218
  volatileCooldown = previous.volatileCooldown;
219
+ stablePrefixLength = previous.stablePrefixLength;
220
+ candidatePrefixLength = previous.candidatePrefixLength;
221
+ candidatePrefixAge = previous.candidatePrefixAge;
222
+ rewriteFloor = previous.rewriteFloor;
126
223
 
127
224
  const prefixLength = commonPrefixLength(previous.lines, current);
128
225
  const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
@@ -130,32 +227,50 @@ function deriveLiveCommitState(
130
227
  if (!staticRender) {
131
228
  const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
132
229
  // Append-only growth never rewrites a row that may already have scrolled
133
- // into native scrollback; it only grows the block at/near its tail. Four
134
- // shapes qualify: a pure bottom append, an insertion above stable trailing
135
- // chrome (a streaming tool's footer/border), an in-place extension of the
136
- // current line by one streamed token (line count unchanged), and a
137
- // wrap-shrink of the current line where its last word grew past the wrap
138
- // column and moved down onto an appended row. The first two preserve every
139
- // previous row across a matching prefix + suffix; the last two leave a
140
- // single divergent previous row the block's in-flight bottom line, which
141
- // cannot have been committed (commits stop at the viewport top and the
142
- // bottom line is by definition on screen). Any other divergent interior
143
- // row means the block re-laid-out committed-candidate content a rewrite,
144
- // which suspends commits until the block re-earns append-only.
230
+ // into native scrollback; it only grows the block at/near its tail. Two
231
+ // shapes qualify:
232
+ // - a pure insertion that preserves every previous row across a
233
+ // matching prefix + suffix (a bottom append, or an insertion above
234
+ // stable trailing chrome like a streaming tool's footer/border);
235
+ // - a rewrite whose divergence BEGINS inside the trailing
236
+ // TAIL_VOLATILITY_ROWS of the previous render the streaming edge:
237
+ // the in-flight paragraph re-wrapping as words arrive (its last 1-2
238
+ // visual rows), an unclosed markdown token (`**bold`) re-rendering
239
+ // when its closer streams in, a wrap-shrink pushing the last word
240
+ // onto an appended row. That zone is held back from `safeLength`
241
+ // below, so a tolerated rewrite can never touch a row that was
242
+ // offered for commit.
243
+ // The anchor matters: the gap must START in the tail zone, not merely
244
+ // be small — a one-row ticker mid-block with stable rows beneath it
245
+ // would otherwise classify clean, get offered past, and rewrite
246
+ // committed rows on every tick. Any deeper divergent row means the
247
+ // block re-laid-out committed-candidate content — a rewrite, which
248
+ // suspends commits until the block re-earns append-only.
145
249
  const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
146
- let tailExtendedInPlace = false;
147
- if (
148
- !preservedEveryRow &&
149
- prefixLength + suffixLength === previous.lines.length - 1 &&
150
- prefixLength < current.length
151
- ) {
152
- const prevTail = normalizeRow(previous.lines[prefixLength]!);
153
- const curTail = normalizeRow(current[prefixLength]!);
154
- tailExtendedInPlace =
155
- curTail.startsWith(prevTail) || (current.length > previous.lines.length && prevTail.startsWith(curTail));
156
- }
157
- if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length) {
250
+ const tailConfined = preservedEveryRow || prefixLength >= previous.lines.length - TAIL_VOLATILITY_ROWS;
251
+ if (tailConfined && current.length >= previous.lines.length) {
252
+ // Strict trailing-row growth: every previous row except the last
253
+ // is visibly unchanged and the last grew in place as a visible
254
+ // prefix, with no rows appended — a line accumulating tokens.
255
+ // The sole divergent row is the block's physical last row, which
256
+ // the engine's window floor never commits while it stays last
257
+ // (chunkTo windowTop ≤ last row index), so the volatile-tail
258
+ // holdback below is unnecessary: the whole body is offerable and
259
+ // the block's scrolled-off head reaches native scrollback.
260
+ trailingRowGrowth =
261
+ current.length === previous.lines.length &&
262
+ prefixLength === previous.lines.length - 1 &&
263
+ rowVisiblyGrew(previous.lines[prefixLength]!, current[prefixLength]!);
158
264
  if (volatileCooldown === 0) appendOnly = true;
265
+ // Clean growth inserts/rewrites rows at the divergence; a floor
266
+ // inside the preserved suffix travels down with it, a floor at or
267
+ // above the divergent zone stays put (conservative: a stale floor
268
+ // index can only point at an earlier row, never a later one).
269
+ const delta = current.length - previous.lines.length;
270
+ if (delta > 0 && Number.isFinite(rewriteFloor)) {
271
+ const suffixStart = Math.max(prefixLength, previous.lines.length - suffixLength);
272
+ if (rewriteFloor >= suffixStart) rewriteFloor += delta;
273
+ }
159
274
  } else {
160
275
  cleanFrame = false;
161
276
  appendOnly = false;
@@ -163,65 +278,126 @@ function deriveLiveCommitState(
163
278
  }
164
279
  }
165
280
  if (cleanFrame && volatileCooldown > 0) volatileCooldown--;
281
+
282
+ // Stable-prefix ratchet, independent of append-only. `prefixLength` is
283
+ // this frame's visibly-unchanged leading run; the candidate accumulates
284
+ // the MINIMUM prefix across a STABLE_PREFIX_COMMIT_FRAMES window, so
285
+ // promotion means every promoted row stayed identical for the whole
286
+ // window (row r is inside frame i's common prefix iff r < p_i, so
287
+ // r < min(p) holds for every frame of the window). A row settling
288
+ // mid-window promotes at most two windows later. The engine audit owns
289
+ // any promoted rows that already committed (recommit, never loss).
290
+ if (prefixLength < stablePrefixLength) {
291
+ // A divergence inside the promoted run is the ratchet's proof of
292
+ // over-promotion: this row was visibly stable for a full window,
293
+ // got promoted (and likely committed), and then mutated anyway — a
294
+ // slow ticker (an agent row's tool/cost counter, a growing progress
295
+ // tree), not settling content. It will mutate again, and every
296
+ // promote→mutate cycle makes the engine audit recommit, spraying a
297
+ // stale snapshot of the block into native scrollback. Floor the
298
+ // ratchet at the divergence permanently: rows above it may still
299
+ // promote, rows at/below it never re-promote while the block lives.
300
+ // One-off re-layouts before any promotion (a call→result frame
301
+ // transition, a codespan finalizing) never hit this branch, and the
302
+ // append-only re-arm path commits the full block regardless of the
303
+ // floor.
304
+ rewriteFloor = Math.min(rewriteFloor, prefixLength);
305
+ stablePrefixLength = prefixLength;
306
+ candidatePrefixLength = prefixLength;
307
+ candidatePrefixAge = 0;
308
+ } else {
309
+ candidatePrefixLength =
310
+ candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength);
311
+ candidatePrefixAge++;
312
+ if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) {
313
+ // Cap at the volatile-tail holdback: a long static stretch would
314
+ // otherwise promote the streaming edge itself (min prefix == full
315
+ // length), and the next chunk's tail re-wrap would then rewrite
316
+ // offered rows.
317
+ stablePrefixLength = Math.min(
318
+ candidatePrefixLength,
319
+ rewriteFloor,
320
+ Math.max(0, current.length - TAIL_VOLATILITY_ROWS),
321
+ );
322
+ candidatePrefixLength = prefixLength;
323
+ candidatePrefixAge = 0;
324
+ }
325
+ }
166
326
  }
167
327
 
168
328
  return {
169
329
  appendOnly,
170
330
  volatileCooldown,
171
- safeLength: appendOnly ? current.length : 0,
331
+ stablePrefixLength,
332
+ candidatePrefixLength,
333
+ candidatePrefixAge,
334
+ rewriteFloor,
335
+ // A clean-streaming block's body is committable up to the volatile-tail
336
+ // holdback (the streaming edge is never offered, so its tolerated
337
+ // rewrites can never touch committed rows); otherwise the settled head
338
+ // still is — only the volatile tail stays deferred. Strict in-place
339
+ // growth of the trailing row skips the holdback: its only mutable row
340
+ // is the block's last, which cannot commit while it remains last.
341
+ safeLength: appendOnly
342
+ ? trailingRowGrowth
343
+ ? current.length
344
+ : Math.max(stablePrefixLength, current.length - TAIL_VOLATILITY_ROWS, 0)
345
+ : stablePrefixLength,
172
346
  };
173
347
  }
174
348
 
175
349
  /**
176
- * Transcript container that freezes the rendered output of every block except
177
- * the bottom-most (live) one on terminals where committed native scrollback is
178
- * immutable.
350
+ * Transcript container that renders every block's current content each frame
351
+ * and reports the live-region seam (`NativeScrollbackLiveRegion`) that gates
352
+ * the engine's append-only scrollback commits.
179
353
  *
180
- * On ED3-risk terminals with an unobservable viewport (ghostty/kitty/iTerm2/…)
181
- * the renderer cannot clear saved lines (`\x1b[3J` may yank a reader) or query
182
- * whether the user has scrolled, so any block that re-lays-out *after* it has
183
- * scrolled past the viewport leaves a stale duplicate above the live region
184
- * (a finalized assistant message re-wrapping, a tool preview collapsing to its
185
- * compact result, a late async tool completion). The renderer's only safe move
186
- * for such an offscreen edit is to not repaint which is correct only if the
187
- * committed region never changes underneath it.
354
+ * The engine never rewrites committed history: rows above the seam that have
355
+ * entered the tape keep whatever bytes they were committed with ("let the
356
+ * history be"), while the visible window always repaints from each block's
357
+ * latest render a late tool result, a post-finalize error pin, or an expand
358
+ * toggle is always reflected on screen. Blocks that are still mutating (an
359
+ * unfinalized tool, a streaming assistant message) stay below the seam so
360
+ * their rows do not enter history while they can still change; a streaming
361
+ * block whose render grows append-only deepens the seam through its settled
362
+ * head so a long reply's scrolled-off rows still reach scrollback mid-stream.
188
363
  *
189
- * This container provides that guarantee: a block's render is snapshotted while
190
- * it is the live (bottom-most) block, and once a newer block is appended it
191
- * replays the snapshot instead of recomputing. Mutations after a block leaves
192
- * live are intentionally deferred until the next checkpoint {@link thaw} (prompt
193
- * submit native-scrollback rebuild), where the whole transcript is replayed
194
- * and any drift reconciles safely. On terminals that can rebuild history this
195
- * freezing is unnecessary, so it renders every block live for full fidelity.
364
+ * Assembly is incremental: the returned array is persistent and mutated in
365
+ * place. Each block's render is still called every frame, but a block whose
366
+ * render returned the same array reference at an unchanged offset reuses its
367
+ * previously assembled rows; the array is truncated and re-pushed only from
368
+ * the first divergent block. The leading byte-identical row count is reported
369
+ * through {@link RenderStablePrefix} so the engine can skip marker scanning,
370
+ * line preparation, and the committed-prefix audit for those rows.
196
371
  */
197
- export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
198
- // Bumped to invalidate every block's snapshot at once; a snapshot is only
199
- // honored when its stored generation still matches.
372
+ export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion, RenderStablePrefix {
373
+ // Bumped to retire every block's diff snapshot at once (theme change /
374
+ // clear); a snapshot is only honored when its stored generation matches.
200
375
  #generation = 0;
201
- // Line index where the live (repaintable) region began on the previous
202
- // render — the start of the earliest still-mutating block, or the bottom
203
- // block when everything is finalized. A block leaves the live region only
204
- // once it has finalized AND a finalized block sits below it; the frame it
205
- // crosses out is recomputed so it freezes at its true final content, not the
206
- // mid-stream snapshot it last rendered while live (TUI render coalescing can
207
- // advance a block's content in the very frame it stops being live).
208
- #prevLiveStartIndex = 0;
209
376
  // Local line index where the current live region begins in the most recent
210
- // render. TUI extends the native-scrollback pinned region from this point
211
- // through the live blocks and the root chrome rendered below them.
377
+ // render. TUI commits rows to native scrollback only above this seam (or
378
+ // the deeper commit-safe end below).
212
379
  #nativeScrollbackLiveRegionStart: number | undefined;
213
380
  // Local line index up to which the leading run of live blocks is safe to
214
- // commit. Finalized blocks contribute their full frozen body; still-live
215
- // blocks contribute only while their render has been observed growing
216
- // without visibly rewriting a previously rendered interior row (escape
217
- // placement and pad drift are ignored). A rewrite suspends the block's
218
- // contribution until it re-earns append-only via VOLATILE_REARM_FRAMES
219
- // clean frames; the pinned emitter then backfills the stalled gap.
381
+ // commit. Finalized blocks contribute their full body; still-live blocks
382
+ // contribute only while their render has been observed growing without
383
+ // visibly rewriting a previously rendered interior row (escape placement
384
+ // and pad drift are ignored). A rewrite suspends the block's contribution
385
+ // until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames;
386
+ // the engine then backfills the stalled gap.
220
387
  #nativeScrollbackCommitSafeEnd: number | undefined;
221
-
388
+ // Persistent assembled transcript rows. Rows before the stable floor are
389
+ // byte-identical to the previous render; rows at/after it were re-pushed.
390
+ #lines: string[] = [];
391
+ #segments: BlockSegment[] = EMPTY_SEGMENTS;
392
+ #renderWidth = -1;
393
+ // Stable-prefix floor accumulated across renders since the last
394
+ // getRenderStablePrefixRows() read (see RenderStablePrefix: reading
395
+ // consumes the report and re-bases the baseline). Out-of-band renders
396
+ // between engine frames lower it; they can never inflate it.
397
+ #stableRowsFloor = 0;
222
398
  override invalidate(): void {
223
- // A theme/global invalidation forces a full recompute on the rebuild that
224
- // follows; retire every snapshot.
399
+ // Theme/global invalidation: retire every diff snapshot so stale styling
400
+ // is not diffed against the recolored render.
225
401
  this.#generation++;
226
402
  super.invalidate();
227
403
  }
@@ -231,6 +407,12 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
231
407
  super.clear();
232
408
  }
233
409
 
410
+ getRenderStablePrefixRows(): number {
411
+ const value = Math.min(this.#stableRowsFloor, this.#lines.length);
412
+ this.#stableRowsFloor = this.#lines.length;
413
+ return value;
414
+ }
415
+
234
416
  getNativeScrollbackLiveRegionStart(): number | undefined {
235
417
  return this.#nativeScrollbackLiveRegionStart;
236
418
  }
@@ -240,31 +422,36 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
240
422
  }
241
423
 
242
424
  /**
243
- * Retire all frozen snapshots so the next render reflects each block's current
244
- * state. Call at reconciliation checkpoints (prompt submit) where the whole
245
- * transcript is replayed into native scrollback and any drift a frozen block
246
- * accumulated is reconciled.
425
+ * Whether `component` sits below a still-mutating block i.e. inside the
426
+ * live region, where its rows cannot have been committed to native
427
+ * scrollback yet (commits are prefix-only and stop at the first
428
+ * still-live block). Callers that retract ephemeral blocks (IRC cards)
429
+ * must check this: removing a block whose rows may already be in history
430
+ * is an interior deletion of the committed prefix, which the engine can
431
+ * only repair by recommitting everything below it — duplication.
247
432
  */
248
- thaw(): void {
249
- this.#generation++;
433
+ isWithinLiveRegion(component: Component): boolean {
434
+ const index = this.children.indexOf(component);
435
+ if (index < 0) return false;
436
+ for (let i = 0; i < index; i++) {
437
+ if (!isBlockFinalized(this.children[i]!)) return true;
438
+ }
439
+ return false;
250
440
  }
251
441
 
252
- override render(width: number): string[] {
442
+ override render(width: number): readonly string[] {
253
443
  width = Math.max(1, width);
254
444
  this.#nativeScrollbackLiveRegionStart = undefined;
255
445
  this.#nativeScrollbackCommitSafeEnd = undefined;
256
446
 
257
- // Freezing/snapshotting only applies on ED3-risk terminals; elsewhere every
258
- // block renders live. Inter-block spacing applies on BOTH paths so the gap
259
- // between blocks is identical regardless of terminal.
260
- const risk = TERMINAL.eagerEraseScrollbackRisk;
261
447
  const count = this.children.length;
262
448
 
263
449
  // The live region spans from the earliest still-mutating block through the
264
- // bottom. A block that has not finalized must stay repaintable: out-of-band
265
- // inserts (TTSR/todo cards) can append a finalized block *below* a tool that
266
- // is still awaiting its result, and freezing the tool there would strand its
267
- // committed rows on the mid-stream preview the late result never reaches.
450
+ // bottom. A block that has not finalized must stay below the seam: out-of-
451
+ // band inserts (TTSR/todo cards) can append a finalized block *below* a
452
+ // tool that is still awaiting its result, and committing the tool there
453
+ // would strand its history rows on the mid-stream preview the late result
454
+ // never reaches.
268
455
  let liveStartIndex = count - 1;
269
456
  for (let i = 0; i < count; i++) {
270
457
  if (!isBlockFinalized(this.children[i]!)) {
@@ -272,84 +459,121 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
272
459
  break;
273
460
  }
274
461
  }
275
- // Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
276
- // recompute them so they freeze at their final content. Everything below
277
- // the lower of the two cutoffs was already frozen last frame and replays.
278
- const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
279
- if (risk) this.#prevLiveStartIndex = liveStartIndex;
280
462
 
281
- const lines: string[] = [];
463
+ const lines = this.#lines;
464
+ const previousSegments = this.#segments;
465
+ const segments: BlockSegment[] = new Array(count);
466
+ // Poisoned until the walk completes: a block render throwing mid-walk
467
+ // leaves the persistent array half-rebuilt, and the next render must
468
+ // not trust stale segments against it. Restored at the end.
469
+ this.#segments = EMPTY_SEGMENTS;
470
+ const stableFloorBefore = this.#stableRowsFloor;
471
+ this.#stableRowsFloor = 0;
472
+ // Stability requires the same width and, per segment, the same block at
473
+ // the same offset returning the same array reference. The first
474
+ // divergence truncates the persistent array there; everything after
475
+ // re-pushes.
476
+ let chainStable = this.#renderWidth === width;
477
+ this.#renderWidth = width;
478
+ // Entry-unstable (width change): the divergence truncation inside the
479
+ // loop only fires on a stable→unstable transition, so reset the
480
+ // persistent array here to keep the `!chainStable ⇒ lines.length === row`
481
+ // invariant — otherwise re-pushed rows land after the stale frame.
482
+ if (!chainStable) lines.length = 0;
483
+
282
484
  // Tracks whether we are still inside the leading run of commit-safe live
283
485
  // blocks. The first still-live volatile block closes it, but rendering
284
486
  // continues so lower blocks remain visible.
285
487
  let commitSafeOpen = true;
286
- // The live-region start is recorded at the first visible row at/after the
287
- // cutoff; empty leading blocks (or a separator) must not claim it early.
488
+ // The live-region start is recorded at the first visible row at/after
489
+ // liveStartIndex; empty leading blocks (or a separator) must not claim it
490
+ // early.
288
491
  let liveRecorded = false;
492
+ // Frame row cursor: rows emitted (reused or pushed) so far.
493
+ let row = 0;
494
+ let stableRows = 0;
289
495
  for (let i = 0; i < count; i++) {
290
496
  const child = this.children[i]! as Component & SnapshotCarrier;
291
497
 
292
- // Resolve this child's contribution its visible body with plain-blank
293
- // top/bottom edges stripped (the container owns inter-block gaps). On
294
- // ED3-risk terminals a frozen, scrolled-off block replays its snapshot
295
- // instead of recomputing; a stale generation (post-thaw) or width
296
- // mismatch (resize) recomputes, as does a block still live last frame.
297
- let contribution: string[] | undefined;
298
- const previousSnapshot = risk ? child[kSnapshot] : undefined;
299
- if (risk && i < liveStartIndex && i < replayCutoff) {
300
- if (hasValidSnapshot(previousSnapshot, width, this.#generation)) {
301
- contribution = previousSnapshot.lines;
302
- }
303
- }
498
+ // This child's contribution: its current render with plain-blank
499
+ // top/bottom edges stripped (the container owns inter-block gaps).
500
+ // Always the latest content committed history keeps whatever bytes
501
+ // it was written with, but the window must reflect the present state
502
+ // (late tool results, post-finalize re-layouts, expand toggles).
503
+ // A block whose render returned the same array reference reuses the
504
+ // previously stripped contribution (same ref identical rows).
505
+ const previousSnapshot = child[kSnapshot];
506
+ const raw = child.render(width);
507
+ const previous = previousSegments[i];
508
+ const reusable =
509
+ previous !== undefined &&
510
+ previous.component === child &&
511
+ previous.rawRef === raw &&
512
+ previous.width === width;
513
+ const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
514
+ const finalized = isBlockFinalized(child);
304
515
  let liveCommitState: LiveCommitState | undefined;
305
- if (contribution === undefined) {
306
- const rendered = child.render(width);
307
- contribution = stripPlainBlankEdges(rendered);
308
- if (risk && i >= liveStartIndex && !isBlockFinalized(child)) {
309
- liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
310
- }
311
- // Cache every block's latest contribution. While a block is in the
312
- // live region this keeps its snapshot current; on the frame it crosses
313
- // out, the recompute above refreshes it before it freezes.
314
- if (risk) {
315
- child[kSnapshot] = {
316
- width,
317
- lines: contribution,
318
- generation: this.#generation,
319
- appendOnly: liveCommitState?.appendOnly ?? false,
320
- volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
321
- };
322
- }
516
+ if (i >= liveStartIndex && !finalized) {
517
+ liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
323
518
  }
519
+ // Cache the latest contribution as the next frame's diff input.
520
+ child[kSnapshot] = {
521
+ width,
522
+ lines: contribution,
523
+ generation: this.#generation,
524
+ appendOnly: liveCommitState?.appendOnly ?? false,
525
+ volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
526
+ stablePrefixLength: liveCommitState?.stablePrefixLength ?? 0,
527
+ candidatePrefixLength: liveCommitState?.candidatePrefixLength ?? 0,
528
+ candidatePrefixAge: liveCommitState?.candidatePrefixAge ?? 0,
529
+ rewriteFloor: liveCommitState?.rewriteFloor ?? Number.POSITIVE_INFINITY,
530
+ };
324
531
 
325
532
  // Empty (or stripped-to-nothing) children contribute nothing and never
326
533
  // affect spacing or the live-region offsets. An empty still-live child
327
534
  // still closes the commit-safe run: if it later gains rows, it pushes
328
535
  // everything below it.
329
536
  if (contribution.length === 0) {
330
- if (risk && i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
537
+ if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false;
538
+ if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) {
539
+ chainStable = false;
540
+ lines.length = row;
541
+ }
542
+ if (chainStable) stableRows = row;
543
+ segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount: 0, sep: 0 };
331
544
  continue;
332
545
  }
333
546
 
334
547
  // Every block is separated from preceding visible content by exactly one
335
548
  // blank row — skipped when it opens the transcript or the prior row is
336
549
  // already a plain blank (a fragment's own trailing pad), never doubling.
337
- const sep = lines.length > 0 && !isPlainBlank(lines[lines.length - 1]!) ? 1 : 0;
550
+ // `lines[row - 1]` is valid in both modes: reused rows are still present
551
+ // in the persistent array, re-pushed rows were just written.
552
+ const sep = row > 0 && !isPlainBlank(lines[row - 1]!) ? 1 : 0;
338
553
 
339
- // The separator before the first live block stays in the committed prefix
340
- // (it is deterministic and never changes once the prior block is frozen),
554
+ // The separator before the first live block stays in the committed
555
+ // prefix (it is deterministic once the prior block's body is settled),
341
556
  // so the live region begins at the block's first content row.
342
- if (risk && !liveRecorded && i >= liveStartIndex) {
343
- this.#nativeScrollbackLiveRegionStart = lines.length + sep;
557
+ if (!liveRecorded && i >= liveStartIndex) {
558
+ this.#nativeScrollbackLiveRegionStart = row + sep;
344
559
  liveRecorded = true;
345
560
  }
346
561
 
347
- if (sep) lines.push("");
348
- const blockStart = lines.length;
349
- for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
562
+ const rowCount = sep + contribution.length;
563
+ const stable = chainStable && reusable && previous.startRow === row && previous.sep === sep;
564
+ if (stable) {
565
+ stableRows = row + rowCount;
566
+ } else {
567
+ if (chainStable) {
568
+ chainStable = false;
569
+ lines.length = row;
570
+ }
571
+ if (sep) lines.push("");
572
+ for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
573
+ }
350
574
 
351
- if (risk && i >= liveStartIndex && commitSafeOpen) {
352
- const finalized = isBlockFinalized(child);
575
+ const blockStart = row + sep;
576
+ if (i >= liveStartIndex && commitSafeOpen) {
353
577
  const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
354
578
  if (safeLength > 0) {
355
579
  this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
@@ -359,7 +583,15 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
359
583
  // rows around as it grows, so the run closes there.
360
584
  if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
361
585
  }
586
+
587
+ segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount, sep };
588
+ row += rowCount;
362
589
  }
590
+ // Trailing shrink: blocks removed from the tail leave stale rows behind
591
+ // when every surviving segment was reused.
592
+ if (lines.length !== row) lines.length = row;
593
+ this.#segments = segments;
594
+ this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row);
363
595
  return lines;
364
596
  }
365
597
  }