@oh-my-pi/pi-coding-agent 15.10.10 → 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 (345) hide show
  1. package/CHANGELOG.md +95 -4
  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 +7 -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 -7
  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/eval/backend.d.ts +0 -2
  25. package/dist/types/eval/idle-timeout.d.ts +0 -4
  26. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  27. package/dist/types/export/html/template.generated.d.ts +1 -1
  28. package/dist/types/extensibility/extensions/types.d.ts +3 -3
  29. package/dist/types/hindsight/mental-models.d.ts +17 -8
  30. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  31. package/dist/types/internal-urls/types.d.ts +1 -1
  32. package/dist/types/lsp/edits.d.ts +9 -0
  33. package/dist/types/lsp/index.d.ts +2 -2
  34. package/dist/types/lsp/types.d.ts +2 -0
  35. package/dist/types/lsp/utils.d.ts +3 -0
  36. package/dist/types/mcp/json-rpc.d.ts +5 -0
  37. package/dist/types/mnemopi/state.d.ts +11 -1
  38. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  39. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  40. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  41. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  42. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  43. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  44. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  45. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  46. package/dist/types/modes/components/footer.d.ts +1 -1
  47. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  48. package/dist/types/modes/components/hook-input.d.ts +4 -0
  49. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +1 -1
  51. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  52. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  53. package/dist/types/modes/components/session-selector.d.ts +1 -1
  54. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  55. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  56. package/dist/types/modes/components/transcript-container.d.ts +25 -6
  57. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  58. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  59. package/dist/types/modes/components/user-message.d.ts +2 -1
  60. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  61. package/dist/types/modes/components/welcome.d.ts +19 -3
  62. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  63. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  64. package/dist/types/modes/interactive-mode.d.ts +1 -1
  65. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  66. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  67. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  68. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  69. package/dist/types/modes/types.d.ts +2 -1
  70. package/dist/types/session/agent-session.d.ts +1 -1
  71. package/dist/types/session/auth-broker-config.d.ts +4 -0
  72. package/dist/types/session/session-manager.d.ts +1 -1
  73. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  74. package/dist/types/ssh/connection-manager.d.ts +8 -0
  75. package/dist/types/task/parallel.d.ts +2 -2
  76. package/dist/types/task/worktree.d.ts +2 -0
  77. package/dist/types/tools/ask.d.ts +4 -0
  78. package/dist/types/tools/conflict-detect.d.ts +16 -0
  79. package/dist/types/tools/github-cache.d.ts +7 -0
  80. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  81. package/dist/types/tui/output-block.d.ts +3 -3
  82. package/dist/types/utils/changelog.d.ts +8 -0
  83. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  84. package/dist/types/web/scrapers/types.d.ts +12 -0
  85. package/dist/types/web/search/providers/codex.d.ts +1 -1
  86. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  87. package/examples/extensions/tools.ts +5 -4
  88. package/package.json +14 -11
  89. package/scripts/build-binary.ts +18 -23
  90. package/scripts/bundle-dist.ts +81 -0
  91. package/scripts/{dev-launch → omp} +1 -1
  92. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  93. package/src/async/job-manager.ts +57 -3
  94. package/src/autoresearch/dashboard.ts +1 -1
  95. package/src/autoresearch/prompt-setup.md +6 -6
  96. package/src/autoresearch/prompt.md +6 -6
  97. package/src/capability/fs.ts +10 -0
  98. package/src/cli/args.ts +1 -1
  99. package/src/cli/auth-gateway-cli.ts +1 -3
  100. package/src/cli/dry-balance-cli.ts +1 -1
  101. package/src/cli/gallery-cli.ts +1 -1
  102. package/src/cli/gallery-fixtures/fs.ts +1 -1
  103. package/src/cli/gallery-fixtures/types.ts +5 -1
  104. package/src/cli/list-models.ts +2 -1
  105. package/src/cli/usage-cli.ts +603 -0
  106. package/src/cli-commands.ts +1 -0
  107. package/src/cli.ts +69 -5
  108. package/src/commands/complete.ts +1 -1
  109. package/src/commands/launch.ts +1 -1
  110. package/src/commands/read.ts +6 -3
  111. package/src/commands/usage.ts +35 -0
  112. package/src/commit/agentic/agent.ts +1 -1
  113. package/src/commit/model-selection.ts +1 -1
  114. package/src/config/append-only-context-mode.ts +6 -12
  115. package/src/config/model-discovery.ts +554 -0
  116. package/src/config/model-registry.ts +231 -1019
  117. package/src/config/model-resolver.ts +113 -156
  118. package/src/config/model-roles.ts +74 -0
  119. package/src/config/models-config-schema.ts +57 -8
  120. package/src/config/models-config.ts +129 -0
  121. package/src/config/settings-schema.ts +18 -4
  122. package/src/config/settings.ts +37 -1
  123. package/src/dap/client.ts +124 -37
  124. package/src/dap/session.ts +259 -158
  125. package/src/debug/log-viewer.ts +1 -1
  126. package/src/debug/raw-sse.ts +1 -1
  127. package/src/edit/diff.ts +47 -3
  128. package/src/edit/hashline/block-resolver.ts +20 -1
  129. package/src/edit/hashline/diff.ts +36 -1
  130. package/src/edit/hashline/execute.ts +8 -2
  131. package/src/edit/index.ts +16 -1
  132. package/src/edit/modes/patch.ts +52 -0
  133. package/src/edit/modes/replace.ts +56 -22
  134. package/src/edit/notebook.ts +22 -2
  135. package/src/edit/renderer.ts +36 -10
  136. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  137. package/src/eval/backend.ts +0 -2
  138. package/src/eval/completion-bridge.ts +2 -1
  139. package/src/eval/idle-timeout.ts +2 -9
  140. package/src/eval/js/context-manager.ts +6 -8
  141. package/src/eval/js/executor.ts +6 -2
  142. package/src/eval/js/index.ts +0 -2
  143. package/src/eval/js/shared/helpers.ts +5 -6
  144. package/src/eval/js/shared/local-module-loader.ts +1 -1
  145. package/src/eval/js/shared/prelude.txt +62 -1
  146. package/src/eval/js/shared/rewrite-imports.ts +40 -22
  147. package/src/eval/js/shared/runtime.ts +1 -1
  148. package/src/eval/py/index.ts +0 -2
  149. package/src/eval/py/kernel.ts +19 -0
  150. package/src/eval/py/runner.py +107 -3
  151. package/src/exec/bash-executor.ts +3 -1
  152. package/src/export/html/template.generated.ts +1 -1
  153. package/src/export/html/template.js +3 -1
  154. package/src/extensibility/extensions/types.ts +3 -2
  155. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  156. package/src/hindsight/mental-models.ts +59 -12
  157. package/src/hindsight/state.ts +6 -1
  158. package/src/internal-urls/artifact-protocol.ts +11 -2
  159. package/src/internal-urls/docs-index.generated.ts +8 -8
  160. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  161. package/src/internal-urls/router.ts +1 -1
  162. package/src/internal-urls/types.ts +1 -1
  163. package/src/lib/xai-http.ts +1 -1
  164. package/src/lsp/client.ts +118 -38
  165. package/src/lsp/clients/biome-client.ts +101 -39
  166. package/src/lsp/edits.ts +143 -95
  167. package/src/lsp/index.ts +31 -22
  168. package/src/lsp/render.ts +1 -1
  169. package/src/lsp/types.ts +2 -0
  170. package/src/lsp/utils.ts +28 -10
  171. package/src/main.ts +165 -17
  172. package/src/mcp/json-rpc.ts +35 -5
  173. package/src/mcp/transports/stdio.ts +7 -1
  174. package/src/memories/index.ts +2 -1
  175. package/src/mnemopi/backend.ts +25 -3
  176. package/src/mnemopi/state.ts +38 -2
  177. package/src/modes/components/agent-dashboard.ts +10 -7
  178. package/src/modes/components/assistant-message.ts +19 -13
  179. package/src/modes/components/bash-execution.ts +1 -1
  180. package/src/modes/components/copy-selector.ts +1 -1
  181. package/src/modes/components/diff.ts +13 -2
  182. package/src/modes/components/dynamic-border.ts +12 -3
  183. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  184. package/src/modes/components/extensions/extension-list.ts +1 -1
  185. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  186. package/src/modes/components/footer.ts +1 -1
  187. package/src/modes/components/history-search.ts +1 -1
  188. package/src/modes/components/hook-editor.ts +8 -0
  189. package/src/modes/components/hook-input.ts +8 -0
  190. package/src/modes/components/hook-selector.ts +2 -2
  191. package/src/modes/components/model-selector.ts +4 -2
  192. package/src/modes/components/plan-review-overlay.ts +1 -1
  193. package/src/modes/components/session-observer-overlay.ts +2 -2
  194. package/src/modes/components/session-selector.ts +1 -1
  195. package/src/modes/components/settings-selector.ts +5 -1
  196. package/src/modes/components/status-line/component.ts +1 -1
  197. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  198. package/src/modes/components/transcript-container.ts +258 -53
  199. package/src/modes/components/tree-selector.ts +3 -3
  200. package/src/modes/components/user-message-selector.ts +1 -1
  201. package/src/modes/components/user-message.ts +17 -5
  202. package/src/modes/components/visual-truncate.ts +1 -1
  203. package/src/modes/components/welcome.ts +108 -26
  204. package/src/modes/controllers/command-controller.ts +10 -3
  205. package/src/modes/controllers/event-controller.ts +73 -4
  206. package/src/modes/controllers/input-controller.ts +1 -1
  207. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  208. package/src/modes/controllers/selector-controller.ts +1 -1
  209. package/src/modes/controllers/streaming-reveal.ts +85 -18
  210. package/src/modes/interactive-mode.ts +3 -9
  211. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  212. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  213. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  214. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  215. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  216. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  217. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  218. package/src/modes/types.ts +2 -1
  219. package/src/prompts/agents/explore.md +2 -2
  220. package/src/prompts/agents/librarian.md +1 -2
  221. package/src/prompts/agents/oracle.md +1 -1
  222. package/src/prompts/agents/plan.md +5 -5
  223. package/src/prompts/agents/task.md +5 -5
  224. package/src/prompts/ci-green-request.md +5 -7
  225. package/src/prompts/goals/goal-budget-limit.md +2 -2
  226. package/src/prompts/goals/goal-continuation.md +4 -4
  227. package/src/prompts/goals/goal-mode-active.md +1 -1
  228. package/src/prompts/memories/read-path.md +1 -1
  229. package/src/prompts/memories/stage_one_system.md +2 -2
  230. package/src/prompts/review-custom-request.md +1 -1
  231. package/src/prompts/system/agent-creation-architect.md +2 -2
  232. package/src/prompts/system/auto-continue.md +1 -1
  233. package/src/prompts/system/background-tan-dispatch.md +1 -1
  234. package/src/prompts/system/btw-user.md +2 -2
  235. package/src/prompts/system/commit-message-system.md +13 -1
  236. package/src/prompts/system/custom-system-prompt.md +1 -1
  237. package/src/prompts/system/eager-todo.md +2 -2
  238. package/src/prompts/system/irc-incoming.md +1 -1
  239. package/src/prompts/system/manual-continue.md +1 -1
  240. package/src/prompts/system/omfg-user.md +3 -4
  241. package/src/prompts/system/orchestrate-notice.md +9 -9
  242. package/src/prompts/system/plan-mode-active.md +4 -4
  243. package/src/prompts/system/plan-mode-subagent.md +4 -5
  244. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  245. package/src/prompts/system/project-prompt.md +2 -2
  246. package/src/prompts/system/subagent-system-prompt.md +4 -4
  247. package/src/prompts/system/system-prompt.md +13 -24
  248. package/src/prompts/system/title-system.md +2 -2
  249. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  250. package/src/prompts/system/workflow-notice.md +1 -1
  251. package/src/prompts/tools/ast-edit.md +1 -1
  252. package/src/prompts/tools/ast-grep.md +2 -2
  253. package/src/prompts/tools/bash.md +5 -7
  254. package/src/prompts/tools/browser.md +7 -7
  255. package/src/prompts/tools/debug.md +1 -1
  256. package/src/prompts/tools/eval.md +3 -3
  257. package/src/prompts/tools/find.md +0 -1
  258. package/src/prompts/tools/github.md +8 -7
  259. package/src/prompts/tools/goal.md +1 -1
  260. package/src/prompts/tools/image-gen.md +1 -1
  261. package/src/prompts/tools/inspect-image-system.md +1 -1
  262. package/src/prompts/tools/irc.md +15 -15
  263. package/src/prompts/tools/lsp.md +2 -2
  264. package/src/prompts/tools/patch.md +2 -2
  265. package/src/prompts/tools/read.md +3 -4
  266. package/src/prompts/tools/recall.md +1 -1
  267. package/src/prompts/tools/reflect.md +1 -1
  268. package/src/prompts/tools/render-mermaid.md +2 -2
  269. package/src/prompts/tools/replace.md +4 -10
  270. package/src/prompts/tools/rewind.md +2 -2
  271. package/src/prompts/tools/search-tool-bm25.md +1 -9
  272. package/src/prompts/tools/search.md +0 -1
  273. package/src/prompts/tools/ssh.md +0 -4
  274. package/src/prompts/tools/task.md +2 -3
  275. package/src/prompts/tools/todo.md +1 -1
  276. package/src/sdk.ts +23 -10
  277. package/src/session/agent-session.ts +44 -10
  278. package/src/session/auth-broker-config.ts +30 -1
  279. package/src/session/session-manager.ts +2 -2
  280. package/src/session/streaming-output.ts +23 -2
  281. package/src/slash-commands/builtin-registry.ts +20 -0
  282. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  283. package/src/ssh/connection-manager.ts +27 -0
  284. package/src/task/commands.ts +2 -1
  285. package/src/task/executor.ts +61 -53
  286. package/src/task/index.ts +137 -60
  287. package/src/task/parallel.ts +3 -3
  288. package/src/task/render.ts +2 -2
  289. package/src/task/worktree.ts +64 -56
  290. package/src/thinking.ts +2 -1
  291. package/src/tiny/title-client.ts +26 -11
  292. package/src/tools/archive-reader.ts +30 -2
  293. package/src/tools/ask.ts +104 -21
  294. package/src/tools/ast-edit.ts +25 -5
  295. package/src/tools/auto-generated-guard.ts +20 -3
  296. package/src/tools/bash-interactive.ts +27 -7
  297. package/src/tools/bash.ts +54 -13
  298. package/src/tools/browser/launch.ts +11 -2
  299. package/src/tools/browser/readable.ts +19 -2
  300. package/src/tools/browser/registry.ts +4 -1
  301. package/src/tools/browser/render.ts +2 -2
  302. package/src/tools/browser/tab-supervisor.ts +55 -16
  303. package/src/tools/conflict-detect.ts +50 -4
  304. package/src/tools/debug.ts +1 -1
  305. package/src/tools/eval-render.ts +5 -5
  306. package/src/tools/eval.ts +0 -2
  307. package/src/tools/fetch.ts +33 -10
  308. package/src/tools/gh-cache-invalidation.ts +63 -8
  309. package/src/tools/gh-renderer.ts +1 -1
  310. package/src/tools/gh.ts +172 -29
  311. package/src/tools/github-cache.ts +70 -6
  312. package/src/tools/image-gen.ts +3 -9
  313. package/src/tools/irc.ts +5 -1
  314. package/src/tools/job.ts +1 -1
  315. package/src/tools/read.ts +202 -61
  316. package/src/tools/render-utils.ts +3 -3
  317. package/src/tools/resolve.ts +1 -1
  318. package/src/tools/search.ts +92 -29
  319. package/src/tools/sqlite-reader.ts +17 -5
  320. package/src/tools/ssh.ts +8 -8
  321. package/src/tools/todo.ts +38 -8
  322. package/src/tools/write.ts +118 -18
  323. package/src/tui/output-block.ts +4 -4
  324. package/src/utils/changelog.ts +27 -1
  325. package/src/utils/file-mentions.ts +2 -1
  326. package/src/web/scrapers/arxiv.ts +1 -1
  327. package/src/web/scrapers/go-pkg.ts +1 -1
  328. package/src/web/scrapers/iacr.ts +1 -1
  329. package/src/web/scrapers/readthedocs.ts +1 -1
  330. package/src/web/scrapers/twitter.ts +2 -1
  331. package/src/web/scrapers/types.ts +87 -8
  332. package/src/web/scrapers/wikipedia.ts +1 -1
  333. package/src/web/scrapers/youtube.ts +6 -1
  334. package/src/web/search/index.ts +1 -1
  335. package/src/web/search/providers/codex.ts +2 -1
  336. package/src/web/search/providers/gemini.ts +2 -3
  337. package/src/web/search/render.ts +8 -6
  338. package/dist/types/config/model-equivalence.d.ts +0 -24
  339. package/dist/types/config/model-id-affixes.d.ts +0 -12
  340. package/dist/types/config/model-provider-priority.d.ts +0 -1
  341. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  342. package/src/config/model-equivalence.ts +0 -875
  343. package/src/config/model-id-affixes.ts +0 -81
  344. package/src/config/model-provider-priority.ts +0 -56
  345. package/src/exec/idle-timeout-watchdog.ts +0 -126
