@linxiraos/pi-tui 1.1.27 → 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 (373) hide show
  1. package/CHANGELOG.md +47 -463
  2. package/README.md +7 -5
  3. package/dist/types/app-keybindings.d.ts +1 -1
  4. package/dist/types/apps/cleanse-board.d.ts +1 -1
  5. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  6. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  7. package/dist/types/apps/git/git-tui.d.ts +2 -2
  8. package/dist/types/apps/git/help.d.ts +3 -1
  9. package/dist/types/apps/if-bench-board.d.ts +2 -2
  10. package/dist/types/apps/setup-model-picker.d.ts +1 -1
  11. package/dist/types/autocomplete.d.ts +5 -2
  12. package/dist/types/charts/chart-plan.d.ts +674 -0
  13. package/dist/types/charts/chart-svg.d.ts +59 -0
  14. package/dist/types/charts/table-data.d.ts +175 -0
  15. package/dist/types/chat/assistant-message.d.ts +5 -0
  16. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  17. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  18. package/dist/types/chat/execution-shared.d.ts +6 -5
  19. package/dist/types/chat/fence-figure.d.ts +42 -0
  20. package/dist/types/chat/figure-markdown.d.ts +42 -0
  21. package/dist/types/chat/messages.d.ts +1 -1
  22. package/dist/types/chat/svg-figure.d.ts +33 -0
  23. package/dist/types/chat/svg-source.d.ts +49 -0
  24. package/dist/types/chat/table-chart.d.ts +89 -0
  25. package/dist/types/chat/tool-execution.d.ts +2 -0
  26. package/dist/types/chat/user-message.d.ts +15 -2
  27. package/dist/types/chrome/shared.d.ts +9 -0
  28. package/dist/types/chrome/transcript-container.d.ts +6 -0
  29. package/dist/types/components/box.d.ts +1 -0
  30. package/dist/types/components/disclosure.d.ts +1 -0
  31. package/dist/types/components/editor.d.ts +15 -4
  32. package/dist/types/components/form.d.ts +1 -0
  33. package/dist/types/components/image.d.ts +12 -44
  34. package/dist/types/components/key-value-list.d.ts +1 -0
  35. package/dist/types/components/layout/row.d.ts +1 -0
  36. package/dist/types/components/layout/stack.d.ts +1 -0
  37. package/dist/types/components/markdown.d.ts +36 -3
  38. package/dist/types/components/scroll-view.d.ts +1 -0
  39. package/dist/types/components/section.d.ts +1 -0
  40. package/dist/types/components/table.d.ts +1 -0
  41. package/dist/types/components/text.d.ts +1 -0
  42. package/dist/types/components/tree-view.d.ts +7 -0
  43. package/dist/types/components/truncated-text.d.ts +1 -0
  44. package/dist/types/fuzzy.d.ts +29 -1
  45. package/dist/types/key-hint-format.d.ts +1 -1
  46. package/dist/types/keybinding-matchers.d.ts +3 -0
  47. package/dist/types/keybindings.d.ts +1 -1
  48. package/dist/types/kitty-graphics.d.ts +2 -2
  49. package/dist/types/loop-watchdog.d.ts +8 -7
  50. package/dist/types/multiplexers/cmux.d.ts +16 -0
  51. package/dist/types/multiplexers/herdr.d.ts +26 -0
  52. package/dist/types/multiplexers/notify.d.ts +13 -0
  53. package/dist/types/multiplexers/orca.d.ts +12 -0
  54. package/dist/types/multiplexers/screen.d.ts +9 -0
  55. package/dist/types/multiplexers/tmux.d.ts +29 -0
  56. package/dist/types/multiplexers/types.d.ts +100 -0
  57. package/dist/types/multiplexers/wmux.d.ts +12 -0
  58. package/dist/types/multiplexers/zellij.d.ts +12 -0
  59. package/dist/types/native/backend.d.ts +3 -2
  60. package/dist/types/native/blobs.d.ts +7 -3
  61. package/dist/types/native/encode.d.ts +3 -4
  62. package/dist/types/native/overlay.d.ts +5 -0
  63. package/dist/types/overlays/agent-hub-projection.d.ts +6 -5
  64. package/dist/types/overlays/agent-hub-types.d.ts +9 -0
  65. package/dist/types/overlays/agent-hub.d.ts +2 -0
  66. package/dist/types/overlays/agent-transcript-viewer.d.ts +11 -2
  67. package/dist/types/overlays/agents-hub.d.ts +9 -0
  68. package/dist/types/overlays/annotation-overlay.d.ts +19 -2
  69. package/dist/types/overlays/annotation-types.d.ts +2 -0
  70. package/dist/types/overlays/ask-dialog.d.ts +5 -2
  71. package/dist/types/overlays/history-search.d.ts +10 -0
  72. package/dist/types/overlays/hook-selector.d.ts +9 -4
  73. package/dist/types/overlays/jobs-panel.d.ts +1 -7
  74. package/dist/types/overlays/model-browser.d.ts +54 -7
  75. package/dist/types/overlays/model-hub.d.ts +23 -0
  76. package/dist/types/overlays/oauth-selector.d.ts +0 -1
  77. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  78. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  79. package/dist/types/overlays/session-selector.d.ts +6 -2
  80. package/dist/types/overlays/settings-defs.d.ts +1 -0
  81. package/dist/types/overlays/usage-dashboard.d.ts +8 -4
  82. package/dist/types/overlays/usage-display.d.ts +3 -1
  83. package/dist/types/prompt/composer.d.ts +1 -1
  84. package/dist/types/prompt/custom-editor.d.ts +11 -8
  85. package/dist/types/prompt/image-source.d.ts +2 -1
  86. package/dist/types/prompt/interactive-context-helpers.d.ts +4 -1
  87. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  88. package/dist/types/prompt/welcome.d.ts +6 -1
  89. package/dist/types/render/composite.d.ts +2 -0
  90. package/dist/types/render/output-pane.d.ts +1 -0
  91. package/dist/types/render/render-utils.d.ts +1 -0
  92. package/dist/types/render/tool-card.d.ts +6 -0
  93. package/dist/types/render/width-aware-text.d.ts +2 -0
  94. package/dist/types/setup/scenes/types.d.ts +1 -0
  95. package/dist/types/status-line/component.d.ts +3 -0
  96. package/dist/types/status-line/host.d.ts +4 -1
  97. package/dist/types/status-line/segments.d.ts +2 -0
  98. package/dist/types/status-line/types.d.ts +14 -3
  99. package/dist/types/terminal-capabilities.d.ts +27 -35
  100. package/dist/types/terminal-multiplexer.d.ts +127 -14
  101. package/dist/types/terminal.d.ts +22 -6
  102. package/dist/types/theme/active-symbols.d.ts +1 -1
  103. package/dist/types/theme/schema.d.ts +11 -2
  104. package/dist/types/theme/symbols.d.ts +1 -1
  105. package/dist/types/theme/theme-class.d.ts +7 -0
  106. package/dist/types/theme/theme.d.ts +1 -1
  107. package/dist/types/theme/tui-adapters.d.ts +3 -0
  108. package/dist/types/tmux.d.ts +11 -4
  109. package/dist/types/tools/daemon.d.ts +1 -1
  110. package/dist/types/tools/eval.d.ts +22 -3
  111. package/dist/types/tools/glob.d.ts +4 -0
  112. package/dist/types/tools/native-view.d.ts +47 -0
  113. package/dist/types/tools/output-meta.d.ts +11 -1
  114. package/dist/types/tools/renderer.d.ts +37 -2
  115. package/dist/types/tools/streaming-output.d.ts +20 -1
  116. package/dist/types/tools/task.d.ts +0 -4
  117. package/dist/types/tools/write.d.ts +9 -2
  118. package/dist/types/tools/yield.d.ts +22 -0
  119. package/dist/types/tui.d.ts +31 -0
  120. package/dist/types/utils.d.ts +6 -0
  121. package/dist/types/vim.d.ts +19 -5
  122. package/package.json +13 -9
  123. package/src/app-keybindings.d.ts +1 -1
  124. package/src/app-keybindings.ts +1 -1
  125. package/src/apps/autoresearch-dashboard.ts +7 -3
  126. package/src/apps/cleanse-board.ts +2 -2
  127. package/src/apps/cleanse-picker.ts +1 -1
  128. package/src/apps/debug/log-viewer.ts +188 -72
  129. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  130. package/src/apps/debug/raw-sse.ts +39 -5
  131. package/src/apps/git/diff-pane.ts +26 -5
  132. package/src/apps/git/git-tui.ts +2 -2
  133. package/src/apps/git/help.ts +29 -20
  134. package/src/apps/if-bench-board.ts +2 -2
  135. package/src/apps/ps-top.ts +2 -2
  136. package/src/apps/setup-model-picker.ts +1 -1
  137. package/src/autocomplete.d.ts +5 -2
  138. package/src/autocomplete.ts +67 -43
  139. package/src/charts/chart-plan.d.ts +674 -0
  140. package/src/charts/chart-plan.ts +5081 -0
  141. package/src/charts/chart-svg.d.ts +59 -0
  142. package/src/charts/chart-svg.ts +4657 -0
  143. package/src/charts/table-data.d.ts +175 -0
  144. package/src/charts/table-data.ts +1478 -0
  145. package/src/chat/advisor-message.ts +11 -0
  146. package/src/chat/assistant-message.d.ts +5 -0
  147. package/src/chat/assistant-message.ts +219 -50
  148. package/src/chat/chat-transcript-builder.d.ts +2 -0
  149. package/src/chat/chat-transcript-builder.ts +5 -3
  150. package/src/chat/compaction-summary-message.d.ts +1 -0
  151. package/src/chat/compaction-summary-message.ts +8 -0
  152. package/src/chat/execution-shared.d.ts +6 -5
  153. package/src/chat/execution-shared.ts +42 -25
  154. package/src/chat/fence-figure.d.ts +42 -0
  155. package/src/chat/fence-figure.ts +80 -0
  156. package/src/chat/figure-markdown.d.ts +42 -0
  157. package/src/chat/figure-markdown.ts +174 -0
  158. package/src/chat/messages.d.ts +1 -1
  159. package/src/chat/messages.ts +1 -1
  160. package/src/chat/read-tool-group.ts +6 -4
  161. package/src/chat/skill-message.ts +6 -0
  162. package/src/chat/svg-figure.d.ts +33 -0
  163. package/src/chat/svg-figure.ts +399 -0
  164. package/src/chat/svg-source.d.ts +49 -0
  165. package/src/chat/svg-source.ts +188 -0
  166. package/src/chat/table-chart.d.ts +89 -0
  167. package/src/chat/table-chart.ts +294 -0
  168. package/src/chat/tool-execution.d.ts +2 -0
  169. package/src/chat/tool-execution.ts +80 -6
  170. package/src/chat/user-message.d.ts +15 -2
  171. package/src/chat/user-message.ts +55 -3
  172. package/src/chrome/shared.ts +53 -0
  173. package/src/chrome/transcript-container.d.ts +6 -0
  174. package/src/chrome/transcript-container.ts +93 -13
  175. package/src/components/box.d.ts +1 -0
  176. package/src/components/box.ts +5 -0
  177. package/src/components/disclosure.d.ts +1 -0
  178. package/src/components/disclosure.ts +8 -0
  179. package/src/components/editor.d.ts +15 -4
  180. package/src/components/editor.ts +294 -46
  181. package/src/components/form.d.ts +1 -0
  182. package/src/components/form.ts +8 -0
  183. package/src/components/image.d.ts +12 -44
  184. package/src/components/image.ts +47 -151
  185. package/src/components/key-value-list.d.ts +1 -0
  186. package/src/components/key-value-list.ts +4 -0
  187. package/src/components/layout/row.d.ts +1 -0
  188. package/src/components/layout/row.ts +7 -0
  189. package/src/components/layout/stack.d.ts +1 -0
  190. package/src/components/layout/stack.ts +7 -0
  191. package/src/components/markdown.d.ts +36 -3
  192. package/src/components/markdown.ts +433 -318
  193. package/src/components/scroll-view.d.ts +1 -0
  194. package/src/components/scroll-view.ts +5 -0
  195. package/src/components/section.d.ts +1 -0
  196. package/src/components/section.ts +5 -0
  197. package/src/components/settings-list.ts +2 -0
  198. package/src/components/table.d.ts +1 -0
  199. package/src/components/table.ts +4 -0
  200. package/src/components/text.d.ts +1 -0
  201. package/src/components/text.ts +7 -0
  202. package/src/components/tree-view.d.ts +7 -0
  203. package/src/components/tree-view.ts +38 -8
  204. package/src/components/truncated-text.d.ts +1 -0
  205. package/src/components/truncated-text.ts +5 -0
  206. package/src/desktop-notify.ts +1 -1
  207. package/src/fuzzy.d.ts +29 -1
  208. package/src/fuzzy.ts +115 -30
  209. package/src/key-hint-format.d.ts +1 -1
  210. package/src/key-hint-format.ts +1 -1
  211. package/src/keybinding-matchers.d.ts +3 -0
  212. package/src/keybinding-matchers.ts +6 -2
  213. package/src/keybindings.d.ts +1 -1
  214. package/src/keybindings.ts +1 -1
  215. package/src/kitty-graphics.d.ts +2 -2
  216. package/src/kitty-graphics.ts +4 -4
  217. package/src/loop-watchdog.d.ts +8 -7
  218. package/src/loop-watchdog.ts +18 -13
  219. package/src/multiplexers/cmux.d.ts +16 -0
  220. package/src/multiplexers/cmux.ts +29 -0
  221. package/src/multiplexers/herdr.d.ts +26 -0
  222. package/src/multiplexers/herdr.ts +64 -0
  223. package/src/multiplexers/notify.d.ts +13 -0
  224. package/src/multiplexers/notify.ts +24 -0
  225. package/src/multiplexers/orca.d.ts +12 -0
  226. package/src/multiplexers/orca.ts +16 -0
  227. package/src/multiplexers/screen.d.ts +9 -0
  228. package/src/multiplexers/screen.ts +15 -0
  229. package/src/multiplexers/tmux.d.ts +29 -0
  230. package/src/multiplexers/tmux.ts +40 -0
  231. package/src/multiplexers/types.d.ts +100 -0
  232. package/src/multiplexers/types.ts +110 -0
  233. package/src/multiplexers/wmux.d.ts +12 -0
  234. package/src/multiplexers/wmux.ts +16 -0
  235. package/src/multiplexers/zellij.d.ts +12 -0
  236. package/src/multiplexers/zellij.ts +24 -0
  237. package/src/native/backend.d.ts +3 -2
  238. package/src/native/backend.ts +174 -18
  239. package/src/native/blobs.d.ts +7 -3
  240. package/src/native/blobs.ts +47 -12
  241. package/src/native/encode.d.ts +3 -4
  242. package/src/native/encode.ts +58 -31
  243. package/src/native/overlay.d.ts +5 -0
  244. package/src/native/overlay.ts +13 -0
  245. package/src/native/reconcile.ts +4 -0
  246. package/src/overlays/agent-hub-projection.d.ts +6 -5
  247. package/src/overlays/agent-hub-projection.ts +124 -13
  248. package/src/overlays/agent-hub-types.d.ts +9 -0
  249. package/src/overlays/agent-hub-types.ts +6 -0
  250. package/src/overlays/agent-hub.ts +44 -29
  251. package/src/overlays/agent-transcript-viewer.ts +91 -23
  252. package/src/overlays/agents-hub.ts +28 -8
  253. package/src/overlays/annotation-overlay.ts +293 -90
  254. package/src/overlays/annotation-types.ts +2 -0
  255. package/src/overlays/ask-dialog.ts +7 -10
  256. package/src/overlays/btw-history-panel.ts +12 -1
  257. package/src/overlays/copy-selector.ts +30 -8
  258. package/src/overlays/extensions/state-manager.ts +52 -12
  259. package/src/overlays/history-search.ts +100 -7
  260. package/src/overlays/hook-selector.ts +12 -5
  261. package/src/overlays/jobs-panel.ts +28 -46
  262. package/src/overlays/model-browser.ts +341 -110
  263. package/src/overlays/model-hub.ts +300 -49
  264. package/src/overlays/move-overlay.ts +28 -14
  265. package/src/overlays/oauth-selector.ts +54 -70
  266. package/src/overlays/plan-review-overlay.ts +194 -70
  267. package/src/overlays/plugin-settings.ts +3 -3
  268. package/src/overlays/report-panel.ts +2 -2
  269. package/src/overlays/rewind-selector.ts +86 -17
  270. package/src/overlays/session-observer-registry.d.ts +2 -0
  271. package/src/overlays/session-observer-registry.ts +10 -2
  272. package/src/overlays/session-selector.ts +64 -26
  273. package/src/overlays/settings-defs.ts +4 -3
  274. package/src/overlays/settings-selector.ts +33 -8
  275. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  276. package/src/overlays/stats-notice.ts +1 -1
  277. package/src/overlays/tree-selector.ts +41 -20
  278. package/src/overlays/usage-dashboard.ts +100 -50
  279. package/src/overlays/usage-display.ts +10 -4
  280. package/src/prompt/attachment-chips.ts +15 -11
  281. package/src/prompt/composer-attachments.ts +6 -4
  282. package/src/prompt/composer.ts +4 -15
  283. package/src/prompt/custom-editor.ts +54 -26
  284. package/src/prompt/image-source.ts +2 -1
  285. package/src/prompt/interactive-context-helpers.ts +30 -18
  286. package/src/prompt/macos-spelling.ts +7 -0
  287. package/src/prompt/magic-keywords.ts +11 -2
  288. package/src/prompt/model-mention-autocomplete.ts +14 -14
  289. package/src/prompt/tips.txt +6 -5
  290. package/src/prompt/welcome.ts +55 -12
  291. package/src/prompt/word-completion.ts +38 -5
  292. package/src/render/composite.d.ts +2 -0
  293. package/src/render/composite.ts +64 -0
  294. package/src/render/output-pane.d.ts +1 -0
  295. package/src/render/output-pane.ts +6 -0
  296. package/src/render/render-utils.d.ts +1 -0
  297. package/src/render/render-utils.ts +11 -0
  298. package/src/render/tool-card.d.ts +6 -0
  299. package/src/render/tool-card.ts +15 -0
  300. package/src/render/width-aware-text.d.ts +2 -0
  301. package/src/render/width-aware-text.ts +5 -0
  302. package/src/setup/scenes/model.ts +2 -1
  303. package/src/setup/scenes/types.ts +1 -0
  304. package/src/status-line/component.ts +292 -78
  305. package/src/status-line/host.ts +4 -1
  306. package/src/status-line/segments.ts +40 -22
  307. package/src/status-line/types.ts +15 -3
  308. package/src/terminal-capabilities.d.ts +27 -35
  309. package/src/terminal-capabilities.ts +75 -189
  310. package/src/terminal-multiplexer.d.ts +127 -14
  311. package/src/terminal-multiplexer.ts +112 -33
  312. package/src/terminal.d.ts +22 -6
  313. package/src/terminal.ts +139 -37
  314. package/src/theme/active-symbols.d.ts +1 -1
  315. package/src/theme/active-symbols.ts +1 -1
  316. package/src/theme/schema-validation.ts +1 -0
  317. package/src/theme/schema.d.ts +11 -2
  318. package/src/theme/schema.ts +14 -1
  319. package/src/theme/session-color.ts +1 -7
  320. package/src/theme/symbols.d.ts +1 -1
  321. package/src/theme/symbols.ts +6 -1
  322. package/src/theme/theme-class.d.ts +7 -0
  323. package/src/theme/theme-class.ts +26 -1
  324. package/src/theme/theme-schema.json +5 -1
  325. package/src/theme/theme.d.ts +1 -1
  326. package/src/theme/theme.ts +8 -4
  327. package/src/theme/tui-adapters.d.ts +3 -0
  328. package/src/theme/tui-adapters.ts +68 -0
  329. package/src/tmux.d.ts +11 -4
  330. package/src/tmux.ts +47 -11
  331. package/src/tools/ast-edit.ts +59 -35
  332. package/src/tools/ast-grep.ts +35 -16
  333. package/src/tools/bash.ts +92 -42
  334. package/src/tools/daemon.d.ts +1 -1
  335. package/src/tools/daemon.ts +1 -1
  336. package/src/tools/default-renderer.ts +35 -19
  337. package/src/tools/edit.ts +142 -120
  338. package/src/tools/eval-format/index.ts +28 -2
  339. package/src/tools/eval-format/javascript.ts +7 -5
  340. package/src/tools/eval.d.ts +22 -3
  341. package/src/tools/eval.ts +364 -208
  342. package/src/tools/fetch.ts +5 -6
  343. package/src/tools/glob.d.ts +4 -0
  344. package/src/tools/glob.ts +33 -65
  345. package/src/tools/grep.ts +81 -48
  346. package/src/tools/index.ts +2 -0
  347. package/src/tools/lsp.ts +81 -59
  348. package/src/tools/mcp.ts +111 -68
  349. package/src/tools/native-view.d.ts +47 -0
  350. package/src/tools/native-view.ts +102 -1
  351. package/src/tools/output-meta.d.ts +11 -1
  352. package/src/tools/output-meta.ts +23 -3
  353. package/src/tools/read.ts +7 -5
  354. package/src/tools/renderer.d.ts +37 -2
  355. package/src/tools/renderer.ts +42 -2
  356. package/src/tools/streaming-output.d.ts +20 -1
  357. package/src/tools/streaming-output.ts +149 -50
  358. package/src/tools/task.d.ts +0 -4
  359. package/src/tools/task.ts +112 -59
  360. package/src/tools/todo.ts +119 -30
  361. package/src/tools/wait.ts +34 -24
  362. package/src/tools/web-search.ts +1 -1
  363. package/src/tools/write.d.ts +9 -2
  364. package/src/tools/write.ts +147 -95
  365. package/src/tools/yield.d.ts +22 -0
  366. package/src/tools/yield.ts +225 -0
  367. package/src/tui.d.ts +31 -0
  368. package/src/tui.ts +309 -160
  369. package/src/utils.d.ts +6 -0
  370. package/src/utils.ts +19 -0
  371. package/src/vim.d.ts +19 -5
  372. package/src/vim.ts +333 -18
  373. package/src/windows-input-mode.ts +8 -0
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,16 +45,14 @@ import {
44
45
  synchronizedOutputUserOverride,
45
46
  TERMINAL,
46
47
  } from "./terminal-capabilities";
