@linxiraos/pi-tui 1.1.26 → 1.2.0

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 (423) hide show
  1. package/CHANGELOG.md +51 -491
  2. package/README.md +13 -7
  3. package/dist/types/active-terminal.d.ts +30 -0
  4. package/dist/types/app-keybindings.d.ts +7 -2
  5. package/dist/types/apps/cleanse-board.d.ts +1 -1
  6. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  7. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  8. package/dist/types/apps/git/git-tui.d.ts +5 -4
  9. package/dist/types/apps/git/help.d.ts +3 -1
  10. package/dist/types/apps/if-bench-board.d.ts +2 -2
  11. package/dist/types/apps/setup-model-picker.d.ts +1 -1
  12. package/dist/types/autocomplete.d.ts +5 -2
  13. package/dist/types/charts/chart-plan.d.ts +674 -0
  14. package/dist/types/charts/chart-svg.d.ts +59 -0
  15. package/dist/types/charts/table-data.d.ts +175 -0
  16. package/dist/types/chat/assistant-message.d.ts +11 -1
  17. package/dist/types/chat/chat-transcript-builder.d.ts +3 -0
  18. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  19. package/dist/types/chat/execution-shared.d.ts +6 -5
  20. package/dist/types/chat/fence-figure.d.ts +42 -0
  21. package/dist/types/chat/figure-markdown.d.ts +42 -0
  22. package/dist/types/chat/messages.d.ts +1 -1
  23. package/dist/types/chat/svg-figure.d.ts +33 -0
  24. package/dist/types/chat/svg-source.d.ts +49 -0
  25. package/dist/types/chat/table-chart.d.ts +89 -0
  26. package/dist/types/chat/tool-execution.d.ts +2 -0
  27. package/dist/types/chat/user-message.d.ts +17 -4
  28. package/dist/types/chrome/shared.d.ts +9 -0
  29. package/dist/types/chrome/status-notice.d.ts +15 -3
  30. package/dist/types/chrome/transcript-container.d.ts +6 -0
  31. package/dist/types/components/box.d.ts +1 -0
  32. package/dist/types/components/disclosure.d.ts +1 -0
  33. package/dist/types/components/editor.d.ts +23 -5
  34. package/dist/types/components/form.d.ts +1 -0
  35. package/dist/types/components/image.d.ts +26 -44
  36. package/dist/types/components/input.d.ts +2 -1
  37. package/dist/types/components/key-value-list.d.ts +1 -0
  38. package/dist/types/components/layout/row.d.ts +1 -0
  39. package/dist/types/components/layout/stack.d.ts +1 -0
  40. package/dist/types/components/loader.d.ts +5 -6
  41. package/dist/types/components/markdown.d.ts +47 -5
  42. package/dist/types/components/scroll-view.d.ts +1 -0
  43. package/dist/types/components/section.d.ts +1 -0
  44. package/dist/types/components/table.d.ts +1 -0
  45. package/dist/types/components/text.d.ts +1 -0
  46. package/dist/types/components/tree-view.d.ts +7 -0
  47. package/dist/types/components/truncated-text.d.ts +1 -0
  48. package/dist/types/fuzzy.d.ts +29 -1
  49. package/dist/types/key-hint-format.d.ts +9 -1
  50. package/dist/types/keybinding-matchers.d.ts +3 -0
  51. package/dist/types/keybindings.d.ts +1 -1
  52. package/dist/types/kitty-graphics.d.ts +2 -2
  53. package/dist/types/loop-watchdog.d.ts +8 -7
  54. package/dist/types/multiplexers/cmux.d.ts +16 -0
  55. package/dist/types/multiplexers/herdr.d.ts +26 -0
  56. package/dist/types/multiplexers/notify.d.ts +13 -0
  57. package/dist/types/multiplexers/orca.d.ts +12 -0
  58. package/dist/types/multiplexers/screen.d.ts +9 -0
  59. package/dist/types/multiplexers/tmux.d.ts +29 -0
  60. package/dist/types/multiplexers/types.d.ts +100 -0
  61. package/dist/types/multiplexers/wmux.d.ts +12 -0
  62. package/dist/types/multiplexers/zellij.d.ts +12 -0
  63. package/dist/types/native/apply.d.ts +4 -3
  64. package/dist/types/native/backend.d.ts +5 -4
  65. package/dist/types/native/blobs.d.ts +7 -3
  66. package/dist/types/native/encode.d.ts +8 -6
  67. package/dist/types/native/node.d.ts +36 -5
  68. package/dist/types/native/overlay.d.ts +8 -3
  69. package/dist/types/native/settle.d.ts +4 -3
  70. package/dist/types/overlays/agent-hub-projection.d.ts +6 -5
  71. package/dist/types/overlays/agent-hub-types.d.ts +9 -0
  72. package/dist/types/overlays/agent-hub.d.ts +3 -0
  73. package/dist/types/overlays/agent-transcript-viewer.d.ts +12 -2
  74. package/dist/types/overlays/agents-hub.d.ts +9 -0
  75. package/dist/types/overlays/annotation-overlay.d.ts +19 -2
  76. package/dist/types/overlays/annotation-types.d.ts +2 -0
  77. package/dist/types/overlays/ask-dialog.d.ts +5 -2
  78. package/dist/types/overlays/btw-history-panel.d.ts +13 -1
  79. package/dist/types/overlays/copy-selector.d.ts +1 -0
  80. package/dist/types/overlays/history-search.d.ts +10 -0
  81. package/dist/types/overlays/hook-selector.d.ts +9 -4
  82. package/dist/types/overlays/hub-frame.d.ts +4 -1
  83. package/dist/types/overlays/jobs-panel.d.ts +1 -7
  84. package/dist/types/overlays/model-browser.d.ts +54 -7
  85. package/dist/types/overlays/model-hub.d.ts +37 -0
  86. package/dist/types/overlays/oauth-selector.d.ts +0 -1
  87. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  88. package/dist/types/overlays/report-panel.d.ts +68 -0
  89. package/dist/types/overlays/rewind-selector.d.ts +1 -0
  90. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  91. package/dist/types/overlays/session-selector.d.ts +6 -2
  92. package/dist/types/overlays/settings-defs.d.ts +1 -0
  93. package/dist/types/overlays/thinking-selector.d.ts +2 -2
  94. package/dist/types/overlays/usage-dashboard.d.ts +12 -8
  95. package/dist/types/overlays/usage-display.d.ts +3 -1
  96. package/dist/types/prompt/composer-cache.d.ts +0 -16
  97. package/dist/types/prompt/composer.d.ts +18 -3
  98. package/dist/types/prompt/custom-editor.d.ts +25 -13
  99. package/dist/types/prompt/image-source.d.ts +2 -1
  100. package/dist/types/prompt/interactive-context-helpers.d.ts +5 -1
  101. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  102. package/dist/types/prompt/welcome.d.ts +7 -2
  103. package/dist/types/render/composite.d.ts +2 -0
  104. package/dist/types/render/output-pane.d.ts +1 -0
  105. package/dist/types/render/render-utils.d.ts +1 -0
  106. package/dist/types/render/tool-card.d.ts +6 -0
  107. package/dist/types/render/width-aware-text.d.ts +2 -0
  108. package/dist/types/setup/scenes/types.d.ts +1 -0
  109. package/dist/types/space-hold.d.ts +40 -25
  110. package/dist/types/status-line/component.d.ts +3 -0
  111. package/dist/types/status-line/context-usage.d.ts +7 -5
  112. package/dist/types/status-line/host.d.ts +4 -1
  113. package/dist/types/status-line/segments.d.ts +2 -0
  114. package/dist/types/status-line/types.d.ts +14 -3
  115. package/dist/types/terminal-capabilities.d.ts +41 -36
  116. package/dist/types/terminal-multiplexer.d.ts +127 -14
  117. package/dist/types/terminal.d.ts +23 -16
  118. package/dist/types/theme/active-symbols.d.ts +1 -1
  119. package/dist/types/theme/schema.d.ts +11 -2
  120. package/dist/types/theme/symbols.d.ts +1 -1
  121. package/dist/types/theme/theme-class.d.ts +7 -0
  122. package/dist/types/theme/theme.d.ts +1 -1
  123. package/dist/types/theme/tui-adapters.d.ts +3 -0
  124. package/dist/types/thinking.d.ts +1 -1
  125. package/dist/types/tmux.d.ts +11 -4
  126. package/dist/types/tools/bash.d.ts +7 -2
  127. package/dist/types/tools/daemon.d.ts +1 -1
  128. package/dist/types/tools/eval.d.ts +22 -3
  129. package/dist/types/tools/glob.d.ts +4 -0
  130. package/dist/types/tools/native-view.d.ts +47 -0
  131. package/dist/types/tools/output-meta.d.ts +11 -1
  132. package/dist/types/tools/renderer.d.ts +37 -2
  133. package/dist/types/tools/streaming-output.d.ts +20 -1
  134. package/dist/types/tools/task.d.ts +2 -3
  135. package/dist/types/tools/write.d.ts +9 -2
  136. package/dist/types/tools/yield.d.ts +22 -0
  137. package/dist/types/tui.d.ts +36 -0
  138. package/dist/types/utils.d.ts +6 -0
  139. package/dist/types/vim.d.ts +19 -5
  140. package/dist/types/windows-input-mode.d.ts +33 -1
  141. package/package.json +13 -9
  142. package/src/active-terminal.d.ts +30 -0
  143. package/src/active-terminal.ts +80 -0
  144. package/src/app-keybindings.d.ts +7 -2
  145. package/src/app-keybindings.ts +7 -2
  146. package/src/apps/autoresearch-dashboard.ts +7 -3
  147. package/src/apps/cleanse-board.ts +2 -2
  148. package/src/apps/cleanse-picker.ts +1 -1
  149. package/src/apps/debug/log-viewer.ts +188 -72
  150. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  151. package/src/apps/debug/raw-sse.ts +39 -5
  152. package/src/apps/git/diff-pane.ts +96 -23
  153. package/src/apps/git/git-tui.ts +23 -16
  154. package/src/apps/git/help.ts +29 -20
  155. package/src/apps/git/sidebar.ts +8 -3
  156. package/src/apps/if-bench-board.ts +2 -2
  157. package/src/apps/ps-top.ts +2 -2
  158. package/src/apps/setup-model-picker.ts +1 -1
  159. package/src/autocomplete.d.ts +5 -2
  160. package/src/autocomplete.ts +67 -43
  161. package/src/bracketed-paste.ts +10 -3
  162. package/src/charts/chart-plan.d.ts +674 -0
  163. package/src/charts/chart-plan.ts +5081 -0
  164. package/src/charts/chart-svg.d.ts +59 -0
  165. package/src/charts/chart-svg.ts +4657 -0
  166. package/src/charts/table-data.d.ts +175 -0
  167. package/src/charts/table-data.ts +1478 -0
  168. package/src/chat/advisor-message.ts +11 -0
  169. package/src/chat/assistant-message.d.ts +11 -1
  170. package/src/chat/assistant-message.ts +245 -54
  171. package/src/chat/chat-transcript-builder.d.ts +3 -0
  172. package/src/chat/chat-transcript-builder.ts +9 -3
  173. package/src/chat/compaction-summary-message.d.ts +1 -0
  174. package/src/chat/compaction-summary-message.ts +8 -0
  175. package/src/chat/execution-shared.d.ts +6 -5
  176. package/src/chat/execution-shared.ts +42 -25
  177. package/src/chat/fence-figure.d.ts +42 -0
  178. package/src/chat/fence-figure.ts +80 -0
  179. package/src/chat/figure-markdown.d.ts +42 -0
  180. package/src/chat/figure-markdown.ts +174 -0
  181. package/src/chat/messages.d.ts +1 -1
  182. package/src/chat/messages.ts +1 -1
  183. package/src/chat/read-tool-group.ts +6 -4
  184. package/src/chat/skill-message.ts +6 -0
  185. package/src/chat/svg-figure.d.ts +33 -0
  186. package/src/chat/svg-figure.ts +399 -0
  187. package/src/chat/svg-source.d.ts +49 -0
  188. package/src/chat/svg-source.ts +188 -0
  189. package/src/chat/table-chart.d.ts +89 -0
  190. package/src/chat/table-chart.ts +294 -0
  191. package/src/chat/tool-execution.d.ts +2 -0
  192. package/src/chat/tool-execution.ts +86 -7
  193. package/src/chat/user-message.d.ts +17 -4
  194. package/src/chat/user-message.ts +71 -8
  195. package/src/chrome/shared.ts +53 -0
  196. package/src/chrome/status-notice.ts +25 -7
  197. package/src/chrome/transcript-container.d.ts +6 -0
  198. package/src/chrome/transcript-container.ts +162 -19
  199. package/src/components/box.d.ts +1 -0
  200. package/src/components/box.ts +5 -0
  201. package/src/components/disclosure.d.ts +1 -0
  202. package/src/components/disclosure.ts +8 -0
  203. package/src/components/editor.d.ts +23 -5
  204. package/src/components/editor.ts +316 -47
  205. package/src/components/form.d.ts +1 -0
  206. package/src/components/form.ts +8 -0
  207. package/src/components/image.d.ts +26 -44
  208. package/src/components/image.ts +115 -152
  209. package/src/components/input.d.ts +2 -1
  210. package/src/components/input.ts +17 -7
  211. package/src/components/key-value-list.d.ts +1 -0
  212. package/src/components/key-value-list.ts +4 -0
  213. package/src/components/layout/row.d.ts +1 -0
  214. package/src/components/layout/row.ts +7 -0
  215. package/src/components/layout/stack.d.ts +1 -0
  216. package/src/components/layout/stack.ts +7 -0
  217. package/src/components/loader.d.ts +5 -6
  218. package/src/components/loader.ts +10 -17
  219. package/src/components/markdown.d.ts +47 -5
  220. package/src/components/markdown.ts +786 -303
  221. package/src/components/scroll-view.d.ts +1 -0
  222. package/src/components/scroll-view.ts +5 -0
  223. package/src/components/section.d.ts +1 -0
  224. package/src/components/section.ts +5 -0
  225. package/src/components/settings-list.ts +2 -0
  226. package/src/components/table.d.ts +1 -0
  227. package/src/components/table.ts +4 -0
  228. package/src/components/text.d.ts +1 -0
  229. package/src/components/text.ts +7 -0
  230. package/src/components/tree-view.d.ts +7 -0
  231. package/src/components/tree-view.ts +38 -8
  232. package/src/components/truncated-text.d.ts +1 -0
  233. package/src/components/truncated-text.ts +5 -0
  234. package/src/desktop-notify.ts +3 -2
  235. package/src/fuzzy.d.ts +29 -1
  236. package/src/fuzzy.ts +115 -30
  237. package/src/hotkeys-markdown.ts +9 -2
  238. package/src/key-hint-format.d.ts +9 -1
  239. package/src/key-hint-format.ts +26 -11
  240. package/src/keybinding-matchers.d.ts +3 -0
  241. package/src/keybinding-matchers.ts +6 -2
  242. package/src/keybindings.d.ts +1 -1
  243. package/src/keybindings.ts +1 -1
  244. package/src/kitty-graphics.d.ts +2 -2
  245. package/src/kitty-graphics.ts +11 -5
  246. package/src/loop-watchdog.d.ts +8 -7
  247. package/src/loop-watchdog.ts +18 -13
  248. package/src/multiplexers/cmux.d.ts +16 -0
  249. package/src/multiplexers/cmux.ts +29 -0
  250. package/src/multiplexers/herdr.d.ts +26 -0
  251. package/src/multiplexers/herdr.ts +64 -0
  252. package/src/multiplexers/notify.d.ts +13 -0
  253. package/src/multiplexers/notify.ts +24 -0
  254. package/src/multiplexers/orca.d.ts +12 -0
  255. package/src/multiplexers/orca.ts +16 -0
  256. package/src/multiplexers/screen.d.ts +9 -0
  257. package/src/multiplexers/screen.ts +15 -0
  258. package/src/multiplexers/tmux.d.ts +29 -0
  259. package/src/multiplexers/tmux.ts +40 -0
  260. package/src/multiplexers/types.d.ts +100 -0
  261. package/src/multiplexers/types.ts +110 -0
  262. package/src/multiplexers/wmux.d.ts +12 -0
  263. package/src/multiplexers/wmux.ts +16 -0
  264. package/src/multiplexers/zellij.d.ts +12 -0
  265. package/src/multiplexers/zellij.ts +24 -0
  266. package/src/native/apply.d.ts +4 -3
  267. package/src/native/apply.ts +4 -3
  268. package/src/native/backend.d.ts +5 -4
  269. package/src/native/backend.ts +199 -22
  270. package/src/native/blobs.d.ts +7 -3
  271. package/src/native/blobs.ts +47 -12
  272. package/src/native/encode.d.ts +8 -6
  273. package/src/native/encode.ts +76 -34
  274. package/src/native/node.d.ts +36 -5
  275. package/src/native/node.ts +31 -5
  276. package/src/native/overlay.d.ts +8 -3
  277. package/src/native/overlay.ts +17 -4
  278. package/src/native/reconcile.ts +16 -4
  279. package/src/native/settle.d.ts +4 -3
  280. package/src/native/settle.ts +4 -3
  281. package/src/overlays/agent-hub-projection.d.ts +6 -5
  282. package/src/overlays/agent-hub-projection.ts +124 -13
  283. package/src/overlays/agent-hub-types.d.ts +9 -0
  284. package/src/overlays/agent-hub-types.ts +6 -0
  285. package/src/overlays/agent-hub.ts +48 -29
  286. package/src/overlays/agent-transcript-viewer.ts +93 -23
  287. package/src/overlays/agents-hub.ts +28 -8
  288. package/src/overlays/annotation-overlay.ts +293 -90
  289. package/src/overlays/annotation-types.ts +2 -0
  290. package/src/overlays/ask-dialog.ts +10 -17
  291. package/src/overlays/btw-history-panel.ts +136 -39
  292. package/src/overlays/copy-selector.d.ts +1 -0
  293. package/src/overlays/copy-selector.ts +32 -8
  294. package/src/overlays/extensions/extension-dashboard.ts +4 -4
  295. package/src/overlays/extensions/state-manager.ts +52 -12
  296. package/src/overlays/history-search.ts +100 -7
  297. package/src/overlays/hook-selector.ts +12 -5
  298. package/src/overlays/hub-frame.ts +19 -1
  299. package/src/overlays/jobs-panel.ts +33 -52
  300. package/src/overlays/model-browser.ts +367 -122
  301. package/src/overlays/model-hub.ts +435 -59
  302. package/src/overlays/move-overlay.ts +28 -14
  303. package/src/overlays/oauth-selector.ts +54 -70
  304. package/src/overlays/pause-screen.ts +2 -2
  305. package/src/overlays/plan-review-overlay.ts +208 -70
  306. package/src/overlays/plugin-settings.ts +3 -3
  307. package/src/overlays/report-panel.ts +191 -0
  308. package/src/overlays/rewind-selector.ts +88 -17
  309. package/src/overlays/session-observer-registry.d.ts +2 -0
  310. package/src/overlays/session-observer-registry.ts +10 -2
  311. package/src/overlays/session-selector.ts +64 -26
  312. package/src/overlays/settings-defs.ts +5 -4
  313. package/src/overlays/settings-selector.ts +33 -8
  314. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  315. package/src/overlays/stats-notice.ts +1 -1
  316. package/src/overlays/thinking-selector.ts +11 -10
  317. package/src/overlays/tree-selector.ts +41 -20
  318. package/src/overlays/usage-dashboard.ts +117 -58
  319. package/src/overlays/usage-display.ts +10 -4
  320. package/src/overlays/usage-row.ts +22 -8
  321. package/src/prompt/attachment-chips.ts +15 -11
  322. package/src/prompt/composer-attachments.ts +6 -4
  323. package/src/prompt/composer-cache.ts +7 -86
  324. package/src/prompt/composer.ts +37 -26
  325. package/src/prompt/custom-editor.ts +282 -105
  326. package/src/prompt/image-source.ts +2 -1
  327. package/src/prompt/interactive-context-helpers.ts +32 -18
  328. package/src/prompt/macos-spelling.ts +7 -0
  329. package/src/prompt/magic-keywords.ts +11 -2
  330. package/src/prompt/model-mention-autocomplete.ts +14 -14
  331. package/src/prompt/queued-messages.ts +6 -4
  332. package/src/prompt/tips.txt +13 -9
  333. package/src/prompt/welcome.ts +64 -46
  334. package/src/prompt/word-completion.ts +38 -5
  335. package/src/render/composite.d.ts +2 -0
  336. package/src/render/composite.ts +64 -0
  337. package/src/render/output-pane.d.ts +1 -0
  338. package/src/render/output-pane.ts +6 -0
  339. package/src/render/render-utils.d.ts +1 -0
  340. package/src/render/render-utils.ts +16 -1
  341. package/src/render/tool-card.d.ts +6 -0
  342. package/src/render/tool-card.ts +15 -0
  343. package/src/render/width-aware-text.d.ts +2 -0
  344. package/src/render/width-aware-text.ts +5 -0
  345. package/src/setup/scenes/model.ts +2 -1
  346. package/src/setup/scenes/types.ts +1 -0
  347. package/src/space-hold.d.ts +40 -25
  348. package/src/space-hold.ts +156 -52
  349. package/src/status-line/component.ts +292 -78
  350. package/src/status-line/context-usage.ts +21 -46
  351. package/src/status-line/host.ts +4 -1
  352. package/src/status-line/segments.ts +40 -22
  353. package/src/status-line/types.ts +15 -3
  354. package/src/terminal-capabilities.d.ts +41 -36
  355. package/src/terminal-capabilities.ts +137 -213
  356. package/src/terminal-multiplexer.d.ts +127 -14
  357. package/src/terminal-multiplexer.ts +112 -33
  358. package/src/terminal.d.ts +23 -16
  359. package/src/terminal.ts +203 -99
  360. package/src/theme/active-symbols.d.ts +1 -1
  361. package/src/theme/active-symbols.ts +1 -1
  362. package/src/theme/schema-validation.ts +1 -0
  363. package/src/theme/schema.d.ts +11 -2
  364. package/src/theme/schema.ts +14 -1
  365. package/src/theme/session-color.ts +1 -7
  366. package/src/theme/symbols.d.ts +1 -1
  367. package/src/theme/symbols.ts +11 -4
  368. package/src/theme/theme-class.d.ts +7 -0
  369. package/src/theme/theme-class.ts +26 -1
  370. package/src/theme/theme-schema.json +5 -1
  371. package/src/theme/theme.d.ts +1 -1
  372. package/src/theme/theme.ts +8 -4
  373. package/src/theme/tui-adapters.d.ts +3 -0
  374. package/src/theme/tui-adapters.ts +68 -0
  375. package/src/thinking.d.ts +1 -1
  376. package/src/thinking.ts +1 -1
  377. package/src/tmux.d.ts +11 -4
  378. package/src/tmux.ts +47 -11
  379. package/src/tools/ast-edit.ts +59 -35
  380. package/src/tools/ast-grep.ts +35 -16
  381. package/src/tools/bash.d.ts +7 -2
  382. package/src/tools/bash.ts +116 -47
  383. package/src/tools/daemon.d.ts +1 -1
  384. package/src/tools/daemon.ts +1 -1
  385. package/src/tools/default-renderer.ts +35 -19
  386. package/src/tools/edit.ts +142 -120
  387. package/src/tools/eval-format/index.ts +28 -2
  388. package/src/tools/eval-format/javascript.ts +7 -5
  389. package/src/tools/eval.d.ts +22 -3
  390. package/src/tools/eval.ts +364 -208
  391. package/src/tools/fetch.ts +5 -6
  392. package/src/tools/glob.d.ts +4 -0
  393. package/src/tools/glob.ts +33 -65
  394. package/src/tools/grep.ts +81 -48
  395. package/src/tools/index.ts +2 -0
  396. package/src/tools/lsp.ts +81 -59
  397. package/src/tools/mcp.ts +111 -68
  398. package/src/tools/native-view.d.ts +47 -0
  399. package/src/tools/native-view.ts +102 -1
  400. package/src/tools/output-meta.d.ts +11 -1
  401. package/src/tools/output-meta.ts +23 -3
  402. package/src/tools/read.ts +7 -5
  403. package/src/tools/renderer.d.ts +37 -2
  404. package/src/tools/renderer.ts +42 -2
  405. package/src/tools/streaming-output.d.ts +20 -1
  406. package/src/tools/streaming-output.ts +154 -50
  407. package/src/tools/task.d.ts +2 -3
  408. package/src/tools/task.ts +115 -62
  409. package/src/tools/todo.ts +119 -30
  410. package/src/tools/wait.ts +34 -24
  411. package/src/tools/web-search.ts +1 -1
  412. package/src/tools/write.d.ts +9 -2
  413. package/src/tools/write.ts +147 -95
  414. package/src/tools/yield.d.ts +22 -0
  415. package/src/tools/yield.ts +225 -0
  416. package/src/tui.d.ts +36 -0
  417. package/src/tui.ts +666 -217
  418. package/src/utils.d.ts +6 -0
  419. package/src/utils.ts +19 -0
  420. package/src/vim.d.ts +19 -5
  421. package/src/vim.ts +333 -18
  422. package/src/windows-input-mode.d.ts +33 -1
  423. package/src/windows-input-mode.ts +212 -6