@@ -1,4 +1,4 @@
1
- import { type Component, Container, type NativeScrollbackLiveRegion } from "@oh-my-pi/pi-tui";
1
+ import { type Component, Container, type NativeScrollbackLiveRegion, type RenderStablePrefix } from "@oh-my-pi/pi-tui";
2
2
 
3
3
  const kSnapshot = Symbol("transcript.liveDiffSnapshot");
4
4
 
@@ -10,7 +10,7 @@ const kSnapshot = Symbol("transcript.liveDiffSnapshot");
10
10
  */
11
11
  interface LiveDiffSnapshot {
12
12
  width: number;
13
- lines: string[];
13
+ lines: readonly string[];
14
14
  generation: number;
15
15
  appendOnly: boolean;
16
16
  /**
@@ -27,6 +27,12 @@ interface LiveDiffSnapshot {
27
27
  stablePrefixLength: number;
28
28
  candidatePrefixLength: number;
29
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;
30
36
  }
31
37
 
32
38
  interface SnapshotCarrier {
@@ -60,7 +66,7 @@ function isPlainBlank(line: string): boolean {
60
66
  // Strip leading/trailing plain-blank rows so each block contributes only its
61
67
  // visible body; the container owns the gaps between blocks. Returns the input
62
68
  // array unchanged when there is nothing to trim (no allocation on the hot path).
63
- function stripPlainBlankEdges(lines: string[]): string[] {
69
+ function stripPlainBlankEdges(lines: readonly string[]): readonly string[] {
64
70
  let start = 0;
65
71
  let end = lines.length;
66
72
  while (start < end && isPlainBlank(lines[start]!)) start++;
@@ -68,12 +74,35 @@ function stripPlainBlankEdges(lines: string[]): string[] {
68
74
  return start === 0 && end === lines.length ? lines : lines.slice(start, end);
69
75
  }
70
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
+
71
99
  interface LiveCommitState {
72
100
  appendOnly: boolean;
73
101
  volatileCooldown: number;
74
102
  stablePrefixLength: number;
75
103
  candidatePrefixLength: number;
76
104
  candidatePrefixAge: number;
105
+ rewriteFloor: number;
77
106
  safeLength: number;
78
107
  }
79
108
 
@@ -106,6 +135,22 @@ const VOLATILE_REARM_FRAMES = 30;
106
135
  */
107
136
  const STABLE_PREFIX_COMMIT_FRAMES = 30;
108
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
+
109
154
  /**
110
155
  * Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
111
156
  * write framing, not content. A styled line's closing escape moves when the
@@ -124,6 +169,15 @@ function rowsVisiblyEqual(prev: string, cur: string): boolean {
124
169
  return prev === cur || normalizeRow(prev) === normalizeRow(cur);
125
170
  }
126
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
+
127
181
  function hasValidSnapshot(
128
182
  snapshot: LiveDiffSnapshot | undefined,
129
183
  width: number,
@@ -132,14 +186,14 @@ function hasValidSnapshot(
132
186
  return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
133
187
  }
134
188
 
135
- function commonPrefixLength(prev: string[], cur: string[]): number {
189
+ function commonPrefixLength(prev: readonly string[], cur: readonly string[]): number {
136
190
  const limit = Math.min(prev.length, cur.length);
137
191
  let i = 0;
138
192
  while (i < limit && rowsVisiblyEqual(prev[i]!, cur[i]!)) i++;
139
193
  return i;
140
194
  }
141
195
 
142
- function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
196
+ function commonSuffixLength(prev: readonly string[], cur: readonly string[], prefixLength: number): number {
143
197
  const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
144
198
  let i = 0;
145
199
  while (i < limit && rowsVisiblyEqual(prev[prev.length - 1 - i]!, cur[cur.length - 1 - i]!)) i++;
@@ -148,7 +202,7 @@ function commonSuffixLength(prev: string[], cur: string[], prefixLength: number)
148
202
 
149
203
  function deriveLiveCommitState(
150
204
  previous: LiveDiffSnapshot | undefined,
151
- current: string[],
205
+ current: readonly string[],
152
206
  width: number,
153
207
  generation: number,
154
208
  ): LiveCommitState {
@@ -157,12 +211,15 @@ function deriveLiveCommitState(
157
211
  let stablePrefixLength = 0;
158
212
  let candidatePrefixLength = 0;
159
213
  let candidatePrefixAge = 0;
214
+ let rewriteFloor = Number.POSITIVE_INFINITY;
215
+ let trailingRowGrowth = false;
160
216
  if (hasValidSnapshot(previous, width, generation)) {
161
217
  appendOnly = previous.appendOnly;
162
218
  volatileCooldown = previous.volatileCooldown;
163
219
  stablePrefixLength = previous.stablePrefixLength;
164
220
  candidatePrefixLength = previous.candidatePrefixLength;
165
221
  candidatePrefixAge = previous.candidatePrefixAge;
222
+ rewriteFloor = previous.rewriteFloor;
166
223
 
167
224
  const prefixLength = commonPrefixLength(previous.lines, current);
168
225
  const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
@@ -170,32 +227,50 @@ function deriveLiveCommitState(
170
227
  if (!staticRender) {
171
228
  const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
172
229
  // Append-only growth never rewrites a row that may already have scrolled
173
- // into native scrollback; it only grows the block at/near its tail. Four
174
- // shapes qualify: a pure bottom append, an insertion above stable trailing
175
- // chrome (a streaming tool's footer/border), an in-place extension of the
176
- // current line by one streamed token (line count unchanged), and a
177
- // wrap-shrink of the current line where its last word grew past the wrap
178
- // column and moved down onto an appended row. The first two preserve every
179
- // previous row across a matching prefix + suffix; the last two leave a
180
- // single divergent previous row the block's in-flight bottom line, which
181
- // cannot have been committed (commits stop at the viewport top and the
182
- // bottom line is by definition on screen). Any other divergent interior
183
- // row means the block re-laid-out committed-candidate content a rewrite,
184
- // 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.
185
249
  const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
186
- let tailExtendedInPlace = false;
187
- if (
188
- !preservedEveryRow &&
189
- prefixLength + suffixLength === previous.lines.length - 1 &&
190
- prefixLength < current.length
191
- ) {
192
- const prevTail = normalizeRow(previous.lines[prefixLength]!);
193
- const curTail = normalizeRow(current[prefixLength]!);
194
- tailExtendedInPlace =
195
- curTail.startsWith(prevTail) || (current.length > previous.lines.length && prevTail.startsWith(curTail));
196
- }
197
- 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]!);
198
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
+ }
199
274
  } else {
200
275
  cleanFrame = false;
201
276
  appendOnly = false;
@@ -210,10 +285,23 @@ function deriveLiveCommitState(
210
285
  // promotion means every promoted row stayed identical for the whole
211
286
  // window (row r is inside frame i's common prefix iff r < p_i, so
212
287
  // r < min(p) holds for every frame of the window). A row settling
213
- // mid-window promotes at most two windows later. A change above the
214
- // already-promoted run retreats it to the divergence — the engine
215
- // audit owns any rows that already committed (recommit, never loss).
288
+ // mid-window promotes at most two windows later. The engine audit owns
289
+ // any promoted rows that already committed (recommit, never loss).
216
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);
217
305
  stablePrefixLength = prefixLength;
218
306
  candidatePrefixLength = prefixLength;
219
307
  candidatePrefixAge = 0;
@@ -222,7 +310,15 @@ function deriveLiveCommitState(
222
310
  candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength);
223
311
  candidatePrefixAge++;
224
312
  if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) {
225
- stablePrefixLength = candidatePrefixLength;
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
+ );
226
322
  candidatePrefixLength = prefixLength;
227
323
  candidatePrefixAge = 0;
228
324
  }
@@ -235,16 +331,25 @@ function deriveLiveCommitState(
235
331
  stablePrefixLength,
236
332
  candidatePrefixLength,
237
333
  candidatePrefixAge,
238
- // An append-only block's whole body is committable; otherwise the
239
- // settled head still is only the volatile tail stays deferred.
240
- safeLength: appendOnly ? current.length : stablePrefixLength,
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,
241
346
  };
242
347
  }
243
348
 
244
349
  /**
245
- * Transcript container that always renders every block's current content and
246
- * reports the live-region seam (`NativeScrollbackLiveRegion`) that gates the
247
- * engine's append-only scrollback commits.
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.
248
353
  *
249
354
  * The engine never rewrites committed history: rows above the seam that have
250
355
  * entered the tape keep whatever bytes they were committed with ("let the
@@ -255,8 +360,16 @@ function deriveLiveCommitState(
255
360
  * their rows do not enter history while they can still change; a streaming
256
361
  * block whose render grows append-only deepens the seam through its settled
257
362
  * head so a long reply's scrolled-off rows still reach scrollback mid-stream.
363
+ *
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.
258
371
  */
259
- export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
372
+ export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion, RenderStablePrefix {
260
373
  // Bumped to retire every block's diff snapshot at once (theme change /
261
374
  // clear); a snapshot is only honored when its stored generation matches.
262
375
  #generation = 0;
@@ -272,7 +385,16 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
272
385
  // until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames;
273
386
  // the engine then backfills the stalled gap.
274
387
  #nativeScrollbackCommitSafeEnd: number | undefined;
275
-
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;
276
398
  override invalidate(): void {
277
399
  // Theme/global invalidation: retire every diff snapshot so stale styling
278
400
  // is not diffed against the recolored render.
@@ -285,6 +407,12 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
285
407
  super.clear();
286
408
  }
287
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
+
288
416
  getNativeScrollbackLiveRegionStart(): number | undefined {
289
417
  return this.#nativeScrollbackLiveRegionStart;
290
418
  }
@@ -293,7 +421,25 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
293
421
  return this.#nativeScrollbackCommitSafeEnd;
294
422
  }
295
423
 
296
- override render(width: number): string[] {
424
+ /**
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.
432
+ */
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;
440
+ }
441
+
442
+ override render(width: number): readonly string[] {
297
443
  width = Math.max(1, width);
298
444
  this.#nativeScrollbackLiveRegionStart = undefined;
299
445
  this.#nativeScrollbackCommitSafeEnd = undefined;
@@ -314,7 +460,27 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
314
460
  }
315
461
  }