47
- import { classifyTerminalMultiplexer } from "./terminal-multiplexer";
48
+ import { compositeLineAt } from "./render/composite";
48
49
  import {
49
50
  Ellipsis,
50
- extractSegments,
51
51
  getWidthConfigEpoch,
52
52
  isOsc66Line,
53
53
  normalizeTerminalOutput,
54
54
  osc66MaxScale,
55
55
  sliceByColumn,
56
- sliceWithWidth,
57
56
  truncateToWidth,
58
57
  visibleWidth,
59
58
  } from "./utils";
@@ -200,6 +199,10 @@ export interface TUIStartOptions {
200
199
  * Paint without owning stdin: the terminal stays in cooked mode (kernel
201
200
  * echo + line editing at the hardware cursor) until {@link TUI.enableInput}
202
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.
203
206
  */
204
207
  deferInput?: boolean;
205
208
  }
@@ -318,6 +321,16 @@ export interface Component {
318
321
  * Called when theme changes or when component needs to re-render from scratch.
319
322
  */
320
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;
321
334
  /**
322
335
  * Optional hook to set whether this component ignores tight layout mode.
323
336
  */
@@ -548,6 +561,7 @@ export class Container implements Component {
548
561
  clear(): void {
549
562
  this.children = [];
550
563
  this.#memoLines = undefined;
564
+ this.#memoChildLines = [];
551
565
  }
552
566
 
553
567
  /** Dispose every child, then detach it from this container. */
@@ -557,12 +571,23 @@ export class Container implements Component {
557
571
  }
558
572
 
559
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.
560
576
  this.#memoLines = undefined;
577
+ this.#memoChildLines = [];
561
578
  for (const child of this.children) {
562
579
  child.invalidate?.();
563
580
  }
564
581
  }
565
582
 
583
+ releaseRenderCaches(): void {
584
+ this.#memoLines = undefined;
585
+ this.#memoChildLines = [];
586
+ for (const child of this.children) {
587
+ child.releaseRenderCaches?.();
588
+ }
589
+ }
590
+
566
591
  /**
567
592
  * Propagate teardown to children. Call when the container's children are
568
593
  * being permanently discarded (not when they are detached for reuse — use
@@ -621,6 +646,13 @@ interface HardwareCursorState {
621
646
  visible: boolean;
622
647
  }
623
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
+
624
656
  interface PreparedLine {
625
657
  raw: string;
626
658
  width: number;
@@ -960,8 +992,8 @@ export class TUI extends Container {
960
992
  // settle window, leaving only Unicode placeholder cells. Hold the first image
961
993
  // paint until that window has passed; later images render normally.
962
994
  static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
963
- #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
964
- #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.
965
997
  #sixelProbePendingGraphics = false;
966
998
  #sixelProbeBuffer = "";
967
999
  #sixelProbeTimeout?: NodeJS.Timeout;
@@ -975,6 +1007,9 @@ export class TUI extends Container {
975
1007
  // Caps how many inline images render as live graphics; older ones fall back
976
1008
  // to text via a purge + full redraw. Cap is configured by the host app.
977
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;
978
1013
  #ghosttyInitialImageDelayDone = false;
979
1014
  #ghosttyInitialImageDelayTimer: RenderTimer | undefined;
980
1015
  #ghosttyImageReadyAtMs = 0;
@@ -988,6 +1023,17 @@ export class TUI extends Container {
988
1023
  #cancelPostmortemRestore?: () => void;
989
1024
  /** True between a `deferInput` start() and enableInput(). */
990
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;
991
1037
  // Always-on event-loop lag probe. The high default threshold keeps it quiet;
992
1038
  // it only logs `ui.loop-blocked` (with the current loop phase) when a frame
993
1039
  // budget is genuinely starved. Armed in start(), disarmed in stop().
@@ -1023,6 +1069,11 @@ export class TUI extends Container {
1023
1069
  #resizeInPlaceActive = false;
1024
1070
  #resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode();
1025
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 = "";
1026
1077
  // Holds an alternate-screen exit until its replacement full paint can emit it
1027
1078
  // atomically. It must survive a deferred Ghostty image frame.
1028
1079
  #pendingAltExit = "";
@@ -1218,6 +1269,8 @@ export class TUI extends Container {
1218
1269
 
1219
1270
  #purgeInlineImages(): void {
1220
1271
  const transmittedIds = this.#imageBudget.takeAllTransmittedIds();
1272
+ this.#screenImagePlacements.clear();
1273
+ this.#altImagePlacements.clear();
1221
1274
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
1222
1275
  for (const id of transmittedIds) {
1223
1276
  this.terminal.write(encodeKittyDeleteImage(id));
@@ -1505,6 +1558,12 @@ export class TUI extends Container {
1505
1558
  // `hello` query must go out now to confirm the surface.
1506
1559
  const nativeExpected = this.terminal.tspExpected === true;
1507
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
+ }
1508
1567
  this.#watchdog.start();
1509
1568
  this.#ghosttyInitialImageDelayDone = false;
1510
1569
  this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
@@ -1518,12 +1577,19 @@ export class TUI extends Container {
1518
1577
  this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true, status) => {
1519
1578
  if (mode !== 2026 || !confirmed) return;
1520
1579
  if (synchronizedOutputUserOverride() !== null) return;
1521
- // Herdr's Ghostty VTE honors DEC 2026 even when DECRQM is unanswered or
1522
- // reports unrecognized (status 0). Other confirmed unsupported reports
1523
- // still disable: status 4 is permanently reset, and a three-argument
1524
- // callback (`status` omitted) is a definitive unsupported from a
1525
- // custom Terminal that does not distinguish DECRPM codes.
1526
- 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
+ }
1527
1593
  this.#setSynchronizedOutput(supported);
1528
1594
  });
1529
1595
  // Icons painted before the Glyph Protocol registration landed may sit in
@@ -1533,6 +1599,13 @@ export class TUI extends Container {
1533
1599
  this.invalidate();
1534
1600
  this.requestRender(true);
1535
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
+ });
1536
1609
  this.terminal.onTspHello?.(hello => this.#onTspHello(hello));
1537
1610
  this.terminal.start(
1538
1611
  data => this.#handleInput(data),
@@ -1746,7 +1819,7 @@ export class TUI extends Container {
1746
1819
  this.#resizeSettleTimer?.cancel();
1747
1820
  this.#resizeSettleTimer = undefined;
1748
1821
  if (this.#altActive || this.#resizeAltActive) {
1749
- this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1822
+ this.terminal.write(`${this.#takePendingAltEnter()}${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1750
1823
  setAltScreenActive(false);
1751
1824
  this.#altActive = false;
1752
1825
  this.#resizeAltActive = false;
@@ -1835,7 +1908,7 @@ export class TUI extends Container {
1835
1908
  resizeInPlaceOverride() !== false &&
1836
1909
  this.#resizeScrollbackMode === "rebuild" &&
1837
1910
  this.#synchronizedOutputEnabled &&
1838
- classifyTerminalMultiplexer() === "tmux"
1911
+ classifyTerminalMultiplexerModule()?.altRestoreEndsSynchronizedOutput === true
1839
1912
  );
1840
1913
  }
1841
1914
 
@@ -1900,7 +1973,9 @@ export class TUI extends Container {
1900
1973
  this.#resizeSettleTimer?.cancel();
1901
1974
  this.#forgetHardwareCursorState();
1902
1975
  this.#recordHardwareCursorHidden();
1903
- if (this.#eraseLiveViewportForResize()) {
1976
+ const erase = this.#liveViewportResizeErase();
1977
+ if (erase !== "") {
1978
+ this.terminal.write(erase);
1904
1979
  // The erase parked the hardware cursor on the viewport's top row;
1905
1980
  // snapshot the parked offset so the settled probe anchors there.
1906
1981
  this.#parkedViewportOffset = 0;
@@ -1935,13 +2010,14 @@ export class TUI extends Container {
1935
2010
  * cursor-relative addressing would start rows late; fall back to the same
1936
2011
  * bottom-preserving bound as resize-anchor recovery.
1937
2012
  *
1938
- * Both erase paths leave the cursor on the viewport's top row. Returns true
1939
- * when it erased (multiplexers skip it: an immediate erase races the pane
1940
- * 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).
1941
2017
  */
1942
- #eraseLiveViewportForResize(): boolean {
2018
+ #liveViewportResizeErase(): string {
1943
2019
  if (!this.#hasEverRendered || this.#providerWindow.length === 0 || isInsideTerminalMultiplexer()) {
1944
- return false;
2020
+ return "";
1945
2021
  }
1946
2022
  if (this.terminal.rows < this.#previousHeight) {
1947
2023
  const staleRows = this.#reflowedRowCount(
@@ -1951,13 +2027,11 @@ export class TUI extends Container {
1951
2027
  this.terminal.columns,
1952
2028
  );
1953
2029
  const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1954
- this.terminal.write(`\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`);
1955
- } else {
1956
- const up = this.#reflowedRowCount(this.#providerWindow, 0, this.#parkedViewportOffset, this.terminal.columns);
1957
- const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
1958
- this.terminal.write(`\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`);
2030
+ return `\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`;
1959
2031
  }
1960
- 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}`;
1961
2035
  }
1962
2036
 
1963
2037
  /**
@@ -1988,8 +2062,12 @@ export class TUI extends Container {
1988
2062
  this.#altPreparedRows = [];
1989
2063
  this.#forgetHardwareCursorState();
1990
2064
  this.#recordHardwareCursorHidden();
1991
- // Blank the live region up front so a reflow-driven scroll can only push
1992
- // 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
1993
2071
  // the settled CPR probe: its reflowed row count bounds the anchor to
1994
2072
  // `height - staleRows`, so a mis-parked cursor (a single-step tmux zoom
1995
2073
  // re-lays the pane before SIGWINCH delivery, moving the park target
@@ -1999,8 +2077,9 @@ export class TUI extends Container {
1999
2077
  this.#resizeProbeWindow = this.#providerWindow;
2000
2078
  this.#resizeProbeOffset = this.#parkedViewportOffset;
2001
2079
  }
2002
- if (this.#eraseLiveViewportForResize()) {
2003
- // 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
2004
2083
  // parked offset no longer applies; carrying a stale nonzero offset
2005
2084
  // into the probe would anchor the settled repaint above the real
2006
2085
  // viewport top and overwrite visible committed rows.
@@ -2023,7 +2102,7 @@ export class TUI extends Container {
2023
2102
  }
2024
2103
  this.#noteAltBufferToggle();
2025
2104
  this.#imageBudget.beginAltScreenLifecycle();
2026
- this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
2105
+ this.#pendingAltEnter = `${erase}\x1b[?1049h${this.#keyboardEnhancementEnter()}`;
2027
2106
  }
2028
2107
  this.#resizeSettleTimer?.cancel();
2029
2108
  this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
@@ -2062,6 +2141,8 @@ export class TUI extends Container {
2062
2141
  this.#prepareResizeReplay(this.terminal.columns, this.terminal.rows);
2063
2142
  }
2064
2143
  if (this.#clearScrollbackOnNextRender) {
2144
+ // An entry no resize frame carried yet rides ahead of this exit in
2145
+ // the rebuild frame (see #renderProviderFrame).
2065
2146
  this.#pendingAltExit = exitSequence;
2066
2147
  this.#resizeAltExitFused = true;
2067
2148
  this.requestRender(true);
@@ -2069,10 +2150,22 @@ export class TUI extends Container {
2069
2150
  }
2070
2151
  }
2071
2152
  this.#noteAltBufferToggle();
2072
- this.terminal.write(exitSequence);
2153
+ this.terminal.write(this.#takePendingAltEnter() + exitSequence);
2073
2154
  setAltScreenActive(false);
2074
2155
  this.#beginResizeAnchorProbe();
2075
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
+
2076
2169
  /**
2077
2170
  * Recover the reflowed viewport anchor after the resize settle window ends.
2078
2171
  * The terminal reflowed the restored normal buffer during the drag, so
@@ -2200,7 +2293,7 @@ export class TUI extends Container {
2200
2293
  // an intact bottom anchor still need no replay or additional wait.
2201
2294
  if (
2202
2295
  this.#resizeScrollbackMode === "rebuild" &&
2203
- classifyTerminalMultiplexer() === "tmux" &&
2296
+ classifyTerminalMultiplexerModule()?.growsBeforeSigwinch === true &&
2204
2297
  !this.#resizeRepaintsInPlace() &&
2205
2298
  this.#frameProvider?.beginHistoryReplay &&
2206
2299
  this.#resizeBurstGrew &&
@@ -2321,6 +2414,35 @@ export class TUI extends Container {
2321
2414
  this.terminal.enableInput?.();
2322
2415
  this.#querySixelSupport();
2323
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;
2324
2446
  }
2325
2447
 
2326
2448
  addStartListener(listener: StartListener): () => void {
@@ -2346,17 +2468,19 @@ export class TUI extends Container {
2346
2468
  // PI_FORCE_IMAGE_PROTOCOL choice — including its `off` kill switch — wins
2347
2469
  // over the probe.
2348
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;
2349
2475
  if (isImageProtocolForced()) return;
2350
2476
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
2351
2477
 
2352
2478
  this.#clearSixelProbeState();
2353
2479
  this.#sixelProbePendingGraphics = true;
2354
2480
  this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
2355
- // XTSMGRAPHICS item 2 reports the terminal's maximum SIXEL geometry. DA1
2356
- // attribute 4 advertises SIXEL as well, but ProcessTerminal swallows every
2357
- // `CSI ? … c` reply for the whole session so a late one cannot leak into the
2358
- // composer (#8542): those bytes never reach an input listener, so this probe
2359
- // 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.
2360
2484
  this.terminal.write("\x1b[?2;1;0S");
2361
2485
  this.#sixelProbeTimeout = setTimeout(() => {
2362
2486
  this.#finishSixelProbe(false);
@@ -2440,7 +2564,11 @@ export class TUI extends Container {
2440
2564
 
2441
2565
  #finishSixelProbe(supported: boolean): void {
2442
2566
  this.#clearSixelProbeState();
2443
- if (!supported || TERMINAL.imageProtocol) return;
2567
+ if (supported) this.#enableSixelProtocol();
2568
+ }
2569
+
2570
+ #enableSixelProtocol(): void {
2571
+ if (TERMINAL.imageProtocol) return;
2444
2572
 
2445
2573
  setTerminalImageProtocol(ImageProtocol.Sixel);
2446
2574
  this.#queryCellSize();
@@ -2534,7 +2662,7 @@ export class TUI extends Container {
2534
2662
  this.#cancelResizeProbe();
2535
2663
  if (this.#resizeAltActive) {
2536
2664
  this.#resizeAltActive = false;
2537
- this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
2665
+ this.terminal.write(`${this.#takePendingAltEnter()}${this.#keyboardEnhancementExit()}\x1b[?1049l`);
2538
2666
  setAltScreenActive(false);
2539
2667
  }
2540
2668
  if (this.#altActive || this.#pendingAltExit) {
@@ -2545,7 +2673,7 @@ export class TUI extends Container {
2545
2673
  // so the final OFF goes last or the shell keeps reporting.
2546
2674
  const mouseExit = this.#mouseTracking !== "off" ? MOUSE_TRACKING_OFF : "";
2547
2675
  const exitSequence = this.#pendingAltExit
2548
- ? `${this.#pendingAltExit}${mouseExit}`
2676
+ ? `${this.#takePendingAltEnter()}${this.#pendingAltExit}${mouseExit}`
2549
2677
  : `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
2550
2678
  this.terminal.write(exitSequence);
2551
2679
  setAltScreenActive(false);
@@ -2831,6 +2959,11 @@ export class TUI extends Container {
2831
2959
  }
2832
2960
  if (data.length === 0) return;
2833
2961
 
2962
+ if (this.#heldInput !== undefined && this.#focusedComponent === this.#heldFocus) {
2963
+ this.#holdInput(data);
2964
+ return;
2965
+ }
2966
+
2834
2967
  // If focused component is an overlay, verify it's still visible (visibility can change due to
2835
2968
  // terminal resize or visible() callback). Runs before the capture preflight below, which must
2836
2969
  // target the effective focus owner, not a hidden overlay.
@@ -2910,6 +3043,18 @@ export class TUI extends Container {
2910
3043
  }
2911
3044
  }
2912
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
+
2913
3058
  #consumeCellSizeResponse(data: string): boolean {
2914
3059
  // Response format: ESC [ 6 ; height ; width t
2915
3060
  const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
@@ -3108,99 +3253,56 @@ export class TUI extends Container {
3108
3253
  if (idx < 0 || idx >= result.length) continue;
3109
3254
  const truncatedOverlayLine =
3110
3255
  visibleWidth(overlayLines[i]) > width ? sliceByColumn(overlayLines[i], 0, width, true) : overlayLines[i];
3111
- result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
3256
+ result[idx] = compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
3112
3257
  }
3113
3258
  }
3114
3259
  return result;
3115
3260
  }
3116
3261
 
3117
- /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
3118
- #compositeLineAt(
3119
- baseLine: string,
3120
- overlayLine: string,
3121
- startCol: number,
3122
- overlayWidth: number,
3123
- 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,
3124
3271
  ): string {
3125
- if (TERMINAL.isImageLine(baseLine)) {
3126
- // Full-width overlays such as /switch are opaque: replace the
3127
- // Unicode placeholder cells so the image cannot cover the modal.
3128
- // Partial overlays cannot safely splice placement control sequences.
3129
- if (startCol !== 0 || overlayWidth < totalWidth) return baseLine;
3130
- const overlay = sliceWithWidth(overlayLine, 0, totalWidth, true);
3131
- return SEGMENT_RESET + overlay.text + " ".repeat(Math.max(0, totalWidth - overlay.width));
3132
- }
3133
-
3134
- // Single pass through baseLine extracts both before and after segments
3135
- const afterStart = startCol + overlayWidth;
3136
- const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
3137
-
3138
- // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
3139
- const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
3140
-
3141
- // Pad segments to target widths
3142
- const beforePad = Math.max(0, startCol - base.beforeWidth);
3143
- const overlayPad = Math.max(0, overlayWidth - overlay.width);
3144
- const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
3145
- const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
3146
- const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
3147
- const afterPad = Math.max(0, afterTarget - base.afterWidth);
3148
-
3149
- // Compose result
3150
- const r = SEGMENT_RESET;
3151
- const result =
3152
- base.before +
3153
- " ".repeat(beforePad) +
3154
- r +
3155
- overlay.text +
3156
- " ".repeat(overlayPad) +
3157
- r +
3158
- base.after +
3159
- " ".repeat(afterPad);
3160
-
3161
- // CRITICAL: Always verify and truncate to terminal width.
3162
- // This is the final safeguard against width overflow which would crash the TUI.
3163
- // Width tracking can drift from actual visible width due to:
3164
- // - Complex ANSI/OSC sequences (hyperlinks, colors)
3165
- // - Wide characters at segment boundaries
3166
- // - Edge cases in segment extraction
3167
- const resultWidth = visibleWidth(result);
3168
- if (resultWidth <= totalWidth) {
3169
- return result;
3170
- }
3171
- // Truncate with strict=true to ensure we don't exceed totalWidth
3172
- 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;
3173
3279
  }
3174
3280
 
3175
- /**
3176
- * Rewrite a Kitty direct-placement line for the viewport row it is written
3177
- * at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
3178
- * under the placement id resolved by the budget's epoch tracking (see
3179
- * {@link ImageBudget.resolvePlacementEmit}). `screenRow` -1 (write position
3180
- * unknown) and non-placement image lines (placeholder grids, sixel, iTerm2,
3181
- * tmux-wrapped) pass through verbatim.
3182
- */
3183
- #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 {
3184
3288
  if (screenRow < 0) return line;
3185
3289
  const parsed = parseKittyDirectPlacementLine(line);
3186
3290
  if (!parsed) return line;
3187
- // The emitted placement attaches from the block's first *visible* row
3188
- // (the clip drops the rows above the viewport), so epoch tracking keys
3189
- // on that row — not the block origin, which may be long committed.
3190
- const placement = this.#imageBudget.resolvePlacementEmit(
3191
- parsed.imageId,
3192
- frameRow >= 0 ? frameRow - Math.min(parsed.rows - 1, screenRow) : -1,
3193
- 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
+ })
3194
3305
  );
3195
- if (!placement) return line;
3196
- return encodeKittyPlacementLine({
3197
- imageId: parsed.imageId,
3198
- placementId: placement.placementId,
3199
- columns: parsed.columns,
3200
- rows: parsed.rows,
3201
- screenRow,
3202
- imageHeightPx: placement.heightPx,
3203
- });
3204
3306
  }
3205
3307
 
3206
3308
  #terminalLine(line: PreparedLine): string {
@@ -3462,7 +3564,7 @@ export class TUI extends Container {
3462
3564
  // resize in rebuild mode): erase native history and the viewport,
3463
3565
  // then repaint from row zero.
3464
3566
  const destructiveReset = this.#clearScrollbackOnNextRender;
3465
- const compactReplay = destructiveReset && classifyTerminalMultiplexer() === "tmux";
3567
+ const compactReplay = destructiveReset && classifyTerminalMultiplexerModule()?.expandsRepPadding === true;
3466
3568
  if (destructiveReset) {
3467
3569
  this.#providerViewportTop = 0;
3468
3570
  this.#providerWindow = [];
@@ -3490,12 +3592,13 @@ export class TUI extends Container {
3490
3592
  }
3491
3593
  const newTop = Math.max(0, Math.min(startTop + historyRows.length, height - rows)) + anchorPull;
3492
3594
  const pendingAltExit = this.#pendingAltExit;
3493
- 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;
3494
3597
  const renewSync =
3495
3598
  destructiveReset &&
3496
3599
  this.#resizeScrollbackMode === "rebuild" &&
3497
3600
  this.#synchronizedOutputEnabled &&
3498
- classifyTerminalMultiplexer() === "tmux";
3601
+ classifyTerminalMultiplexerModule()?.expiresSynchronizedOutput === true;
3499
3602
  let syncBytes = Buffer.byteLength(buffer);
3500
3603
  const append = (sequence: string): void => {
3501
3604
  buffer += sequence;
@@ -3516,7 +3619,7 @@ export class TUI extends Container {
3516
3619
  // reset forgot are named explicitly here — their tracking is gone, so
3517
3620
  // nothing downstream could find them again.
3518
3621
  for (const id of this.#imageBudget.takeResetPurgeIds()) buffer += encodeKittyDeleteImage(id);
3519
- this.#imageBudget.resetPlacementEpochs();
3622
+ this.#screenImagePlacements.clear();
3520
3623
  }
3521
3624
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
3522
3625
  for (const id of this.#imageBudget.takePurgeIds()) buffer += encodeKittyDeleteImage(id);
@@ -3539,7 +3642,6 @@ export class TUI extends Container {
3539
3642
  // image the terminal no longer had and every inline image vanished
3540
3643
  // after a settled width resize.
3541
3644
  if (destructiveReset) buffer += `${LINE_TERMINATOR}\x1b[H\x1b[2J\x1b[3J`;
3542
- for (const sequence of this.#imageBudget.takeTransmits()) append(sequence);
3543
3645
  const diffable =
3544
3646
  anchorPull === 0 &&
3545
3647
  geometryStable &&
@@ -3548,6 +3650,8 @@ export class TUI extends Container {
3548
3650
  !this.#forceViewportRepaintOnNextRender &&
3549
3651
  !destructiveReset &&
3550
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);
3551
3655
  if (diffable) {
3552
3656
  for (let index = 0; index < rows; index++) {
3553
3657
  if (
@@ -3566,9 +3670,8 @@ export class TUI extends Container {
3566
3670
  current,
3567
3671
  width,
3568
3672
  newTop + index,
3569
- -1,
3570
- -1,
3571
3673
  this.#osc66SpacerGlyphWidth(prepared.lines, index),
3674
+ { imagePlacements: this.#screenImagePlacements },
3572
3675
  )}`;
3573
3676
  }
3574
3677
  if (this.#providerWindow.length > rows && newTop + rows < height) {
@@ -3603,8 +3706,6 @@ export class TUI extends Container {
3603
3706
  preparedHistory.rows[index]!,
3604
3707
  width,
3605
3708
  Math.min(screenRow, height - 1),
3606
- -1,
3607
- -1,
3608
3709
  this.#osc66SpacerGlyphWidth(preparedHistory.lines, index),
3609
3710
  { blankRow: compactReplay },
3610
3711
  ),
@@ -3618,10 +3719,12 @@ export class TUI extends Container {
3618
3719
  prepared.rows[index]!,
3619
3720
  width,
3620
3721
  Math.min(screenRow, height - 1),
3621
- -1,
3622
- -1,
3623
3722
  this.#osc66SpacerGlyphWidth(prepared.lines, index),
3624
- { blankRow: compactReplay },
3723
+ {
3724
+ blankRow: compactReplay,
3725
+ imagePlacements: index < replayViewportRows ? undefined : this.#screenImagePlacements,
3726
+ imageRow: newTop + index,
3727
+ },
3625
3728
  ),
3626
3729
  );
3627
3730
  screenRow++;
@@ -4257,10 +4360,8 @@ export class TUI extends Container {
4257
4360
  line: PreparedLine,
4258
4361
  width: number,
4259
4362
  screenRow = -1,
4260
- frameRow = -1,
4261
- committedTo = -1,
4262
4363
  spacerGlyphWidth = -1,
4263
- options?: { blankRow?: boolean },
4364
+ options?: { blankRow?: boolean; imagePlacements?: Map<number, LiveImagePlacement>; imageRow?: number },
4264
4365
  ): string {
4265
4366
  // End every rewrite at column zero. ConPTY can materialize a pending
4266
4367
  // wrap before a following cursor-addressing sequence even while DECAWM is
@@ -4277,7 +4378,9 @@ export class TUI extends Container {
4277
4378
  if (spacerGlyphWidth >= 0) {
4278
4379
  rewrite = spacerGlyphWidth >= width ? "" : `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
4279
4380
  } else if (line.isImage) {
4280
- 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);
4281
4384
  } else {
4282
4385
  const terminalLine = this.#terminalLine(line);
4283
4386
  if (options?.blankRow) {
@@ -4352,9 +4455,9 @@ export class TUI extends Container {
4352
4455
 
4353
4456
  /**
4354
4457
  * Compose and paint a single fullscreen overlay frame on the alt buffer.
4355
- * Cursor markers are stripped (the modal draws its own in-band caret and
4356
- * keeps the hardware cursor hidden), and only the modal is composited over a
4357
- * 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.
4358
4461
  */
4359
4462
  #renderAltFrame(width: number, height: number): void {
4360
4463
  // oxlint-disable-next-line unicorn/no-new-array -- alt-frame length preallocation
@@ -4364,8 +4467,18 @@ export class TUI extends Container {
4364
4467
  this.#imageBudget.beginPass(false, true);
4365
4468
  lines = this.#compositeOverlaysIntoWindow(base, width, height);
4366
4469
  } while (this.#imageBudget.endPass());
4367
- const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height, []);
4368
- 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);
4369
4482
  }
4370
4483
 
4371
4484
  /**
@@ -4373,9 +4486,15 @@ export class TUI extends Container {
4373
4486
  * previous frame, or every row when the height changed, a repaint is forced,
4374
4487
  * or a changed frame holds OSC 66 text before or after. Emits only
4375
4488
  * sync-output brackets, cursor moves, and per-row rewrites — never ED3 or
4376
- * any native-scrollback byte. The hardware cursor stays hidden here.
4489
+ * any native-scrollback byte.
4377
4490
  */
4378
- #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 {
4379
4498
  // The pass that composed this frame ran with `altScreen`, so the normal
4380
4499
  // screen's own placements behind it are not treated as retired.
4381
4500
  this.#imageBudget.limitResidentImages();
@@ -4384,17 +4503,14 @@ export class TUI extends Container {
4384
4503
  // frame resolve against loaded data. The normal-screen path flushes these
4385
4504
  // ahead of its paint; without this, an image first shown inside a
4386
4505
  // fullscreen overlay (e.g. the settings shape preview) would render as
4387
- // 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();
4388
4509
  const purgeIds = this.#imageBudget.takePurgeIds();
4389
4510
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
4390
- for (const id of purgeIds) this.terminal.write(encodeKittyDeleteImage(id));
4391
- }
4392
- const imageTransmits = this.#imageBudget.takeTransmits();
4393
- if (imageTransmits.length > 0) {
4394
- let transmitBuffer = "";
4395
- for (const seq of imageTransmits) transmitBuffer += seq;
4396
- this.terminal.write(transmitBuffer);
4511
+ for (const id of purgeIds) prelude += encodeKittyDeleteImage(id);
4397
4512
  }
4513
+ for (const seq of this.#imageBudget.takeTransmits()) prelude += seq;
4398
4514
  // A forced repaint (resetDisplay, requestRender(true)) rewrites every row
4399
4515
  // even when the cached frame is byte-identical: the redraw gesture must
4400
4516
  // repair a corrupted modal. So does a changed frame with OSC 66 text in
@@ -4411,6 +4527,7 @@ export class TUI extends Container {
4411
4527
  prepared.rows.some((_row, r) =>
4412
4528
  this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r),
4413
4529
  ));
4530
+ prelude += this.#retireImagePlacements(this.#altImagePlacements, prepared.lines, 0, full);
4414
4531
  let rowsBuffer = "";
4415
4532
  for (let r = 0; r < height; r++) {
4416
4533
  if (full) {
@@ -4426,17 +4543,44 @@ export class TUI extends Container {
4426
4543
  prepared.rows[r]!,
4427
4544
  width,
4428
4545
  r,
4429
- -1,
4430
- -1,
4431
4546
  this.#osc66SpacerGlyphWidth(prepared.lines, r),
4547
+ { imagePlacements: this.#altImagePlacements },
4432
4548
  );
4433
4549
  }
4434
4550
  this.#altPreviousLines = prepared.lines;
4435
4551
  this.#altPreparedRows = prepared.rows;
4436
- if (rowsBuffer === "") return;
4437
- this.terminal.write(`${this.#paintBeginSequence}${full ? "\x1b[H" : ""}${rowsBuffer}${this.#paintEndSequence}`);
4438
- this.#debugPaint = { lines: prepared.lines, windowTop: 0, altScreen: true };
4439
- 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 !== "") {
4440
4584
  this.#notifyPaint({
4441
4585
  history: [],
4442
4586
  viewport: prepared.lines,
@@ -4446,6 +4590,11 @@ export class TUI extends Container {
4446
4590
  rows: height,
4447
4591
  });
4448
4592
  }
4449
- 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();
4450
4599
  }
4451
4600
  }