package/src/tui.ts CHANGED
@@ -29,13 +29,14 @@ import { col } from "./native/describe";
29
29
  import { TSP_PREFIX, type TspHello } from "./native/encode";
30
30
  import type { DescribeContext, NativeNode, NativeScreen, NativeSurfaceProvider, NativeUiEvent } from "./native/node";
31
31
  import { STDOUT_BACKLOG_CLEAR_BYTES, setAltScreenActive, type Terminal } from "./terminal";
32
+ import { classifyTerminalMultiplexerModule, terminalMultiplexerSessions } from "./terminal-multiplexer";
32
33
  import {
33
34
  encodeKittyDeleteAllImages,
34
35
  encodeKittyDeleteImage,
36
+ encodeKittyDeletePlacement,
35
37
  encodeKittyPlacementLine,
36
38
  ImageProtocol,
37
39
  isImageProtocolForced,
38
- isInsideHerdr,
39
40
  isInsideTerminalMultiplexer,
40
41
  parseKittyDirectPlacementLine,
41
42
  setCellDimensions,
@@ -44,15 +45,14 @@ import {
44
45
  synchronizedOutputUserOverride,
45
46
  TERMINAL,
46
47
  } from "./terminal-capabilities";
48
+ import { compositeLineAt } from "./render/composite";
47
49
  import {
48
50
  Ellipsis,
49
- extractSegments,
50
51
  getWidthConfigEpoch,
51
52
  isOsc66Line,
52
53
  normalizeTerminalOutput,
53
54
  osc66MaxScale,
54
55
  sliceByColumn,
55
- sliceWithWidth,
56
56
  truncateToWidth,
57
57
  visibleWidth,
58
58
  } from "./utils";
@@ -88,6 +88,9 @@ const MIN_MAIN_AREA_COLUMNS = 64;
88
88
  const HIDE_CURSOR = "\x1b[?25l";
89
89
  const SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
90
90
  const SYNC_OUTPUT_END = "\x1b[?2026l";
91
+ // tmux expires synchronized output after one second. Renew during large
92
+ // replays as the output queue drains, at complete sequence/row boundaries.
93
+ const SYNC_OUTPUT_RENEW_BYTES = 16 * 1024;
91
94
  const DISABLE_AUTOWRAP = "\x1b[?7l";
92
95
  const ENABLE_AUTOWRAP = "\x1b[?7h";
93
96
  const PAINT_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}${DISABLE_AUTOWRAP}`;
@@ -196,6 +199,10 @@ export interface TUIStartOptions {
196
199
  * Paint without owning stdin: the terminal stays in cooked mode (kernel
197
200
  * echo + line editing at the hardware cursor) until {@link TUI.enableInput}
198
201
  * switches to raw input and replays the kernel-buffered keystrokes.
202
+ *
203
+ * A terminal expected to speak TSP needs raw input from the start, so it
204
+ * gets it; its keystrokes are held instead (TSP events and the cell-size
205
+ * reply still apply) until {@link TUI.releaseHeldInput} replays them.
199
206
  */
200
207
  deferInput?: boolean;
201
208
  }
@@ -292,6 +299,12 @@ export interface Component {
292
299
  /** User actions on nodes this component described (toggle, select, activate, custom). */
293
300
  handleNativeEvent?(event: NativeUiEvent): void;
294
301
 
302
+ /**
303
+ * Pure preflight for focused input that must precede TUI-wide input listeners
304
+ * and debug shortcuts. A true result routes the event to this component first.
305
+ */
306
+ capturesInput?(data: string): boolean;
307
+
295
308
  /**
296
309
  * Optional handler for keyboard input when component has focus
297
310
  */
@@ -308,6 +321,16 @@ export interface Component {
308
321
  * Called when theme changes or when component needs to re-render from scratch.
309
322
  */
310
323
  invalidate?(): void;
324
+ /**
325
+ * Optional hook to drop memoized render output (rows, parse and wrap state)
326
+ * that the next `render()` can rebuild from state the component keeps.
327
+ * Unlike {@link invalidate}, it must not rebuild anything eagerly: no
328
+ * renderer or extension callbacks, no image conversions, and no child
329
+ * replacement or disposal. The next render must return the same rows it
330
+ * would have returned without the release. Components without it are
331
+ * simply not released.
332
+ */
333
+ releaseRenderCaches?(): void;
311
334
  /**
312
335
  * Optional hook to set whether this component ignores tight layout mode.
313
336
  */
@@ -538,6 +561,7 @@ export class Container implements Component {
538
561
  clear(): void {
539
562
  this.children = [];
540
563
  this.#memoLines = undefined;
564
+ this.#memoChildLines = [];
541
565
  }
542
566
 
543
567
  /** Dispose every child, then detach it from this container. */
@@ -547,12 +571,23 @@ export class Container implements Component {
547
571
  }
548
572
 
549
573
  invalidate(): void {
574
+ // The per-child refs pin every row the children last rendered; dropping
575
+ // only the concatenation would keep all of them reachable.
550
576
  this.#memoLines = undefined;
577
+ this.#memoChildLines = [];
551
578
  for (const child of this.children) {
552
579
  child.invalidate?.();
553
580
  }
554
581
  }
555
582
 
583
+ releaseRenderCaches(): void {
584
+ this.#memoLines = undefined;
585
+ this.#memoChildLines = [];
586
+ for (const child of this.children) {
587
+ child.releaseRenderCaches?.();
588
+ }
589
+ }
590
+
556
591
  /**
557
592
  * Propagate teardown to children. Call when the container's children are
558
593
  * being permanently discarded (not when they are detached for reuse — use
@@ -611,6 +646,13 @@ interface HardwareCursorState {
611
646
  visible: boolean;
612
647
  }
613
648
 
649
+ /** A cursor-positioned image owned by a mutable viewport, never by history. */
650
+ interface LiveImagePlacement {
651
+ imageId: number;
652
+ placementId: number;
653
+ line: string;
654
+ }
655
+
614
656
  interface PreparedLine {
615
657
  raw: string;
616
658
  width: number;
@@ -817,6 +859,16 @@ export class TUI extends Container {
817
859
  // shrink-then-regrow that never exceeds the pre-burst height (see the
818
860
  // CPR-timeout fallback in #resolveResizeAnchor).
819
861
  #resizeBurstPull = 0;
862
+ // Whether any step of the burst left the committed width / geometry. The
863
+ // terminal reflowed the normal buffer at every intermediate step, so a drag
864
+ // that returns to its starting size still shredded retained history and
865
+ // pushed unerased live rows into it; the settled refresh must key on the
866
+ // whole burst, not on the net change (see #prepareResizeReplay).
867
+ #resizeBurstWidthChanged = false;
868
+ #resizeBurstResized = false;
869
+ // A shrink can discard live rows below the cursor and push others into
870
+ // scrollback. Rebuild must repair that even when the burst ends taller.
871
+ #resizeBurstShrank = false;
820
872
  // Geometry epoch: bumped on every resize transaction entry, so each CSI 6n
821
873
  // request records the geometry it was parked under.
822
874
  #geometryEpoch = 0;
@@ -846,6 +898,12 @@ export class TUI extends Container {
846
898
  #preparedLineMemo = new Map<string, PreparedLine>();
847
899
  #preparedLineMemoSpare = new Map<string, PreparedLine>();
848
900
  #previousFrameLength = 0;
901
+ // Rows a transiently grown frame scrolled off the top without any history
902
+ // transaction committing them (see #emitPlanFrame). The shrink that follows
903
+ // the transient chrome row out pulls exactly these rows back, restoring the
904
+ // pre-growth screen; any append, replay, reset, resize, or overlay frame
905
+ // zeroes the count — those scrolls are committed or superseded.
906
+ #transientScrollPushed = 0;
849
907
  #previousWidth = 0;
850
908
  #previousHeight = 0;
851
909
  // Physical width of the last provider frame. Scrollback reflow follows the
@@ -915,6 +973,16 @@ export class TUI extends Container {
915
973
  static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120;
916
974
  /** Longest wait for a CPR reply before the settled repaint falls back. */
917
975
  static readonly #RESIZE_PROBE_TIMEOUT_MS = 200;
976
+ /**
977
+ * Smallest settled tmux replay that is preceded by a `Rebuilding…` notice.
978
+ * The notice can only be published ahead of the replay (tmux holds the
979
+ * pane's synchronized update until the replay ends), so it stays up for
980
+ * exactly as long as tmux takes to ingest the replay. tmux next-3.9 on
981
+ * Apple silicon ingests a real transcript replay at ~14 MiB/s, so this is
982
+ * about half a second there. A smaller replay finishes before the notice
983
+ * can be read, and showing it would only flash a status row.
984
+ */
985
+ static readonly #RESIZE_REBUILD_NOTICE_MIN_BYTES = 7 * 1024 * 1024;
918
986
  #inputRenderGraceUntilMs = 0;
919
987
  // A scale-`s` OSC 66 heading reserves `s - 1` rows, and the protocol
920
988
  // caps `s` at 7. This bounds spacer lookups and supplies enough context
@@ -924,8 +992,8 @@ export class TUI extends Container {
924
992
  // settle window, leaving only Unicode placeholder cells. Hold the first image
925
993
  // paint until that window has passed; later images render normally.
926
994
  static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
927
- #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
928
- #hardwareCursorState: HardwareCursorState | null = null;
995
+ #hardwareCursorRow = 0; // Normal-buffer cursor row, retained while the alternate buffer is active.
996
+ #hardwareCursorState: HardwareCursorState | null = null; // Current buffer's cursor paint/dedupe state.
929
997
  #sixelProbePendingGraphics = false;
930
998
  #sixelProbeBuffer = "";
931
999
  #sixelProbeTimeout?: NodeJS.Timeout;
@@ -939,6 +1007,9 @@ export class TUI extends Container {
939
1007
  // Caps how many inline images render as live graphics; older ones fall back
940
1008
  // to text via a purge + full redraw. Cap is configured by the host app.
941
1009
  #imageBudget = new ImageBudget(DEFAULT_MAX_INLINE_IMAGES, () => this.requestRender());
1010
+ #screenImagePlacements = new Map<number, LiveImagePlacement>();
1011
+ #altImagePlacements = new Map<number, LiveImagePlacement>();
1012
+ #nextImagePlacementId = 1;
942
1013
  #ghosttyInitialImageDelayDone = false;
943
1014
  #ghosttyInitialImageDelayTimer: RenderTimer | undefined;
944
1015
  #ghosttyImageReadyAtMs = 0;
@@ -952,6 +1023,17 @@ export class TUI extends Container {
952
1023
  #cancelPostmortemRestore?: () => void;
953
1024
  /** True between a `deferInput` start() and enableInput(). */
954
1025
  #inputDeferred = false;
1026
+ /**
1027
+ * Keystrokes held since a TSP `deferInput` start, replayed by
1028
+ * releaseHeldInput(); undefined when not holding.
1029
+ */
1030
+ #heldInput: string[] | undefined;
1031
+ /**
1032
+ * The component focused when holding began. Only its keystrokes are held:
1033
+ * a dialog that takes focus meanwhile (a startup hook's select or confirm)
1034
+ * gets its input live.
1035
+ */
1036
+ #heldFocus: Component | null = null;
955
1037
  // Always-on event-loop lag probe. The high default threshold keeps it quiet;
956
1038
  // it only logs `ui.loop-blocked` (with the current loop phase) when a frame
957
1039
  // budget is genuinely starved. Armed in start(), disarmed in stop().
@@ -982,15 +1064,25 @@ export class TUI extends Container {
982
1064
  #altToggleColumns = 0;
983
1065
  #altToggleRows = 0;
984
1066
  #altToggleEchoPending = false;
985
- // True while an in-place resize transaction (Warp) is inside its settle
986
- // window: the normal-buffer anchor is stale until the settled CPR probe
987
- // resolves, so ordinary paints are dropped until then.
1067
+ // True while an in-place resize waits for its settled rebuild (tmux) or
1068
+ // anchor recovery (Warp): ordinary paints must not use the stale anchor.
988
1069
  #resizeInPlaceActive = false;
989
1070
  #resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode();
990
1071
  #resizeReplaySize: string | undefined;
1072
+ // The resize borrow's entry (live-viewport erase, CSI ?1049h, keyboard
1073
+ // push), held for the first resize frame's synchronized update. Written on
1074
+ // its own, the terminal can present the blank alternate screen for a frame
1075
+ // before the resize frame lands.
1076
+ #pendingAltEnter = "";
991
1077
  // Holds an alternate-screen exit until its replacement full paint can emit it
992
1078
  // atomically. It must survive a deferred Ghostty image frame.
993
1079
  #pendingAltExit = "";
1080
+ // True while #pendingAltExit holds the resize borrow's own exit: the
1081
+ // settle fused it into the destructive rebuild, so the terminal is still
1082
+ // on the borrowed alt buffer until that frame is written. A SIGWINCH or a
1083
+ // fullscreen overlay arriving first adopts the buffer instead of entering
1084
+ // it again (see #settleResizeAltPaint).
1085
+ #resizeAltExitFused = false;
994
1086
 
995
1087
  // Tern Surface Protocol backend, created when the terminal answers the
996
1088
  // `hello` probe, or at start when the environment names Tern. Kept across
@@ -1177,6 +1269,8 @@ export class TUI extends Container {
1177
1269
 
1178
1270
  #purgeInlineImages(): void {
1179
1271
  const transmittedIds = this.#imageBudget.takeAllTransmittedIds();
1272
+ this.#screenImagePlacements.clear();
1273
+ this.#altImagePlacements.clear();
1180
1274
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
1181
1275
  for (const id of transmittedIds) {
1182
1276
  this.terminal.write(encodeKittyDeleteImage(id));
@@ -1464,6 +1558,12 @@ export class TUI extends Container {
1464
1558
  // `hello` query must go out now to confirm the surface.
1465
1559
  const nativeExpected = this.terminal.tspExpected === true;
1466
1560
  this.#inputDeferred = options?.deferInput === true && !nativeExpected;
1561
+ // A restart (a startup dialog's external editor stops and restarts the
1562
+ // TUI) keeps an existing hold: those keys still belong to the editor.
1563
+ if (options?.deferInput === true && nativeExpected) {
1564
+ this.#heldInput = [];
1565
+ this.#heldFocus = this.#focusedComponent;
1566
+ }
1467
1567
  this.#watchdog.start();
1468
1568
  this.#ghosttyInitialImageDelayDone = false;
1469
1569
  this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
@@ -1477,12 +1577,19 @@ export class TUI extends Container {
1477
1577
  this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true, status) => {
1478
1578
  if (mode !== 2026 || !confirmed) return;
1479
1579
  if (synchronizedOutputUserOverride() !== null) return;
1480
- // Herdr's Ghostty VTE honors DEC 2026 even when DECRQM is unanswered or
1481
- // reports unrecognized (status 0). Other confirmed unsupported reports
1482
- // still disable: status 4 is permanently reset, and a three-argument
1483
- // callback (`status` omitted) is a definitive unsupported from a
1484
- // custom Terminal that does not distinguish DECRPM codes.
1485
- if (!supported && isInsideHerdr() && status === 0) return;
1580
+ // Some multiplexer VTEs (Herdr's Ghostty pane) honor DEC 2026 even when
1581
+ // DECRQM is unanswered or reports unrecognized (status 0). Other
1582
+ // confirmed unsupported reports still disable: status 4 is permanently
1583
+ // reset, and a three-argument callback (`status` omitted) is a
1584
+ // definitive unsupported from a custom Terminal that does not
1585
+ // distinguish DECRPM codes.
1586
+ if (
1587
+ !supported &&
1588
+ status === 0 &&
1589
+ terminalMultiplexerSessions().some(multiplexer => multiplexer.honorsSynchronizedOutput)
1590
+ ) {
1591
+ return;
1592
+ }
1486
1593
  this.#setSynchronizedOutput(supported);
1487
1594
  });
1488
1595
  // Icons painted before the Glyph Protocol registration landed may sit in
@@ -1492,6 +1599,13 @@ export class TUI extends Container {
1492
1599
  this.invalidate();
1493
1600
  this.requestRender(true);
1494
1601
  });
1602
+ this.terminal.onSixelSupport?.(supported => {
1603
+ if (!supported || this.#stopped || this.#nativeLive || this.terminal.tspExpected) return;
1604
+ if (isImageProtocolForced()) return;
1605
+ // Keep consuming an already-requested XTSMGRAPHICS reply until it
1606
+ // arrives or times out, even though DA1 has established support.
1607
+ this.#enableSixelProtocol();
1608
+ });
1495
1609
  this.terminal.onTspHello?.(hello => this.#onTspHello(hello));
1496
1610
  this.terminal.start(
1497
1611
  data => this.#handleInput(data),
@@ -1516,7 +1630,7 @@ export class TUI extends Container {
1516
1630
  return;
1517
1631
  }
1518
1632
  this.#cancelResizeProbe();
1519
- if (this.#resizeRepaintsInPlace()) this.#beginResizeInPlacePaint();
1633
+ if (this.#resizeAvoidsAltBuffer()) this.#beginResizeInPlacePaint();
1520
1634
  else this.#beginResizeAltPaint(true);
1521
1635
  return;
1522
1636
  }
@@ -1545,7 +1659,7 @@ export class TUI extends Container {
1545
1659
  this.requestRender(true);
1546
1660
  return;
1547
1661
  }
1548
- if (this.#resizeRepaintsInPlace()) {
1662
+ if (this.#resizeAvoidsAltBuffer()) {
1549
1663
  this.#beginResizeInPlacePaint();
1550
1664
  return;
1551
1665
  }
@@ -1705,7 +1819,7 @@ export class TUI extends Container {
1705
1819
  this.#resizeSettleTimer?.cancel();
1706
1820
  this.#resizeSettleTimer = undefined;
1707
1821
  if (this.#altActive || this.#resizeAltActive) {
1708
- this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1822
+ this.terminal.write(`${this.#takePendingAltEnter()}${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1709
1823
  setAltScreenActive(false);
1710
1824
  this.#altActive = false;
1711
1825
  this.#resizeAltActive = false;
@@ -1760,11 +1874,11 @@ export class TUI extends Container {
1760
1874
  };
1761
1875
  }