316
462
 
317
- 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
+
318
484
  // Tracks whether we are still inside the leading run of commit-safe live
319
485
  // blocks. The first still-live volatile block closes it, but rendering
320
486
  // continues so lower blocks remain visible.
@@ -323,6 +489,9 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
323
489
  // liveStartIndex; empty leading blocks (or a separator) must not claim it
324
490
  // early.
325
491
  let liveRecorded = false;
492
+ // Frame row cursor: rows emitted (reused or pushed) so far.
493
+ let row = 0;
494
+ let stableRows = 0;
326
495
  for (let i = 0; i < count; i++) {
327
496
  const child = this.children[i]! as Component & SnapshotCarrier;
328
497
 
@@ -331,10 +500,20 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
331
500
  // Always the latest content — committed history keeps whatever bytes
332
501
  // it was written with, but the window must reflect the present state
333
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).
334
505
  const previousSnapshot = child[kSnapshot];
335
- const contribution = stripPlainBlankEdges(child.render(width));
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);
336
515
  let liveCommitState: LiveCommitState | undefined;
337
- if (i >= liveStartIndex && !isBlockFinalized(child)) {
516
+ if (i >= liveStartIndex && !finalized) {
338
517
  liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
339
518
  }
340
519
  // Cache the latest contribution as the next frame's diff input.
@@ -347,6 +526,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
347
526
  stablePrefixLength: liveCommitState?.stablePrefixLength ?? 0,
348
527
  candidatePrefixLength: liveCommitState?.candidatePrefixLength ?? 0,
349
528
  candidatePrefixAge: liveCommitState?.candidatePrefixAge ?? 0,
529
+ rewriteFloor: liveCommitState?.rewriteFloor ?? Number.POSITIVE_INFINITY,
350
530
  };
351
531
 
352
532
  // Empty (or stripped-to-nothing) children contribute nothing and never
@@ -354,29 +534,46 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
354
534
  // still closes the commit-safe run: if it later gains rows, it pushes
355
535
  // everything below it.
356
536
  if (contribution.length === 0) {
357
- if (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 };
358
544
  continue;
359
545
  }
360
546
 
361
547
  // Every block is separated from preceding visible content by exactly one
362
548
  // blank row — skipped when it opens the transcript or the prior row is
363
549
  // already a plain blank (a fragment's own trailing pad), never doubling.
364
- 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;
365
553
 
366
554
  // The separator before the first live block stays in the committed
367
555
  // prefix (it is deterministic once the prior block's body is settled),
368
556
  // so the live region begins at the block's first content row.
369
557
  if (!liveRecorded && i >= liveStartIndex) {
370
- this.#nativeScrollbackLiveRegionStart = lines.length + sep;
558
+ this.#nativeScrollbackLiveRegionStart = row + sep;
371
559
  liveRecorded = true;
372
560
  }
373
561
 
374
- if (sep) lines.push("");
375
- const blockStart = lines.length;
376
- 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
+ }
377
574
 
575
+ const blockStart = row + sep;
378
576
  if (i >= liveStartIndex && commitSafeOpen) {
379
- const finalized = isBlockFinalized(child);
380
577
  const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
381
578
  if (safeLength > 0) {
382
579
  this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
@@ -386,7 +583,15 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
386
583
  // rows around as it grows, so the run closes there.
387
584
  if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
388
585
  }
586
+
587
+ segments[i] = { component: child, rawRef: raw, contribution, width, startRow: row, rowCount, sep };
588
+ row += rowCount;
389
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);
390
595
  return lines;