1762
1876
  /**
1763
- * Whether a resize repaints the visible window in place — no alternate-screen
1764
- * borrow. Warp-only: Warp re-reports its size on alt-buffer toggles, so borrowing
1765
- * there self-sustains. Every other terminal keeps the alt-borrow path. Inside a
1766
- * multiplexer the mux owns the grid and consumes the toggles itself, so an
1767
- * inherited Warp marker must not divert the mux-tuned borrow path.
1877
+ * Whether a resize only repaints the visible window in place, without history
1878
+ * replay. Warp re-reports its size on alt-buffer toggles, so borrowing there
1879
+ * self-sustains. Inside a multiplexer the mux owns the grid and consumes the
1880
+ * toggles itself, so an inherited Warp marker must not suppress its replay.
1881
+ * tmux's synchronized rebuild is selected separately in #resizeAvoidsAltBuffer.
1768
1882
  *
1769
1883
  * A ConPTY host is excluded for the same reason as a multiplexer: conhost owns
1770
1884
  * the grid the application writes to. Measured on conhost, resizing the
@@ -1783,6 +1897,21 @@ export class TUI extends Container {
1783
1897
  return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal";
1784
1898
  }
1785
1899
 
1900
+ #resizeAvoidsAltBuffer(): boolean {
1901
+ if (this.#resizeRepaintsInPlace()) return true;
1902
+ // Unlike Warp's viewport-only repaint, tmux still rebuilds history.
1903
+ // Restoring an alternate buffer schedules a tmux redraw that ends its
1904
+ // synchronized update early, exposing the rest of a long replay.
1905
+ return (
1906
+ !this.#resizeAltActive &&
1907
+ !this.#pendingAltExit &&
1908
+ resizeInPlaceOverride() !== false &&
1909
+ this.#resizeScrollbackMode === "rebuild" &&
1910
+ this.#synchronizedOutputEnabled &&
1911
+ classifyTerminalMultiplexerModule()?.altRestoreEndsSynchronizedOutput === true
1912
+ );
1913
+ }
1914
+
1786
1915
  #noteAltBufferToggle(): void {
1787
1916
  this.#altToggleColumns = this.terminal.columns;
1788
1917
  this.#altToggleRows = this.terminal.rows;
@@ -1816,17 +1945,23 @@ export class TUI extends Container {
1816
1945
  #trackResizeBurst(): void {
1817
1946
  const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight;
1818
1947
  if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true;
1948
+ if (this.terminal.rows < burstLastHeight) this.#resizeBurstShrank = true;
1819
1949
  this.#resizeBurstLastHeight = this.terminal.rows;
1820
1950
  this.#resizeBurstPull += Math.max(0, this.terminal.rows - burstLastHeight);
1951
+ // Latch against the physical width: with the sidebar reserving columns,
1952
+ // `#previousWidth` holds the composed main-area width, which would arm
1953
+ // the burst on every sidebar toggle.
1954
+ if (this.terminal.columns !== this.#previousPhysicalWidth) this.#resizeBurstWidthChanged = true;
1955
+ if (this.terminal.columns !== this.#previousPhysicalWidth || this.terminal.rows !== this.#previousHeight) {
1956
+ this.#resizeBurstResized = true;
1957
+ }
1821
1958
  this.#geometryEpoch++;
1822
1959
  }
1823
1960
 
1824
1961
  /**
1825
- * Coalesced in-place resize transaction for Warp-class terminals: never
1826
- * borrows the alt buffer. Drag SIGWINCHes only re-arm the settle window, so a
1827
- * drag emits no paints and no scrollback replay; once quiet, the transaction
1828
- * snapshots the live window and runs the CPR anchor probe, and the single
1829
- * settled repaint lands on the recovered anchor with no ED3 rewrap.
1962
+ * Coalesced resize without borrowing the alt buffer. Once quiet, tmux can
1963
+ * rebuild directly under synchronized output; viewport-only repaints recover
1964
+ * their anchor with CPR. The existing screen remains visible while waiting.
1830
1965
  */
1831
1966
  #beginResizeInPlacePaint(): void {
1832
1967
  if (this.#altActive) {
@@ -1838,7 +1973,9 @@ export class TUI extends Container {
1838
1973
  this.#resizeSettleTimer?.cancel();
1839
1974
  this.#forgetHardwareCursorState();
1840
1975
  this.#recordHardwareCursorHidden();
1841
- if (this.#eraseLiveViewportForResize()) {
1976
+ const erase = this.#liveViewportResizeErase();
1977
+ if (erase !== "") {
1978
+ this.terminal.write(erase);
1842
1979
  // The erase parked the hardware cursor on the viewport's top row;
1843
1980
  // snapshot the parked offset so the settled probe anchors there.
1844
1981
  this.#parkedViewportOffset = 0;
@@ -1846,6 +1983,12 @@ export class TUI extends Container {
1846
1983
  this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
1847
1984
  this.#resizeSettleTimer = undefined;
1848
1985
  if (this.#stopped) return;
1986
+ this.#prepareResizeReplay(this.terminal.columns, this.terminal.rows);
1987
+ if (this.#clearScrollbackOnNextRender) {
1988
+ this.#resizeInPlaceActive = false;
1989
+ this.requestRender();
1990
+ return;
1991
+ }
1849
1992
  this.#resizeProbeWindow = this.#providerWindow;
1850
1993
  this.#resizeProbeOffset = this.#parkedViewportOffset;
1851
1994
  this.#beginResizeAnchorProbe();
@@ -1867,13 +2010,14 @@ export class TUI extends Container {
1867
2010
  * cursor-relative addressing would start rows late; fall back to the same
1868
2011
  * bottom-preserving bound as resize-anchor recovery.
1869
2012
  *
1870
- * Both erase paths leave the cursor on the viewport's top row. Returns true
1871
- * when it erased (multiplexers skip it: an immediate erase races the pane
1872
- * re-layout and blanks pulled-back committed rows).
2013
+ * Both erase paths leave the cursor on the viewport's top row. Returns the
2014
+ * erase sequence, or "" when there is nothing to erase (multiplexers skip
2015
+ * it: an immediate erase races the pane re-layout and blanks pulled-back
2016
+ * committed rows).
1873
2017
  */
1874
- #eraseLiveViewportForResize(): boolean {
2018
+ #liveViewportResizeErase(): string {
1875
2019
  if (!this.#hasEverRendered || this.#providerWindow.length === 0 || isInsideTerminalMultiplexer()) {
1876
- return false;
2020
+ return "";
1877
2021
  }
1878
2022
  if (this.terminal.rows < this.#previousHeight) {
1879
2023
  const staleRows = this.#reflowedRowCount(
@@ -1883,13 +2027,11 @@ export class TUI extends Container {
1883
2027
  this.terminal.columns,
1884
2028
  );
1885
2029
  const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1886
- this.terminal.write(`\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`);
1887
- } else {
1888
- const up = this.#reflowedRowCount(this.#providerWindow, 0, this.#parkedViewportOffset, this.terminal.columns);
1889
- const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
1890
- this.terminal.write(`\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`);
2030
+ return `\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`;
1891
2031
  }
1892
- return true;
2032
+ const up = this.#reflowedRowCount(this.#providerWindow, 0, this.#parkedViewportOffset, this.terminal.columns);
2033
+ const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
2034
+ return `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`;
1893
2035
  }
1894
2036
 
1895
2037
  /**
@@ -1905,15 +2047,27 @@ export class TUI extends Container {
1905
2047
  return;
1906
2048
  }
1907
2049
  this.#trackResizeBurst();
1908
- if (!this.#resizeAltActive) {
2050
+ if (!this.#resizeAltActive && this.#resizeAltExitFused) {
2051
+ // The settled rebuild has not been written yet, so its fused exit never
2052
+ // left the borrowed buffer: resume the borrow there. The live window was
2053
+ // already stashed and the rebuild stays latched, so there is nothing to
2054
+ // snapshot or erase, and entering again would stack another alt switch.
2055
+ this.#resizeAltActive = true;
2056
+ this.#pendingAltExit = "";
2057
+ this.#resizeAltExitFused = false;
2058
+ } else if (!this.#resizeAltActive) {
1909
2059
  this.#resizeAltActive = true;
1910
2060
  setAltScreenActive(true);
1911
2061
  this.#altPreviousLines = [];
1912
2062
  this.#altPreparedRows = [];
1913
2063
  this.#forgetHardwareCursorState();
1914
2064
  this.#recordHardwareCursorHidden();
1915
- // Blank the live region up front so a reflow-driven scroll can only push
1916
- // committed rows into scrollback. The pre-erase window is stashed for
2065
+ // Blank the live region as the borrow enters so a reflow-driven scroll
2066
+ // can only push committed rows into scrollback. The erase is computed
2067
+ // against this SIGWINCH's geometry but rides in the first resize frame's
2068
+ // synchronized update with the buffer switch (#pendingAltEnter), so
2069
+ // neither the blanked normal screen nor the empty alternate one is ever
2070
+ // presented. The pre-erase window is stashed for
1917
2071
  // the settled CPR probe: its reflowed row count bounds the anchor to
1918
2072
  // `height - staleRows`, so a mis-parked cursor (a single-step tmux zoom
1919
2073
  // re-lays the pane before SIGWINCH delivery, moving the park target
@@ -1923,8 +2077,9 @@ export class TUI extends Container {
1923
2077
  this.#resizeProbeWindow = this.#providerWindow;
1924
2078
  this.#resizeProbeOffset = this.#parkedViewportOffset;
1925
2079
  }
1926
- if (this.#eraseLiveViewportForResize()) {
1927
- // The erase parked the cursor on the viewport's top row, so the
2080
+ const erase = this.#liveViewportResizeErase();
2081
+ if (erase !== "") {
2082
+ // The erase parks the cursor on the viewport's top row, so the
1928
2083
  // parked offset no longer applies; carrying a stale nonzero offset
1929
2084
  // into the probe would anchor the settled repaint above the real
1930
2085
  // viewport top and overwrite visible committed rows.
@@ -1947,23 +2102,70 @@ export class TUI extends Container {
1947
2102
  }
1948
2103
  this.#noteAltBufferToggle();
1949
2104
  this.#imageBudget.beginAltScreenLifecycle();
1950
- this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
2105
+ this.#pendingAltEnter = `${erase}\x1b[?1049h${this.#keyboardEnhancementEnter()}`;
1951
2106
  }
1952
2107
  this.#resizeSettleTimer?.cancel();
1953
2108
  this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
1954
2109
  this.#resizeSettleTimer = undefined;
1955
2110
  if (this.#stopped || !this.#resizeAltActive) return;
1956
- this.#resizeAltActive = false;
1957
- this.#suppressResizeUntil = this.#renderScheduler.now() + 100;
1958
- this.#noteAltBufferToggle();
1959
- this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1960
- setAltScreenActive(false);
1961
- this.#altPreviousLines = [];
1962
- this.#altPreparedRows = [];
1963
- this.#beginResizeAnchorProbe();
2111
+ this.#settleResizeAltPaint();
1964
2112
  }, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
1965
2113
  this.requestRender(true);
1966
2114
  }
2115
+
2116
+ /**
2117
+ * End the alt-buffer borrow once the resize settles.
2118
+ *
2119
+ * A settle that rebuilds history never needs the viewport anchor: the
2120
+ * destructive reset erases the screen and history and repaints from row
2121
+ * zero. Restoring the normal buffer on its own write would only expose the
2122
+ * reflowed stale screen for the CPR round trip, then clear it and stream
2123
+ * the replay — the one flash a large resize (a tmux zoom) still showed. So
2124
+ * that settle latches the rebuild now and fuses the restore into the same
2125
+ * synchronized write as ED2+ED3 and the replay: the terminal goes straight
2126
+ * from the resize frame to the rebuilt screen.
2127
+ *
2128
+ * Every other settle restores the normal buffer and probes the reflowed
2129
+ * anchor, which the non-destructive repaint depends on.
2130
+ */
2131
+ #settleResizeAltPaint(): void {
2132
+ this.#resizeAltActive = false;
2133
+ this.#suppressResizeUntil = this.#renderScheduler.now() + 100;
2134
+ this.#altPreviousLines = [];
2135
+ this.#altPreparedRows = [];
2136
+ const exitSequence = `${this.#keyboardEnhancementExit()}\x1b[?1049l`;
2137
+ // Only the provider plan frame emits a pending exit, and an exit already
2138
+ // pending (a fused overlay close) owns that slot.
2139
+ if (this.#frameProvider !== undefined && this.#pendingAltExit === "") {
2140
+ if (this.#resizeScrollbackMode === "rebuild") {
2141
+ this.#prepareResizeReplay(this.terminal.columns, this.terminal.rows);
2142
+ }
2143
+ if (this.#clearScrollbackOnNextRender) {
2144
+ // An entry no resize frame carried yet rides ahead of this exit in
2145
+ // the rebuild frame (see #renderProviderFrame).
2146
+ this.#pendingAltExit = exitSequence;
2147
+ this.#resizeAltExitFused = true;
2148
+ this.requestRender(true);
2149
+ return;
2150
+ }
2151
+ }
2152
+ this.#noteAltBufferToggle();
2153
+ this.terminal.write(this.#takePendingAltEnter() + exitSequence);
2154
+ setAltScreenActive(false);
2155
+ this.#beginResizeAnchorProbe();
2156
+ }
2157
+ /**
2158
+ * Claim the resize borrow's entry for the write that carries it. A borrow
2159
+ * that ends before any frame painted (output backpressure deferred them)
2160
+ * still owes its entry, so the exit write prepends it: one write, never a
2161
+ * bare buffer switch the terminal could present.
2162
+ */
2163
+ #takePendingAltEnter(): string {
2164
+ const enter = this.#pendingAltEnter;
2165
+ this.#pendingAltEnter = "";
2166
+ return enter;
2167
+ }
2168
+
1967
2169
  /**
1968
2170
  * Recover the reflowed viewport anchor after the resize settle window ends.
1969
2171
  * The terminal reflowed the restored normal buffer during the drag, so
@@ -2083,6 +2285,23 @@ export class TUI extends Container {
2083
2285
  let top: number;
2084
2286
  if (isInsideTerminalMultiplexer()) {
2085
2287
  if (reportedRow !== undefined) {
2288
+ // tmux can grow its grid before SIGWINCH reaches the app. A queued
2289
+ // retirement frame can then overwrite pulled-down history at the old
2290
+ // coordinates and park the cursor there. If a previously full viewport
2291
+ // no longer reaches the bottom, replay its semantic history instead of
2292
+ // trusting that potentially damaged physical copy. Ordinary grows with
2293
+ // an intact bottom anchor still need no replay or additional wait.
2294
+ if (
2295
+ this.#resizeScrollbackMode === "rebuild" &&
2296
+ classifyTerminalMultiplexerModule()?.growsBeforeSigwinch === true &&
2297
+ !this.#resizeRepaintsInPlace() &&
2298
+ this.#frameProvider?.beginHistoryReplay &&
2299
+ this.#resizeBurstGrew &&
2300
+ this.#providerViewportTop + probe.window.length >= this.#previousHeight &&
2301
+ reportedTop + staleRows < height
2302
+ ) {
2303
+ this.#prepareForcedRender(true);
2304
+ }
2086
2305
  // The parked cursor's reply is exact under multiplexer clipping:
2087
2306
  // discards leave the cursor in place, pushes only occur after
2088
2307
  // everything below it is discarded (the bottom row IS the
@@ -2195,6 +2414,35 @@ export class TUI extends Container {
2195
2414
  this.terminal.enableInput?.();
2196
2415
  this.#querySixelSupport();
2197
2416
  this.#queryCellSize();
2417
+ // The probes went out over the painted frame; a terminal that could not
2418
+ // parse one left its bytes on the cursor row (see Terminal.enableInput).
2419
+ this.requestRender(true);
2420
+ }
2421
+
2422
+ /**
2423
+ * Replay the keystrokes held since a TSP `deferInput` start through the
2424
+ * normal input path, then deliver input live. Call once the app's key
2425
+ * handlers are installed so a hotkey pressed during startup still fires.
2426
+ * Only keys typed while the start-time focus owner had focus are held. The
2427
+ * hold survives a stop/start until released. Idempotent; no-op when nothing
2428
+ * is held.
2429
+ */
2430
+ releaseHeldInput(): void {
2431
+ const held = this.#heldInput;
2432
+ if (held === undefined) return;
2433
+ this.#heldInput = undefined;
2434
+ if (this.#stopped) return;
2435
+ for (const data of held) this.#handleInput(data);
2436
+ }
2437
+
2438
+ /**
2439
+ * Hand held-key ownership from `previous` to `next` when the app replaces
2440
+ * the component focused at start (a swapped-in custom editor), so keys
2441
+ * typed into the replacement stay queued behind the held ones instead of
2442
+ * overtaking them. No-op unless `previous` owns the held keys.
2443
+ */
2444
+ replaceHeldFocus(previous: Component, next: Component): void {
2445
+ if (this.#heldInput !== undefined && this.#heldFocus === previous) this.#heldFocus = next;
2198
2446
  }
2199
2447
 
2200
2448
  addStartListener(listener: StartListener): () => void {
@@ -2220,17 +2468,19 @@ export class TUI extends Container {
2220
2468
  // PI_FORCE_IMAGE_PROTOCOL choice — including its `off` kill switch — wins
2221
2469
  // over the probe.
2222
2470
  if (TERMINAL.imageProtocol) return;
2471
+ // A Tern surface (live, or about to open optimistically at start) sends
2472
+ // images through TSP; this also keeps held startup input free of probe
2473
+ // listeners.
2474
+ if (this.#nativeLive || this.terminal.tspExpected) return;
2223
2475
  if (isImageProtocolForced()) return;
2224
2476
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
2225
2477
 
2226
2478
  this.#clearSixelProbeState();
2227
2479
  this.#sixelProbePendingGraphics = true;
2228
2480
  this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
2229
- // XTSMGRAPHICS item 2 reports the terminal's maximum SIXEL geometry. DA1
2230
- // attribute 4 advertises SIXEL as well, but ProcessTerminal swallows every
2231
- // `CSI ? … c` reply for the whole session so a late one cannot leak into the
2232
- // composer (#8542): those bytes never reach an input listener, so this probe
2233
- // cannot read them.
2481
+ // XTSMGRAPHICS item 2 reports the maximum SIXEL geometry. DA1 attribute 4
2482
+ // arrives through onSixelSupport instead: ProcessTerminal consumes the
2483
+ // reply bytes so they cannot leak into application input.
2234
2484
  this.terminal.write("\x1b[?2;1;0S");
2235
2485
  this.#sixelProbeTimeout = setTimeout(() => {
2236
2486
  this.#finishSixelProbe(false);
@@ -2314,7 +2564,11 @@ export class TUI extends Container {
2314
2564
 
2315
2565
  #finishSixelProbe(supported: boolean): void {
2316
2566
  this.#clearSixelProbeState();
2317
- if (!supported || TERMINAL.imageProtocol) return;
2567
+ if (supported) this.#enableSixelProtocol();
2568
+ }
2569
+
2570
+ #enableSixelProtocol(): void {
2571
+ if (TERMINAL.imageProtocol) return;
2318
2572
 
2319
2573
  setTerminalImageProtocol(ImageProtocol.Sixel);
2320
2574
  this.#queryCellSize();
@@ -2408,7 +2662,7 @@ export class TUI extends Container {
2408
2662
  this.#cancelResizeProbe();
2409
2663
  if (this.#resizeAltActive) {
2410
2664
  this.#resizeAltActive = false;
2411
- this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
2665
+ this.terminal.write(`${this.#takePendingAltEnter()}${this.#keyboardEnhancementExit()}\x1b[?1049l`);
2412
2666
  setAltScreenActive(false);
2413
2667
  }
2414
2668
  if (this.#altActive || this.#pendingAltExit) {
@@ -2419,7 +2673,7 @@ export class TUI extends Container {
2419
2673
  // so the final OFF goes last or the shell keeps reporting.
2420
2674
  const mouseExit = this.#mouseTracking !== "off" ? MOUSE_TRACKING_OFF : "";
2421
2675
  const exitSequence = this.#pendingAltExit
2422
- ? `${this.#pendingAltExit}${mouseExit}`
2676
+ ? `${this.#takePendingAltEnter()}${this.#pendingAltExit}${mouseExit}`
2423
2677
  : `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
2424
2678
  this.terminal.write(exitSequence);
2425
2679
  setAltScreenActive(false);
@@ -2428,6 +2682,7 @@ export class TUI extends Container {
2428
2682
  this.#altPreviousLines = [];
2429
2683
  this.#altPreparedRows = [];
2430
2684
  this.#pendingAltExit = "";
2685
+ this.#resizeAltExitFused = false;
2431
2686
  } else if (this.#mouseTracking !== "off") {
2432
2687
  // Inline capture with no overlay: still owned by us at quit, so
2433
2688
  // release it — otherwise the parent shell keeps mouse reporting
@@ -2582,6 +2837,13 @@ export class TUI extends Container {
2582
2837
  if (this.#renderTimer) {
2583
2838
  this.#renderTimer.cancel();
2584
2839
  this.#renderTimer = undefined;
2840
+ // The cancelled timer was the only callback owed to a pending ordinary
2841
+ // request. Every caller paints right after (a forced or ordinary request
2842
+ // it issues, or the render already in progress), and that paint serves
2843
+ // the request. Leaving the flag set would turn every later ordinary
2844
+ // request into a no-op: the resize settle's rebuild and all spinner
2845
+ // frames would never paint until some forced render arrived.
2846
+ this.#renderRequested = false;
2585
2847
  }
2586
2848
  }
2587
2849
 
@@ -2683,7 +2945,10 @@ export class TUI extends Container {
2683
2945
  const tagEpoch = this.#cprColumnTags.get(column);
2684
2946
  this.#cprColumnTags.delete(column);
2685
2947
  const probe = this.#resizeProbe;
2686
- if (probe !== undefined && tagEpoch === probe.epoch) {
2948
+ // tmux can resize its grid before the application receives SIGWINCH.
2949
+ // A reply below our known screen is from that newer geometry: do not
2950
+ // repaint it using the stale height. Wait for resize or the probe retry.
2951
+ if (probe !== undefined && tagEpoch === probe.epoch && row >= 1 && row <= this.terminal.rows) {
2687
2952
  this.#resolveResizeAnchor(Number(match[1]) - 1);
2688
2953
  }
2689
2954
  }
@@ -2693,12 +2958,47 @@ export class TUI extends Container {
2693
2958
  data = data.slice(0, searchFrom + match.index) + data.slice(searchFrom + match.index + match[0].length);
2694
2959
  }
2695
2960
  if (data.length === 0) return;
2961
+
2962
+ if (this.#heldInput !== undefined && this.#focusedComponent === this.#heldFocus) {
2963
+ this.#holdInput(data);
2964
+ return;
2965
+ }
2966
+
2967
+ // If focused component is an overlay, verify it's still visible (visibility can change due to
2968
+ // terminal resize or visible() callback). Runs before the capture preflight below, which must
2969
+ // target the effective focus owner, not a hidden overlay.
2970
+ const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
2971
+ if (focusedOverlay && !this.#isOverlayVisible(focusedOverlay)) {
2972
+ // Focused overlay is no longer visible, redirect to topmost visible overlay
2973
+ const topVisible = this.#getTopmostVisibleOverlay();
2974
+ if (topVisible) {
2975
+ this.setFocus(topVisible.component);
2976
+ } else {
2977
+ // No visible overlays, restore to preFocus
2978
+ this.setFocus(focusedOverlay.preFocus);
2979
+ }
2980
+ }
2981
+
2696
2982
  // Ctrl+C/Esc use app-level double-press windows. Give those gestures one
2697
2983
  // frame to drain queued input before an ordinary repaint; delaying every
2698
2984
  // key would make idle navigation pay a full frame of latency.
2699
2985
  if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
2700
2986
  this.#inputRenderGraceUntilMs = this.#renderScheduler.now() + TUI.#INPUT_RENDER_GRACE_MS;
2701
2987
  }
2988
+
2989
+ // A focused input owner can reserve its gesture before TUI-wide listeners
2990
+ // and debug shortcuts see the key, then handle the raw event exactly once.
2991
+ const focusedForCapture = this.#focusedComponent;
2992
+ if (
2993
+ focusedForCapture?.handleInput &&
2994
+ (!isKeyRelease(data) || focusedForCapture.wantsKeyRelease) &&
2995
+ focusedForCapture.capturesInput?.(data)
2996
+ ) {
2997
+ focusedForCapture.handleInput(data);
2998
+ this.requestRender();
2999
+ return;
3000
+ }
3001
+
2702
3002
  if (this.#inputListeners.size > 0) {
2703
3003
  let current = data;
2704
3004
  for (const listener of this.#inputListeners) {
@@ -2727,20 +3027,6 @@ export class TUI extends Container {
2727
3027
  return;
2728
3028
  }
2729
3029
 
2730
- // If focused component is an overlay, verify it's still visible
2731
- // (visibility can change due to terminal resize or visible() callback)
2732
- const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
2733
- if (focusedOverlay && !this.#isOverlayVisible(focusedOverlay)) {
2734
- // Focused overlay is no longer visible, redirect to topmost visible overlay
2735
- const topVisible = this.#getTopmostVisibleOverlay();
2736
- if (topVisible) {
2737
- this.setFocus(topVisible.component);
2738
- } else {
2739
- // No visible overlays, restore to preFocus
2740
- this.setFocus(focusedOverlay.preFocus);
2741
- }
2742
- }
2743
-
2744
3030
  // Pass input to focused component (including Ctrl+C).
2745
3031
  // The focused component can decide how to handle Ctrl+C.
2746
3032
  // Opted-in components only dirty their focused subtree. Unregistered
@@ -2757,6 +3043,18 @@ export class TUI extends Container {
2757
3043
  }
2758
3044
  }
2759
3045
 
3046
+ /**
3047
+ * Queue a keystroke typed before the app installed its key handlers; input
3048
+ * listeners see it once, on replay. The cell-size reply is a terminal report,
3049
+ * consumed now. Ctrl+C/Ctrl+D release the queue so a stalled startup stays
3050
+ * interruptible.
3051
+ */
3052
+ #holdInput(data: string): void {
3053
+ if (this.#consumeCellSizeResponse(data)) return;
3054
+ this.#heldInput!.push(data);
3055
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "ctrl+d")) this.releaseHeldInput();
3056
+ }
3057
+
2760
3058
  #consumeCellSizeResponse(data: string): boolean {
2761
3059
  // Response format: ESC [ 6 ; height ; width t
2762
3060
  const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
@@ -2955,103 +3253,87 @@ export class TUI extends Container {
2955
3253
  if (idx < 0 || idx >= result.length) continue;
2956
3254
  const truncatedOverlayLine =
2957
3255
  visibleWidth(overlayLines[i]) > width ? sliceByColumn(overlayLines[i], 0, width, true) : overlayLines[i];
2958
- result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
3256
+ result[idx] = compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
2959
3257
  }
2960
3258
  }
2961
3259
  return result;
2962
3260
  }
2963
3261
 
2964
- /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
2965
- #compositeLineAt(
2966
- baseLine: string,
2967
- overlayLine: string,
2968
- startCol: number,
2969
- overlayWidth: number,
2970
- totalWidth: number,
3262
+ /**
3263
+ * Remove placements before their viewport cells are replaced. History uses
3264
+ * separate placement ids, so deleting a live copy cannot erase scrollback.
3265
+ */
3266
+ #retireImagePlacements(
3267
+ placements: Map<number, LiveImagePlacement>,
3268
+ lines: readonly string[],
3269
+ top: number,
3270
+ repaint: boolean,
2971
3271
  ): string {
2972
- if (TERMINAL.isImageLine(baseLine)) {
2973
- // Full-width overlays such as /switch are opaque: replace the
2974
- // Unicode placeholder cells so the image cannot cover the modal.
2975
- // Partial overlays cannot safely splice placement control sequences.
2976
- if (startCol !== 0 || overlayWidth < totalWidth) return baseLine;
2977
- const overlay = sliceWithWidth(overlayLine, 0, totalWidth, true);
2978
- return SEGMENT_RESET + overlay.text + " ".repeat(Math.max(0, totalWidth - overlay.width));
2979
- }
2980
-
2981
- // Single pass through baseLine extracts both before and after segments
2982
- const afterStart = startCol + overlayWidth;
2983
- const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
2984
-
2985
- // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
2986
- const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
2987
-
2988
- // Pad segments to target widths
2989
- const beforePad = Math.max(0, startCol - base.beforeWidth);
2990
- const overlayPad = Math.max(0, overlayWidth - overlay.width);
2991
- const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
2992
- const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
2993
- const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
2994
- const afterPad = Math.max(0, afterTarget - base.afterWidth);
2995
-
2996
- // Compose result
2997
- const r = SEGMENT_RESET;
2998
- const result =
2999
- base.before +
3000
- " ".repeat(beforePad) +
3001
- r +
3002
- overlay.text +
3003
- " ".repeat(overlayPad) +
3004
- r +
3005
- base.after +
3006
- " ".repeat(afterPad);
3007
-
3008
- // CRITICAL: Always verify and truncate to terminal width.
3009
- // This is the final safeguard against width overflow which would crash the TUI.
3010
- // Width tracking can drift from actual visible width due to:
3011
- // - Complex ANSI/OSC sequences (hyperlinks, colors)
3012
- // - Wide characters at segment boundaries
3013
- // - Edge cases in segment extraction
3014
- const resultWidth = visibleWidth(result);
3015
- if (resultWidth <= totalWidth) {
3016
- return result;
3017
- }
3018
- // Truncate with strict=true to ensure we don't exceed totalWidth
3019
- return sliceByColumn(result, 0, totalWidth, true);
3272
+ let output = "";
3273
+ for (const [row, placement] of placements) {
3274
+ if (!repaint && lines[row - top] === placement.line) continue;
3275
+ output += encodeKittyDeletePlacement(placement.imageId, placement.placementId);
3276
+ placements.delete(row);
3277
+ }
3278
+ return output;
3020
3279
  }
3021
3280
 
3022
- /**
3023
- * Rewrite a Kitty direct-placement line for the viewport row it is written
3024
- * at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
3025
- * under the placement id resolved by the budget's epoch tracking (see
3026
- * {@link ImageBudget.resolvePlacementEmit}). `screenRow` -1 (write position
3027
- * unknown) and non-placement image lines (placeholder grids, sixel, iTerm2,
3028
- * tmux-wrapped) pass through verbatim.
3029
- */
3030
- #imageLineSequence(line: string, screenRow: number, frameRow: number, committedTo: number): string {
3281
+ /** Clip a direct placement and record its live owner; history placements have no mutable owner. */
3282
+ #imageLineSequence(
3283
+ line: string,
3284
+ screenRow: number,
3285
+ placements: Map<number, LiveImagePlacement> | undefined,
3286
+ placementRow: number,
3287
+ ): string {
3031
3288
  if (screenRow < 0) return line;
3032
3289
  const parsed = parseKittyDirectPlacementLine(line);
3033
3290
  if (!parsed) return line;
3034
- // The emitted placement attaches from the block's first *visible* row
3035
- // (the clip drops the rows above the viewport), so epoch tracking keys
3036
- // on that row — not the block origin, which may be long committed.
3037
- const placement = this.#imageBudget.resolvePlacementEmit(
3038
- parsed.imageId,
3039
- frameRow >= 0 ? frameRow - Math.min(parsed.rows - 1, screenRow) : -1,
3040
- committedTo,
3291
+ const previous = placements?.get(placementRow);
3292
+ const placementId = previous?.placementId ?? this.#nextImagePlacementId++;
3293
+ const erase = previous ? encodeKittyDeletePlacement(previous.imageId, previous.placementId) : "";
3294
+ placements?.set(placementRow, { imageId: parsed.imageId, placementId, line });
3295
+ return (
3296
+ erase +
3297
+ encodeKittyPlacementLine({
3298
+ imageId: parsed.imageId,
3299
+ placementId,
3300
+ columns: parsed.columns,
3301
+ rows: parsed.rows,
3302
+ screenRow,
3303
+ imageHeightPx: this.#imageBudget.getPlacementGeometry(parsed.imageId)?.heightPx ?? 0,
3304
+ })
3041
3305
  );
3042
- if (!placement) return line;
3043
- return encodeKittyPlacementLine({
3044
- imageId: parsed.imageId,
3045
- placementId: placement.placementId,
3046
- columns: parsed.columns,
3047
- rows: parsed.rows,
3048
- screenRow,
3049
- imageHeightPx: placement.heightPx,
3050
- });
3051
3306
  }
3052
3307
 
3053
3308
  #terminalLine(line: PreparedLine): string {
3054
- return line.terminalContent + (line.hasOsc8 ? LINE_TERMINATOR : SEGMENT_RESET);
3309
+ if (line.hasOsc8) return line.terminalContent + LINE_TERMINATOR;
3310
+ return line.terminalContent.endsWith(SEGMENT_RESET) ? line.terminalContent : line.terminalContent + SEGMENT_RESET;
3311
+ }
3312
+
3313
+ /** Encode padding with REP, which tmux expands into identical styled cells. */
3314
+ #compactReplaySpaces(line: string): string {
3315
+ let copied = 0;
3316
+ let output = "";
3317
+ for (let offset = 0; offset < line.length;) {
3318
+ const spaces = line.indexOf(" ", offset);
3319
+ if (spaces === -1) break;
3320
+ const escape = line.indexOf("\x1b", offset);
3321
+ if (escape !== -1 && escape < spaces) {
3322
+ // Only inspect text outside CSI/OSC. Unknown control strings (for
3323
+ // example DCS payloads) must remain byte-for-byte unchanged.
3324
+ const introducer = line.charCodeAt(escape + 1);
3325
+ if (introducer !== 0x5b && introducer !== 0x5d) return line;
3326
+ const end = this.#ansiSequenceEnd(line, escape);
3327
+ if (end < 0) break;
3328
+ offset = end;
3329
+ continue;
3330
+ }
3331
+ let end = spaces + 8;
3332
+ while (line.charCodeAt(end) === 0x20) end++;
3333
+ output += `${line.slice(copied, spaces)} \x1b[${end - spaces - 1}b`;
3334
+ copied = offset = end;
3335
+ }
3336
+ return copied === 0 ? line : output + line.slice(copied);
3055
3337
  }
3056
3338
 
3057
3339
  #notifyPaint(paint: TuiPaint): void {
@@ -3128,12 +3410,21 @@ export class TUI extends Container {
3128
3410
  * out of scrollback), so replaying would write an identical duplicate — the
3129
3411
  * editor/status chrome included — below the retained copy. Skip it, matching
3130
3412
  * the `widthChanged`-gated commit-ledger logic in {@link #doRender}.
3413
+ *
3414
+ * Both gates key on the whole coalesced burst, not the net change: the
3415
+ * terminal reflowed the normal buffer at every intermediate geometry, so a
3416
+ * drag that ends where it started (80 → 60 → 80) has still rewrapped
3417
+ * retained history and pushed unerased live rows into it. Comparing only
3418
+ * the settled size against the committed one would skip the refresh and
3419
+ * leave those stale copies stacked above the repainted viewport.
3131
3420
  */
3132
3421
  #prepareResizeReplay(width: number, height: number): void {
3133
3422
  const size = `${width}x${height}`;
3423
+ const widthChanged = this.#resizeBurstWidthChanged || width !== this.#previousPhysicalWidth;
3424
+ const resized = this.#resizeBurstResized || widthChanged || height !== this.#previousHeight;
3134
3425
  if (
3135
3426
  !this.#hasEverRendered ||
3136
- (this.#previousPhysicalWidth === width && this.#previousHeight === height) ||
3427
+ !resized ||
3137
3428
  this.#resizeReplaySize === size ||
3138
3429
  this.#resizeScrollbackMode === "preserve" ||
3139
3430
  // In-place resizes (Warp) repaint the settled viewport once the drag
@@ -3150,15 +3441,38 @@ export class TUI extends Container {
3150
3441
  this.#forceViewportRepaintOnNextRender = true;
3151
3442
  return;
3152
3443
  }
3444
+ // A height-only settled resize rewraps nothing — rewrap is a width change
3445
+ // everywhere. A pure height grow pulls committed scrollback down without
3446
+ // polluting a copy, so the rebuild (an ED3 plus a full ledger replay)
3447
+ // would be a destructive repaint that buys nothing. `rebuild` still
3448
+ // refreshes on a burst shrink (the multiplexer pushes live pane rows into its
3449
+ // scrollback; the destructive refresh is the only purge) and on a host
3450
+ // that repaints its own grid (ConPTY's stale re-emission is untrusted).
3153
3451
  if (this.#resizeScrollbackMode === "rebuild") {
3452
+ // Every destructive cause warrants a rebuild: a burst shrink can push
3453
+ // live rows into history (tmux discards rows below the cursor, then
3454
+ // pushes rows above it; a later grow cannot undo that clipping or the
3455
+ // provider's retirement), and ConPTY can repaint its own stale grid.
3456
+ if (!widthChanged && this.terminal.hostOwnsGridOnResize !== true && !this.#resizeBurstShrank) return;
3154
3457
  this.#prepareForcedRender(true);
3155
3458
  return;
3156
3459
  }
3157
- if (width === this.#previousPhysicalWidth) return;
3460
+ if (!widthChanged) return;
3158
3461
  provider.beginHistoryReplay();
3159
3462
  this.#forceViewportRepaintOnNextRender = true;
3160
3463
  }
3161
3464
 
3465
+ /**
3466
+ * Bottom-row `Rebuilding…` notice, closed as its own synchronized update so
3467
+ * tmux forwards it before the replay that follows starts a new hold. Cursor
3468
+ * save/restore and no newline keep the row out of native scrollback; the
3469
+ * replay's ED2 erases it together with the rest of the screen.
3470
+ */
3471
+ #rebuildNoticeSequence(width: number, height: number): string {
3472
+ const label = truncateToWidth("↻ Rebuilding…", width, Ellipsis.Omit);
3473
+ return `${this.#paintBeginSequence}\x1b7\x1b[${height};1H${SEGMENT_RESET}${ERASE_LINE}${label}${LINE_TERMINATOR}\x1b8${this.#paintEndSequence}`;
3474
+ }
3475
+
3162
3476
  /**
3163
3477
  * Erase `row` and everything below it, absolute-addressed, without ever
3164
3478
  * issuing a full-screen clear. tmux and Windows conhost (#9597) archive a
@@ -3250,6 +3564,7 @@ export class TUI extends Container {
3250
3564
  // resize in rebuild mode): erase native history and the viewport,
3251
3565
  // then repaint from row zero.
3252
3566
  const destructiveReset = this.#clearScrollbackOnNextRender;
3567
+ const compactReplay = destructiveReset && classifyTerminalMultiplexerModule()?.expandsRepPadding === true;
3253
3568
  if (destructiveReset) {
3254
3569
  this.#providerViewportTop = 0;
3255
3570
  this.#providerWindow = [];
@@ -3259,11 +3574,40 @@ export class TUI extends Container {
3259
3574
  // screen. Appending K history rows moves the anchor down by K; the write
3260
3575
  // scrolls only when history + viewport overflow the physical screen, and
3261
3576
  // the rows that scroll off the top are exactly the oldest history rows.
3577
+ //
3578
+ // A frame that transiently grew (an editor suggestion row) scrolls the same
3579
+ // way — the extra rows must become visible — but its push was never
3580
+ // committed by a history transaction, so the shrink when the transient
3581
+ // chrome row leaves pulls those rows back and restores the pre-growth
3582
+ // anchor instead of parking the frame one row high above a trailing blank.
3583
+ // Frames carrying a history transaction, a reset, an overlay, or a new
3584
+ // geometry commit or supersede the scroll and track nothing.
3262
3585
  const geometryStable = this.#hasEverRendered && this.#previousWidth === width && this.#previousHeight === height;
3263
3586
  const startTop = destructiveReset ? 0 : Math.min(this.#providerViewportTop, Math.max(0, height - 1));
3264
- const newTop = Math.max(0, Math.min(startTop + historyRows.length, height - rows));
3587
+ const transientFrame =
3588
+ history === undefined && !destructiveReset && geometryStable && this.#getTopmostVisibleOverlay() === undefined;
3589
+ let anchorPull = 0;
3590
+ if (transientFrame && rows < this.#previousFrameLength) {
3591
+ anchorPull = Math.min(Math.max(0, height - rows - startTop), this.#transientScrollPushed);
3592
+ }
3593
+ const newTop = Math.max(0, Math.min(startTop + historyRows.length, height - rows)) + anchorPull;
3265
3594
  const pendingAltExit = this.#pendingAltExit;
3266
- let buffer = this.#paintBeginSequence + pendingAltExit;
3595
+ // A fused resize exit whose borrow never painted still owes its entry.
3596
+ let buffer = this.#paintBeginSequence + (pendingAltExit ? this.#takePendingAltEnter() : "") + pendingAltExit;
3597
+ const renewSync =
3598
+ destructiveReset &&
3599
+ this.#resizeScrollbackMode === "rebuild" &&
3600
+ this.#synchronizedOutputEnabled &&
3601
+ classifyTerminalMultiplexerModule()?.expiresSynchronizedOutput === true;
3602
+ let syncBytes = Buffer.byteLength(buffer);
3603
+ const append = (sequence: string): void => {
3604
+ buffer += sequence;
3605
+ if (!renewSync) return;
3606
+ syncBytes += Buffer.byteLength(sequence);
3607
+ if (syncBytes < SYNC_OUTPUT_RENEW_BYTES) return;
3608
+ buffer += SYNC_OUTPUT_BEGIN;
3609
+ syncBytes = 0;
3610
+ };
3267
3611
  if (destructiveReset && TERMINAL.imageProtocol === ImageProtocol.Kitty) {
3268
3612
  // A reset is explicitly destructive, so remove every placement—not only
3269
3613
  // the ones this TUI tracked—then resend images composed for the clean
@@ -3275,7 +3619,7 @@ export class TUI extends Container {
3275
3619
  // reset forgot are named explicitly here — their tracking is gone, so
3276
3620
  // nothing downstream could find them again.
3277
3621
  for (const id of this.#imageBudget.takeResetPurgeIds()) buffer += encodeKittyDeleteImage(id);
3278
- this.#imageBudget.resetPlacementEpochs();
3622
+ this.#screenImagePlacements.clear();
3279
3623
  }
3280
3624
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
3281
3625
  for (const id of this.#imageBudget.takePurgeIds()) buffer += encodeKittyDeleteImage(id);
@@ -3297,15 +3641,17 @@ export class TUI extends Container {
3297
3641
  // erase reclaim the data, so the replay's placements then referenced an
3298
3642
  // image the terminal no longer had and every inline image vanished
3299
3643
  // after a settled width resize.
3300
- if (destructiveReset) buffer += "\x1b[H\x1b[2J\x1b[3J";
3301
- for (const sequence of this.#imageBudget.takeTransmits()) buffer += sequence;
3644
+ if (destructiveReset) buffer += `${LINE_TERMINATOR}\x1b[H\x1b[2J\x1b[3J`;
3302
3645
  const diffable =
3646
+ anchorPull === 0 &&
3303
3647
  geometryStable &&
3304
3648
  historyRows.length === 0 &&
3305
3649
  startTop === newTop &&
3306
3650
  !this.#forceViewportRepaintOnNextRender &&
3307
3651
  !destructiveReset &&
3308
3652
  this.#providerWindow.length > 0;
3653
+ buffer += this.#retireImagePlacements(this.#screenImagePlacements, prepared.lines, newTop, !diffable);
3654
+ for (const sequence of this.#imageBudget.takeTransmits()) append(sequence);
3309
3655
  if (diffable) {
3310
3656
  for (let index = 0; index < rows; index++) {
3311
3657
  if (
@@ -3324,9 +3670,8 @@ export class TUI extends Container {
3324
3670
  current,
3325
3671
  width,
3326
3672
  newTop + index,
3327
- -1,
3328
- -1,
3329
3673
  this.#osc66SpacerGlyphWidth(prepared.lines, index),
3674
+ { imagePlacements: this.#screenImagePlacements },
3330
3675
  )}`;
3331
3676
  }
3332
3677
  if (this.#providerWindow.length > rows && newTop + rows < height) {
@@ -3342,29 +3687,45 @@ export class TUI extends Container {
3342
3687
  if (pushed > this.#providerViewportTop && this.#providerWindow.length > 0) {
3343
3688
  buffer += this.#eraseBelowRow(this.#providerViewportTop, height);
3344
3689
  }
3345
- buffer += `\x1b[${startTop + 1};1H`;
3346
- let screenRow = startTop;
3690
+ // The un-committed push of a transiently grown frame, returned from
3691
+ // scrollback: `CSI Ps + T` scrolls the grid down Ps rows filling the
3692
+ // top from scrollback (kitty's SD extension; terminals without it
3693
+ // ignore the sequence and keep today's shifted layout). The write
3694
+ // below then lands on the restored anchor; its rows exactly fill the
3695
+ // screen, so nothing scrolls further.
3696
+ if (anchorPull > 0) buffer += `\x1b[${anchorPull}+T`;
3697
+ // A history-carrying write starts at the anchor and lets any overflow
3698
+ // scroll; a pulled write owns the whole restored region below it.
3699
+ const writeTop = historyRows.length > 0 || anchorPull === 0 ? startTop : newTop;
3700
+ buffer += `\x1b[${writeTop + 1};1H`;
3701
+ let screenRow = writeTop;
3347
3702
  for (let index = 0; index < preparedHistory.lines.length; index++) {
3348
3703
  if (screenRow > startTop) buffer += "\n";
3349
- buffer += this.#lineRewriteSequence(
3350
- preparedHistory.rows[index]!,
3351
- width,
3352
- Math.min(screenRow, height - 1),
3353
- -1,
3354
- -1,
3355
- this.#osc66SpacerGlyphWidth(preparedHistory.lines, index),
3704
+ append(
3705
+ this.#lineRewriteSequence(
3706
+ preparedHistory.rows[index]!,
3707
+ width,
3708
+ Math.min(screenRow, height - 1),
3709
+ this.#osc66SpacerGlyphWidth(preparedHistory.lines, index),
3710
+ { blankRow: compactReplay },
3711
+ ),
3356
3712
  );
3357
3713
  screenRow++;
3358
3714
  }
3359
3715
  for (let index = 0; index < rows; index++) {
3360
3716
  if (screenRow > startTop) buffer += "\n";
3361
- buffer += this.#lineRewriteSequence(
3362
- prepared.rows[index]!,
3363
- width,
3364
- Math.min(screenRow, height - 1),
3365
- -1,
3366
- -1,
3367
- this.#osc66SpacerGlyphWidth(prepared.lines, index),
3717
+ append(
3718
+ this.#lineRewriteSequence(
3719
+ prepared.rows[index]!,
3720
+ width,
3721
+ Math.min(screenRow, height - 1),
3722
+ this.#osc66SpacerGlyphWidth(prepared.lines, index),
3723
+ {
3724
+ blankRow: compactReplay,
3725
+ imagePlacements: index < replayViewportRows ? undefined : this.#screenImagePlacements,
3726
+ imageRow: newTop + index,
3727
+ },
3728
+ ),
3368
3729
  );
3369
3730
  screenRow++;
3370
3731
  }
@@ -3389,6 +3750,14 @@ export class TUI extends Container {
3389
3750
  this.#parkedViewportOffset = 0;
3390
3751
  }
3391
3752
  buffer += this.#paintEndSequence;
3753
+ if (
3754
+ renewSync &&
3755
+ pendingAltExit === "" &&
3756
+ this.#resizeReplaySize !== undefined &&
3757
+ Buffer.byteLength(buffer) >= TUI.#RESIZE_REBUILD_NOTICE_MIN_BYTES
3758
+ ) {
3759
+ this.terminal.write(this.#rebuildNoticeSequence(width, height));
3760
+ }
3392
3761
  this.terminal.write(buffer);
3393
3762
  this.#debugPaint = {
3394
3763
  lines: prepared.lines,
@@ -3399,6 +3768,7 @@ export class TUI extends Container {
3399
3768
  if (pendingAltExit) {
3400
3769
  this.#noteAltBufferToggle();
3401
3770
  this.#pendingAltExit = "";
3771
+ this.#resizeAltExitFused = false;
3402
3772
  setAltScreenActive(false);
3403
3773
  }
3404
3774
  if (target) this.#recordHardwareCursorState(target);
@@ -3406,12 +3776,28 @@ export class TUI extends Container {
3406
3776
  this.#providerWindow = mutablePreparedLines;
3407
3777
  this.#providerPreparedRows = mutablePreparedRows;
3408
3778
  this.#providerViewportTop = mutableTop;
3779
+ // Track the un-committed scroll of a transiently grown frame so the
3780
+ // matching shrink can pull exactly those rows back. Every committing or
3781
+ // superseding frame zeroes the debt: history appends and replays own
3782
+ // their scrolls, a reset wipes the ledger, a new geometry reflows it,
3783
+ // and an overlay frame paints against full-screen padding.
3784
+ if (transientFrame) {
3785
+ const scrolledOff = Math.max(0, startTop + rows - height);
3786
+ this.#transientScrollPushed =
3787
+ Math.min(this.#transientScrollPushed + scrolledOff, Math.max(0, height - 1)) - anchorPull;
3788
+ if (this.#transientScrollPushed < 0) this.#transientScrollPushed = 0;
3789
+ } else {
3790
+ this.#transientScrollPushed = 0;
3791
+ }
3409
3792
  this.#providerViewportPadTop = replayViewportRows - replayPrependedBlanks;
3410
3793
  this.#previousWidth = width;
3411
3794
  this.#previousHeight = height;
3412
3795
  this.#resizeBurstGrew = false;
3796
+ this.#resizeBurstShrank = false;
3413
3797
  this.#resizeBurstLastHeight = undefined;
3414
3798
  this.#resizeBurstPull = 0;
3799
+ this.#resizeBurstWidthChanged = false;
3800
+ this.#resizeBurstResized = false;
3415
3801
  this.#previousFrameLength = mutablePreparedLines.length;
3416
3802
  this.#clearScrollbackOnNextRender = false;
3417
3803
  this.#forceViewportRepaintOnNextRender = false;
@@ -3457,11 +3843,9 @@ export class TUI extends Container {
3457
3843
  return;
3458
3844
  }
3459
3845
  if (this.#resizeInPlaceActive && !this.#altActive) {
3460
- // In-place resize settling (Warp): the normal-buffer anchor is stale until
3461
- // the settled CPR probe resolves. Painting now would overwrite retained
3462
- // history and record the new geometry over the pending recovery, so drop
3463
- // the frame — the resolve repaints. Fullscreen overlay paints are
3464
- // buffer-isolated and still allowed.
3846
+ // The normal-buffer anchor is stale until the settled rebuild or CPR
3847
+ // recovery. Painting now would overwrite history and record the new
3848
+ // geometry over the pending recovery, so defer until the settle.
3465
3849
  return;
3466
3850
  }
3467
3851
 
@@ -3483,9 +3867,19 @@ export class TUI extends Container {
3483
3867
  // modified-key reporting sequence on the freshly entered alternate
3484
3868
  // screen, or Esc/modified keys revert to legacy encoding inside
3485
3869
  // fullscreen overlays (Ghostty/kitty/iTerm2).
3486
- this.#noteAltBufferToggle();
3487
- this.#imageBudget.beginAltScreenLifecycle();
3488
- this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
3870
+ if (this.#resizeAltExitFused) {
3871
+ // An overlay opened while the settled resize rebuild was still
3872
+ // pending: the terminal never left the borrowed buffer, which already
3873
+ // carries the pushed keyboard mode and its image store, so the overlay
3874
+ // takes it over. The latched rebuild then lands with the overlay's
3875
+ // own fused exit when it closes.
3876
+ this.#pendingAltExit = "";
3877
+ this.#resizeAltExitFused = false;
3878
+ } else {
3879
+ this.#noteAltBufferToggle();
3880
+ this.#imageBudget.beginAltScreenLifecycle();
3881
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
3882
+ }
3489
3883
  this.#setMouseTracking(wantMouse);
3490
3884
  setAltScreenActive(true);
3491
3885
  this.terminal.hideCursor();
@@ -3589,6 +3983,7 @@ export class TUI extends Container {
3589
3983
  if (this.#maybeDeferGhosttyInitialImagePaint()) return;
3590
3984
  this.#emitPlanFrame(width, height, viewport, undefined, undefined);
3591
3985
  this.#paintGutter(width, rawWidth, height, viewport.length);
3986
+ this.#previousPhysicalWidth = rawWidth;
3592
3987
  }
3593
3988
 
3594
3989
  /**
@@ -3965,9 +4360,8 @@ export class TUI extends Container {
3965
4360
  line: PreparedLine,
3966
4361
  width: number,
3967
4362
  screenRow = -1,
3968
- frameRow = -1,
3969
- committedTo = -1,
3970
4363
  spacerGlyphWidth = -1,
4364
+ options?: { blankRow?: boolean; imagePlacements?: Map<number, LiveImagePlacement>; imageRow?: number },
3971
4365
  ): string {
3972
4366
  // End every rewrite at column zero. ConPTY can materialize a pending
3973
4367
  // wrap before a following cursor-addressing sequence even while DECAWM is
@@ -3984,10 +4378,19 @@ export class TUI extends Container {
3984
4378
  if (spacerGlyphWidth >= 0) {
3985
4379
  rewrite = spacerGlyphWidth >= width ? "" : `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
3986
4380
  } else if (line.isImage) {
3987
- rewrite = ERASE_LINE + this.#imageLineSequence(line.line, screenRow, frameRow, committedTo);
4381
+ rewrite =
4382
+ ERASE_LINE +
4383
+ this.#imageLineSequence(line.line, screenRow, options?.imagePlacements, options?.imageRow ?? screenRow);
3988
4384
  } else {
3989
4385
  const terminalLine = this.#terminalLine(line);
3990
- if (line.asciiWidth !== undefined) {
4386
+ if (options?.blankRow) {
4387
+ // A destructive replay starts on a cleared screen and advances only
4388
+ // into fresh rows. Each line resets its rendition before scrolling,
4389
+ // so those rows already have the default background. Re-erasing every
4390
+ // row only adds terminal parser work; images and OSC 66 spacers keep
4391
+ // their dedicated cleanup above.
4392
+ rewrite = this.#compactReplaySpaces(terminalLine);
4393
+ } else if (line.asciiWidth !== undefined) {
3991
4394
  // Exact width model: skip the erase only when the row truly fills
3992
4395
  // the line (an EL there would eat the last cell via pending-wrap).
3993
4396
  rewrite = line.asciiWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
@@ -4052,9 +4455,9 @@ export class TUI extends Container {
4052
4455
 
4053
4456
  /**
4054
4457
  * Compose and paint a single fullscreen overlay frame on the alt buffer.
4055
- * Cursor markers are stripped (the modal draws its own in-band caret and
4056
- * keeps the hardware cursor hidden), and only the modal is composited over a
4057
- * blank base — the transcript is never touched while the alt buffer is up.
4458
+ * Cursor markers are stripped from the frame, but only a focused cursor-mode
4459
+ * component owned by the fullscreen overlay may position the hardware cursor.
4460
+ * The normal transcript is never composited into the alternate buffer.
4058
4461
  */
4059
4462
  #renderAltFrame(width: number, height: number): void {
4060
4463
  // oxlint-disable-next-line unicorn/no-new-array -- alt-frame length preallocation
@@ -4064,8 +4467,18 @@ export class TUI extends Container {
4064
4467
  this.#imageBudget.beginPass(false, true);
4065
4468
  lines = this.#compositeOverlaysIntoWindow(base, width, height);
4066
4469
  } while (this.#imageBudget.endPass());
4067
- const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height, []);
4068
- this.#emitAltFrame(prepared, width, height, true);
4470
+ const markers: { row: number; col: number }[] = [];
4471
+ const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height, markers);
4472
+ const topOverlay = this.#getTopmostVisibleOverlay();
4473
+ const focused = this.#focusedComponent;
4474
+ const acceptsTerminalCursor =
4475
+ topOverlay !== undefined &&
4476
+ focused !== null &&
4477
+ isFocusable(focused) &&
4478
+ focused.focused &&
4479
+ typeof focused.setUseTerminalCursor === "function" &&
4480
+ isOverlayFocusTarget(topOverlay.component, focused);
4481
+ this.#emitAltFrame(prepared, width, height, true, acceptsTerminalCursor ? (markers[0] ?? null) : null);
4069
4482
  }
4070
4483
 
4071
4484
  /**
@@ -4073,9 +4486,15 @@ export class TUI extends Container {
4073
4486
  * previous frame, or every row when the height changed, a repaint is forced,
4074
4487
  * or a changed frame holds OSC 66 text before or after. Emits only
4075
4488
  * sync-output brackets, cursor moves, and per-row rewrites — never ED3 or
4076
- * any native-scrollback byte. The hardware cursor stays hidden here.
4489
+ * any native-scrollback byte.
4077
4490
  */
4078
- #emitAltFrame(prepared: PreparedLines, width: number, height: number, notifyPaint: boolean): void {
4491
+ #emitAltFrame(
4492
+ prepared: PreparedLines,
4493
+ width: number,
4494
+ height: number,
4495
+ notifyPaint: boolean,
4496
+ cursorPosition: { row: number; col: number } | null = null,
4497
+ ): void {
4079
4498
  // The pass that composed this frame ran with `altScreen`, so the normal
4080
4499
  // screen's own placements behind it are not treated as retired.
4081
4500
  this.#imageBudget.limitResidentImages();
@@ -4084,17 +4503,14 @@ export class TUI extends Container {
4084
4503
  // frame resolve against loaded data. The normal-screen path flushes these
4085
4504
  // ahead of its paint; without this, an image first shown inside a
4086
4505
  // fullscreen overlay (e.g. the settings shape preview) would render as
4087
- // blank placeholder cells until the overlay closed.
4506
+ // blank placeholder cells until the overlay closed. A pending resize-borrow
4507
+ // entry leads: the image commands address the alternate buffer's store.
4508
+ let prelude = this.#takePendingAltEnter();
4088
4509
  const purgeIds = this.#imageBudget.takePurgeIds();
4089
4510
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
4090
- for (const id of purgeIds) this.terminal.write(encodeKittyDeleteImage(id));
4091
- }
4092
- const imageTransmits = this.#imageBudget.takeTransmits();
4093
- if (imageTransmits.length > 0) {
4094
- let transmitBuffer = "";
4095
- for (const seq of imageTransmits) transmitBuffer += seq;
4096
- this.terminal.write(transmitBuffer);
4511
+ for (const id of purgeIds) prelude += encodeKittyDeleteImage(id);
4097
4512
  }
4513
+ for (const seq of this.#imageBudget.takeTransmits()) prelude += seq;
4098
4514
  // A forced repaint (resetDisplay, requestRender(true)) rewrites every row
4099
4515
  // even when the cached frame is byte-identical: the redraw gesture must
4100
4516
  // repair a corrupted modal. So does a changed frame with OSC 66 text in
@@ -4111,6 +4527,7 @@ export class TUI extends Container {
4111
4527
  prepared.rows.some((_row, r) =>
4112
4528
  this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r),
4113
4529
  ));
4530
+ prelude += this.#retireImagePlacements(this.#altImagePlacements, prepared.lines, 0, full);
4114
4531
  let rowsBuffer = "";
4115
4532
  for (let r = 0; r < height; r++) {
4116
4533
  if (full) {
@@ -4126,17 +4543,44 @@ export class TUI extends Container {
4126
4543
  prepared.rows[r]!,
4127
4544
  width,
4128
4545
  r,
4129
- -1,
4130
- -1,
4131
4546
  this.#osc66SpacerGlyphWidth(prepared.lines, r),
4547
+ { imagePlacements: this.#altImagePlacements },
4132
4548
  );
4133
4549
  }
4134
4550
  this.#altPreviousLines = prepared.lines;
4135
4551
  this.#altPreparedRows = prepared.rows;
4136
- if (rowsBuffer === "") return;
4137
- this.terminal.write(`${this.#paintBeginSequence}${full ? "\x1b[H" : ""}${rowsBuffer}${this.#paintEndSequence}`);
4138
- this.#debugPaint = { lines: prepared.lines, windowTop: 0, altScreen: true };
4139
- if (notifyPaint) {
4552
+ const target = this.#targetHardwareCursorState(cursorPosition, height);
4553
+ const previousCursor = this.#hardwareCursorState;
4554
+ const cursorChanged =
4555
+ target === null
4556
+ ? previousCursor?.visible === true
4557
+ : previousCursor === null ||
4558
+ previousCursor.row !== target.row ||
4559
+ previousCursor.col !== target.col ||
4560
+ previousCursor.visible !== target.visible;
4561
+ const placeCursor = target !== null && (rowsBuffer !== "" || cursorChanged);
4562
+ const hideCursor = target === null && previousCursor?.visible === true;
4563
+ if (rowsBuffer === "" && !placeCursor && !hideCursor) {
4564
+ if (prelude !== "") this.terminal.write(prelude);
4565
+ return;
4566
+ }
4567
+
4568
+ let cursorBuffer = "";
4569
+ if (placeCursor && target !== null) {
4570
+ cursorBuffer = `\x1b[${target.row + 1};${target.col + 1}H${target.visible ? "\x1b[?25h" : "\x1b[?25l"}`;
4571
+ } else if (hideCursor) {
4572
+ cursorBuffer = "\x1b[?25l";
4573
+ }
4574
+ this.terminal.write(
4575
+ `${this.#paintBeginSequence}${prelude}${full ? "\x1b[H" : ""}${rowsBuffer}${cursorBuffer}${this.#paintEndSequence}`,
4576
+ );
4577
+ this.#debugPaint = {
4578
+ lines: prepared.lines,
4579
+ windowTop: 0,
4580
+ altScreen: true,
4581
+ ...(target === null ? {} : { cursor: { x: target.col, y: target.row, visible: target.visible } }),
4582
+ };
4583
+ if (notifyPaint && rowsBuffer !== "") {
4140
4584
  this.#notifyPaint({
4141
4585
  history: [],
4142
4586
  viewport: prepared.lines,
@@ -4146,6 +4590,11 @@ export class TUI extends Container {
4146
4590
  rows: height,
4147
4591
  });
4148
4592
  }
4149
- if (full) this.#fullRedrawCount += 1;
4593
+ if (full && rowsBuffer !== "") this.#fullRedrawCount += 1;
4594
+ // DEC 1049 restores the normal-buffer cursor on exit. Track this buffer's
4595
+ // visibility and marker position without replacing that saved normal row,
4596
+ // which stop() and a late native-surface handshake use after the restore.
4597
+ if (target) this.#hardwareCursorState = target;
4598
+ else this.#recordHardwareCursorHidden();
4150
4599
  }
4151
4600
  }