391
596
  }
392
597
  }
@@ -438,7 +438,7 @@ class TreeList implements Component {
438
438
  }
439
439
  }
440
440
 
441
- render(width: number): string[] {
441
+ render(width: number): readonly string[] {
442
442
  const lines: string[] = [];
443
443
 
444
444
  if (this.#filteredNodes.length === 0) {
@@ -835,7 +835,7 @@ class SearchLine implements Component {
835
835
 
836
836
  invalidate(): void {}
837
837
 
838
- render(width: number): string[] {
838
+ render(width: number): readonly string[] {
839
839
  const query = this.treeList.getSearchQuery();
840
840
  if (query) {
841
841
  return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)];
@@ -864,7 +864,7 @@ class LabelInput implements Component {
864
864
 
865
865
  invalidate(): void {}
866
866
 
867
- render(width: number): string[] {
867
+ render(width: number): readonly string[] {
868
868
  const lines: string[] = [];
869
869
  const indent = " ";
870
870
  const availableWidth = width - indent.length;
@@ -82,7 +82,7 @@ class UserMessageList implements Component {
82
82
  return true;
83
83
  }
84
84
 
85
- render(width: number): string[] {
85
+ render(width: number): readonly string[] {
86
86
  const lines: string[] = [];
87
87
 
88
88
  if (this.messages.length === 0) {
@@ -12,6 +12,13 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
12
12
  * Component that renders a user message
13
13
  */
14
14
  export class UserMessageComponent extends Container {
15
+ // Memoized OSC 133 zone wrapping keyed on the underlying container render
16
+ // (same source ref ⇒ identical rows ⇒ reuse the wrapped copy). Keeps this
17
+ // component reference-stable for the transcript's incremental assembly and
18
+ // never mutates the container's cached array.
19
+ #zoneSource: readonly string[] | undefined;
20
+ #zoneLines: string[] | undefined;
21
+
15
22
  constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
16
23
  super();
17
24
  const bgColor = (value: string) => theme.bg("userMessageBg", value);
@@ -41,14 +48,19 @@ export class UserMessageComponent extends Container {
41
48
  );
42
49
  }
43
50
 
44
- override render(width: number): string[] {
51
+ override render(width: number): readonly string[] {
45
52
  const lines = super.render(width);
46
53
  if (lines.length === 0) {
47
54
  return lines;
48
55
  }
49
-
50
- lines[0] = OSC133_ZONE_START + lines[0];
51
- lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
52
- return lines;
56
+ if (this.#zoneSource === lines && this.#zoneLines !== undefined) {
57
+ return this.#zoneLines;
58
+ }
59
+ const wrapped = lines.slice();
60
+ wrapped[0] = OSC133_ZONE_START + wrapped[0];
61
+ wrapped[wrapped.length - 1] = wrapped[wrapped.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
62
+ this.#zoneSource = lines;
63
+ this.#zoneLines = wrapped;
64
+ return wrapped;
53
65
  }
54
66
  }
@@ -6,7 +6,7 @@ import { Text } from "@oh-my-pi/pi-tui";
6
6
 
7
7
  export interface VisualTruncateResult {
8
8
  /** The visual lines to display */
9
- visualLines: string[];
9
+ visualLines: readonly string[];
10
10
  /** Number of visual lines that were skipped (hidden) */
11
11
  skippedCount: number;
12
12
  }