@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
@@ -6,8 +6,18 @@ import {
6
6
  type TokenizerAndRendererExtension,
7
7
  type TokenizerThis,
8
8
  type Tokens,
9
+ type TokensList,
9
10
  } from "@linxiraos/pi-utils/marked";
10
- import { mathBlockAt, mathSpanAt, mathSpanInContext, mathStartIndex } from "@linxiraos/pi-utils/math-delimiters";
11
+ import {
12
+ type MathBlockOpener,
13
+ mathBlockCloserIndex,
14
+ mathBlockInContext,
15
+ mathBlockMayCloseAt,
16
+ mathBlockOpenerAt,
17
+ mathSpanInContext,
18
+ mathStartIndex,
19
+ } from "@linxiraos/pi-utils/math-delimiters";
20
+ import { listMayContinueAt } from "@linxiraos/pi-utils/marked-list";
11
21
  import { latexToBlock } from "../latex-block";
12
22
  import { isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
13
23
  import { plainText } from "../native/spans";
@@ -15,6 +25,8 @@ import { md } from "../native/describe";
15
25
  import type { DescribeContext, NativeNode } from "../native/node";
16
26
  import type { SymbolTheme } from "../symbols";
17
27
  import { TERMINAL } from "../terminal-capabilities";
28
+ import { getSymbolTheme, getThemeEpoch } from "../theme/theme";
29
+ import { canCacheMarkdownListItems } from "../theme/tui-adapters";
18
30
  import type { Component } from "../tui";
19
31
  import {
20
32
  applyBackgroundToLine,
@@ -592,7 +604,6 @@ const markdownParser = new Marked();
592
604
  // never math. Inline extensions run before marked's escape tokenizer, so
593
605
  // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
594
606
  // renders as a literal dollar.
595
- const CUSTOM_HR_START_REGEX = /(?:^|\n) {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
596
607
  const CUSTOM_HR_TOKENIZER_REGEX = /^ {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
597
608
 
598
609
  function getHrChar(char: string, hrChar: string): string {
@@ -618,15 +629,6 @@ function getHrChar(char: string, hrChar: string): string {
618
629
  const customHrExtension: TokenizerAndRendererExtension = {
619
630
  name: "customHr",
620
631
  level: "block",
621
- start(src) {
622
- const match = CUSTOM_HR_START_REGEX.exec(src);
623
- if (!match) return undefined;
624
- let idx = match.index;
625
- if (src[idx] === "\n") {
626
- idx += 1;
627
- }
628
- return idx;
629
- },
630
632
  tokenizer(src) {
631
633
  const match = CUSTOM_HR_TOKENIZER_REGEX.exec(src);
632
634
  if (match) {
@@ -663,7 +665,7 @@ const mathBlockExtension: TokenizerAndRendererExtension = {
663
665
  // No `start` hint: marked only probes block extensions at a block boundary
664
666
  // here and never consults their hints.
665
667
  tokenizer(src) {
666
- const block = mathBlockAt(src);
668
+ const block = mathBlockInContext(this, src);
667
669
  if (!block) return undefined;
668
670
  return { type: "math", raw: block.raw, text: block.body, display: true };
669
671
  },
@@ -680,38 +682,67 @@ const mathBlockExtension: TokenizerAndRendererExtension = {
680
682
  // starts at offset 0" guards keep fenced/indented `\begin{cases}` code blocks
681
683
  // for marked's own code rules.
682
684
  const BARE_ENV_BEGIN = /(?:^|\n)[ \t]{0,3}\\begin\{([A-Za-z]+\*?)\}/;
683
- function bareMathEnvBlock(src: string): readonly [number, number] | null {
684
- const bm = BARE_ENV_BEGIN.exec(src);
685
- if (!bm || !isBareMathEnvironment(bm[1])) return null;
686
- const beginLineStart = bm.index === 0 ? 0 : bm.index + 1; // skip the matched leading `\n`
685
+ const BLANK_LINE_RE = /\n[ \t]*\n/;
686
+
687
+ /** Where a search for an `\end{…}` closer in a lexer source started, and what it found. */
688
+ interface EnvCloserSearch {
689
+ from: number;
690
+ at: number;
691
+ }
692
+ const envCloserSearches = new WeakMap<TokenizerThis, Map<string, EnvCloserSearch>>();
693
+
694
+ /**
695
+ * The first `endToken` at or after `from` in the source of `context`, or -1. Block
696
+ * starts ask at increasing offsets, so a search is remembered per source: an
697
+ * unclosed environment otherwise rescans the rest of the document at each one.
698
+ */
699
+ function envCloserIndex(context: TokenizerThis, source: string, endToken: string, from: number): number {
700
+ let searches = envCloserSearches.get(context);
701
+ if (!searches) envCloserSearches.set(context, (searches = new Map()));
702
+ const last = searches.get(endToken);
703
+ if (last !== undefined && last.from <= from && (last.at === -1 || from <= last.at)) return last.at;
704
+ const at = source.indexOf(endToken, from);
705
+ searches.set(endToken, { from, at });
706
+ return at;
707
+ }
708
+
709
+ /** Length of the bare math-environment block that starts at offset 0 of `src`, or 0. */
710
+ function bareMathEnvBlockLength(context: TokenizerThis, src: string): number {
711
+ // A block at offset 0 has its `\begin` on the first line, or on the second
712
+ // after an `lhs =` line, so the opener scan stops there.
713
+ const firstBreak = src.indexOf("\n");
714
+ const secondBreak = firstBreak === -1 ? -1 : src.indexOf("\n", firstBreak + 1);
715
+ const bm = BARE_ENV_BEGIN.exec(secondBreak === -1 ? src : src.slice(0, secondBreak));
716
+ if (!bm || !isBareMathEnvironment(bm[1])) return 0;
717
+ // Skip a matched leading `\n`, at offset 0 too: a blank line before the block
718
+ // is a space token of its own, not part of the block.
719
+ const beginLineStart = src[bm.index] === "\n" ? bm.index + 1 : bm.index;
720
+ // Pull in one immediately-preceding `lhs =`/open-delimiter line (e.g. `f(x) =`).
721
+ if (beginLineStart > 0 && !/[=([{]\s*$/.test(src.slice(0, beginLineStart - 1))) return 0;
687
722
  const endToken = `\\end{${bm[1]}}`;
688
- const endAt = src.indexOf(endToken, bm.index);
689
- if (endAt === -1) return null;
723
+ const source = context.source ?? src;
724
+ const base = (context.end ?? source.length) - src.length;
725
+ const closer = envCloserIndex(context, source, endToken, base + bm.index);
726
+ if (closer === -1) return 0;
727
+ const endAt = closer - base;
690
728
  // The `\end` must close before any blank line (i.e. within the same block).
691
- if (/\n[ \t]*\n/.test(src.slice(beginLineStart, endAt))) return null;
729
+ // Scan only up to the closer: the rest of the document is irrelevant here, and
730
+ // scanning it per block is quadratic across consecutive environments.
731
+ if (BLANK_LINE_RE.test(src.slice(beginLineStart, endAt))) return 0;
692
732
  let blockEnd = endAt + endToken.length;
693
733
  while (src[blockEnd] === " " || src[blockEnd] === "\t") blockEnd++;
694
734
  if (src[blockEnd] === "\n") blockEnd++;
695
- // Pull in one immediately-preceding `lhs =`/open-delimiter line (e.g. `f(x) =`).
696
- let start = beginLineStart;
697
- if (start > 0 && src[start - 1] === "\n") {
698
- const prevStart = src.lastIndexOf("\n", start - 2) + 1;
699
- const prevLine = src.slice(prevStart, start - 1);
700
- if (/[=([{]\s*$/.test(prevLine)) start = prevStart;
701
- }
702
- return [start, blockEnd];
735
+ return blockEnd;
703
736
  }
737
+
704
738
  const mathEnvBlockExtension: TokenizerAndRendererExtension = {
705
739
  name: "mathEnvBlock",
706
740
  level: "block",
707
- start(src) {
708
- const r = bareMathEnvBlock(src);
709
- return r ? r[0] : undefined;
710
- },
741
+ // No `start` hint: marked only probes block extensions at a block boundary.
711
742
  tokenizer(src) {
712
- const r = bareMathEnvBlock(src);
713
- if (r?.[0] !== 0) return undefined; // only consume when the block starts at offset 0
714
- const raw = src.slice(0, r[1]);
743
+ const length = bareMathEnvBlockLength(this, src);
744
+ if (length === 0) return undefined;
745
+ const raw = src.slice(0, length);
715
746
  const text = raw.replace(/\n[ \t]*$/, "");
716
747
  if (text.trim().length === 0) return undefined;
717
748
  return { type: "math", raw, text, display: true };
@@ -761,77 +792,13 @@ markdownParser.use({
761
792
  ],
762
793
  });
763
794
 
764
- // Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
765
- // `((?:.|\n(?!<block-start>))+?)` re-runs its block-start lookahead while
766
- // expanding character by character, so even a FAILING attempt at offset 0
767
- // costs O(len × lookahead) — ~26µs per 200-char list-item body, and marked's
768
- // list tokenizer block-tokenizes every item's content (47.8% of a streaming
769
- // bench profile). A match REQUIRES the setext underline `\n {0,3}(=+|-+)`
770
- // somewhere in src, so this O(n) charCode scan never rejects a src the
771
- // built-in rule would match; single-line srcs (every tight list item) reject
772
- // on the first indexOf.
773
- function lheadingPossible(src: string): boolean {
774
- let i = src.indexOf("\n");
775
- while (i !== -1) {
776
- let j = i + 1;
777
- const limit = j + 3; // underline allows up to 3 leading spaces
778
- while (j < limit && src.charCodeAt(j) === 0x20 /* space */) j++;
779
- const c = src.charCodeAt(j); // NaN past the end fails both comparisons
780
- if (c === 0x3d /* = */ || c === 0x2d /* - */) return true;
781
- i = src.indexOf("\n", j);
782
- }
783
- return false;
784
- }
785
-
786
795
  markdownParser.use({
787
796
  tokenizer: {
788
- // `false` → marked falls back to the built-in tokenizer;
789
- // `undefined` → no token here, built-in never runs.
790
- lheading(src: string): Tokens.Heading | undefined | false {
791
- return lheadingPossible(src) ? false : undefined;
792
- },
793
797
  // Strikethrough is `strikethroughExtension`'s: marked's own looser rule never runs.
794
798
  del: () => undefined,
795
799
  },
796
800
  });
797
801
 
798
- // ---------------------------------------------------------------------------
799
- // Sticky clones of marked's pathological block rules
800
- // ---------------------------------------------------------------------------
801
- // Bun's (JSC) regex engine skips the start-anchor fast-fail for several of
802
- // marked's `^`-anchored block rules — `hr`, `lheading`, `table` and `html` are
803
- // anchored alternations of quantified branches, and a failing `exec`/`test`
804
- // rescans the entire remaining source instead of stopping after offset 0.
805
- // marked's list tokenizer runs `hr.test` and `lheading` per list line against
806
- // the remaining source, so lexing a long list is quadratic (66% of a streaming
807
- // bench profile sat in these two regexes). A sticky (`y`) clone with
808
- // `lastIndex` pinned to 0 attempts the match at offset 0 only.
809
- //
810
- // Equivalence: for a flagless rule whose source is `^`-anchored, a sticky
811
- // clone at `lastIndex = 0` matches exactly when the original matches (same
812
- // match object, same captures) — `^` already restricted matches to offset 0
813
- // (no `m` flag), and stickiness only removes the futile later attempts. The
814
- // flags/anchor guard below skips any rule a future marked version changes.
815
- class AnchoredAtZero extends RegExp {
816
- override exec(str: string): RegExpExecArray | null {
817
- this.lastIndex = 0; // sticky matches set lastIndex; rules are shared
818
- return super.exec(str);
819
- }
820
- override test(str: string): boolean {
821
- this.lastIndex = 0;
822
- return super.test(str);
823
- }
824
- }
825
-
826
- for (const table of [Lexer.rules.block.normal, Lexer.rules.block.gfm]) {
827
- for (const name of ["hr", "lheading", "table", "html"] as const) {
828
- const rule = table[name];
829
- if (rule.flags === "" && rule.source.startsWith("^")) {
830
- table[name] = new AnchoredAtZero(rule.source, "y");
831
- }
832
- }
833
- }
834
-
835
802
  // ---------------------------------------------------------------------------
836
803
  // Module-level LRU render cache
837
804
  // ---------------------------------------------------------------------------
@@ -897,12 +864,16 @@ function appendLines(dst: string[], src: readonly string[]): void {
897
864
  // delta-edge trailingDelimiterSeamHazard check).
898
865
  const FAST_DELTA_RE = /[\n\r\\[`<!*_~$#&@\x1b]/;
899
866
 
867
+ // Emphasis delimiters, which can pair across the fast path's seam.
868
+ const FAST_EMPHASIS_DELIMITERS = ["*", "_", "~"] as const;
869
+
900
870
  // Disarm when the captured row's RAW tail ends in trailing whitespace (wrap
901
- // trims it; appending a char moves the trim boundary), a trailing backslash
902
- // (it can become an escape once the delta supplies the next char — the `\\`
903
- // clause covers that escape-completion hazard), or a full/partial hex swatch
904
- // run (a `#` + 3-8 hex is a swatch glyph; the byte range may shift).
905
- const FAST_RUN_END_RE = /(?:[ \t\\]|#[0-9a-fA-F]{3,8}|#+)$/i;
871
+ // trims it, a no-break space included; appending a char moves the trim
872
+ // boundary), a trailing backslash (it can become an escape once the delta
873
+ // supplies the next char — the `\\` clause covers that escape-completion
874
+ // hazard), or a full/partial hex swatch run (a `#` + 3-8 hex is a swatch
875
+ // glyph; the byte range may shift).
876
+ const FAST_RUN_END_RE = /(?:[\s\\]|#[0-9a-fA-F]{3,8}|#+)$/i;
906
877
 
907
878
  // A partial `#` + 1-2 hex digits can grow into a 3-8 digit swatch glyph
908
879
  // across the seam (delta hex digits are inert).
@@ -957,16 +928,18 @@ const FAST_TABLE_DELIM_ROW_RE = /^\s*(?:\|[\s:]*-+\s*(?:\|[\s:]*-+\s*)*|[\s:]*-+
957
928
 
958
929
  // A paragraph's LAST line can complete into a different block kind under an
959
930
  // inert delta (ATX heading, blockquote, bullet marker, HR, ref-def) — disarm
960
- // when the grown line starts one (ref-def grammar: REF_DEF_LINE_RE).
931
+ // when the grown line starts one (ref-def grammar: REF_DEF_LINE_RE). A line
932
+ // holding `\end{` can also close a bare math environment the paragraph opened
933
+ // lines above (`\end{al` + `ign}`), making those lines one display block.
961
934
  const FAST_LINE_START_HAZARD_RE =
962
935
  // `-` is placed LAST so it is a literal, not a range bound. The other
963
936
  // chars are in ASCENDING code-point order (no reversed ranges that
964
937
  // rely on engine leniency): * + = – — ─ ━ ═ then the literal `-`.
965
938
  /^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)](?:[ \t]|$)|[*+=–—─━═-](?:[ \t]|$)|(?:[*+=–—─━═-][ \t]*){2,}[ \t]*$)/;
966
939
 
967
- /** @internal exported for tests — the grown-line-start block-kind gate. */
940
+ /** @internal exported for tests — the grown-line block-kind gate. */
968
941
  export function fastLineStartHazard(grownLine: string): boolean {
969
- return FAST_LINE_START_HAZARD_RE.test(grownLine) || REF_DEF_LINE_RE.test(grownLine);
942
+ return FAST_LINE_START_HAZARD_RE.test(grownLine) || REF_DEF_LINE_RE.test(grownLine) || grownLine.includes("\\end{");
970
943
  }
971
944
 
972
945
  /** Seam hazards between the captured raw row tail and the delta: the row must
@@ -1038,105 +1011,87 @@ function lexInlineTokens(text: string): Token[] {
1038
1011
  const REF_DEF_LINE_RE = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/;
1039
1012
  const HAS_REF_DEF = new RegExp(REF_DEF_LINE_RE.source, "m");
1040
1013
 
1041
- // marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
1042
- // blank lines only when the remaining source matches
1043
- // `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
1044
- // where `marker` is the exact bullet char for unordered lists (`\${char}`) or
1045
- // 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
1046
- // The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
1047
- // sits at the start of a top-level list token's raw:
1048
- const LIST_MARKER_RE = /^ {0,3}(?:([*+-])|\d{1,9}([.)]))/;
1049
-
1050
- // Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
1051
- // lex(full text) — for the CURRENT text and for every append-only extension of
1052
- // it, because a frozen prefix is sticky (it keeps being reused while the text
1053
- // grows). At a blank-line (`\n\n`) cut directly after a top-level `list`
1054
- // token, the only construct that can straddle the cut is a continuation item
1055
- // of that list: marked consumed the blank line into the last item's raw and
1056
- // re-ran `listItemRegex` at exactly `tailStart`, merging a same-marker item
1057
- // into one renumbered loose list. The cut is safe only when that regex can
1058
- // NEVER match at `tailStart`, no matter what is appended later.
1059
- //
1060
- // Append-only growth means existing characters are immutable while new ones
1061
- // may appear after them, so "closed" may only be concluded from a present
1062
- // character that contradicts every possible continuation (e.g. tail "1x" can
1063
- // never grow into an ordered item, but tail "1" can become "1. c"). Running
1064
- // out of text mid-marker therefore answers "may continue".
1065
- //
1066
- // Returns true when the tail could still continue the list (or the list's
1067
- // marker is unrecognizable) — the conservative "don't freeze" answer. marked
1068
- // may break the list anyway when the matching line is also an hr (`- - -`);
1069
- // treating that as "may continue" merely skips a freeze, never corrupts one.
1070
- function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean {
1071
- const marker = LIST_MARKER_RE.exec(listRaw);
1072
- if (marker === null) return true; // unrecognized list shape — stay conservative
1073
- const n = text.length;
1074
- let i = tailStart;
1075
- // `listItemRegex` allows up to 3 leading spaces (the caller's next-char
1076
- // guard rejects whitespace at the final cut, but mirror the rule exactly).
1077
- while (i < n && i - tailStart < 3 && text.charCodeAt(i) === 0x20 /* space */) i++;
1078
- if (i >= n) return true;
1079
- const bullet = marker[1];
1080
- if (bullet !== undefined) {
1081
- if (text[i] !== bullet) return false; // wrong marker char — closed forever
1082
- i++;
1083
- } else {
1084
- // Ordered: 1-9 digits, then the same `.`/`)` delimiter.
1085
- let digits = 0;
1086
- while (i < n && digits < 10) {
1087
- const c = text.charCodeAt(i);
1088
- if (c < 0x30 /* 0 */ || c > 0x39 /* 9 */) break;
1089
- digits++;
1090
- i++;
1091
- }
1092
- if (digits === 0 || digits > 9) return false; // no digit run / too long — closed forever
1093
- if (i >= n) return true; // delimiter (or more digits) may still arrive
1094
- if (text[i] !== marker[2]) return false; // wrong delimiter — closed forever
1095
- i++;
1096
- }
1097
- // After the marker: `(?:[\t ][^\n]*)?(?:\n|$)` — tab/space + anything, a
1098
- // bare newline, or end-of-input (which appends can still extend).
1099
- if (i >= n) return true;
1100
- const after = text.charCodeAt(i);
1101
- return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
1014
+ /** The last stable block boundary of a token run: see {@link stableBlockBoundary}. */
1015
+ interface BlockBoundary {
1016
+ /** Offset just past the boundary token, or 0 when the run holds none. */
1017
+ end: number;
1018
+ /** Number of tokens up to and including the boundary token, or 0. */
1019
+ count: number;
1102
1020
  }
1103
1021
 
1104
- const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
1022
+ // A whitespace-only line, capturing its terminator: "\n", or "" at the end of the text.
1023
+ const WHITESPACE_LINE_RE = /[^\S\n]*(\n|$)/y;
1024
+
1025
+ /**
1026
+ * A display-math opener in the frozen streaming prefix whose block an append
1027
+ * could still close: the first such opener of its kind.
1028
+ */
1029
+ interface PrefixOpener {
1030
+ kind: MathBlockOpener;
1031
+ /** Offset of the opener's line. */
1032
+ at: number;
1033
+ /** The stable boundary in front of the opener, as a text length and a token count. */
1034
+ rewindEnd: number;
1035
+ rewindCount: number;
1036
+ }
1037
+
1038
+ const NO_OPENERS: readonly PrefixOpener[] = [];
1105
1039
 
1106
1040
  /**
1107
1041
  * Offset just past the last token in `tokens` that closes a block on a hard
1108
1042
  * `"\n\n"` break, together with the number of tokens up to and including it.
1109
1043
  * `count === 0` means the run holds no usable boundary.
1110
1044
  *
1111
- * `base` is where `tokens[0]` starts inside `text`. A boundary qualifies only
1112
- * when splitting there is invisible to the lexer, i.e. `lex(head) ++ lex(tail)
1113
- * === lex(text)`:
1045
+ * `base` is where `tokens[0]` starts inside `text`; `tokens` lex the rest of
1046
+ * `text`, which appends may still extend (the streaming freeze). A boundary
1047
+ * qualifies only when splitting there is invisible to the lexer, i.e.
1048
+ * `lex(head) ++ lex(tail) === lex(text)`:
1114
1049
  * - The break must sit inside `text`. At end-of-text the next character is
1115
1050
  * unknown (and, while streaming, may still arrive), so the cut is deferred.
1116
- * - The next character must start real block content. Whitespace means the
1117
- * block separator straddles the cut — e.g. a fence followed by
1118
- * `"\n\n\n- list"` — and the two lexes desync.
1051
+ * - The next line must start real block content. A leading space or newline
1052
+ * means the block separator straddles the cut — e.g. a fence followed by
1053
+ * `"\n\n\n- list"` — and the two lexes desync. So does a line of any other
1054
+ * whitespace, such as a no-break space: the lexer's blank line is
1055
+ * `/^\s*\n$/`, so it joins the blank run in front of the cut. While
1056
+ * streaming, a whitespace-only last line may still become one.
1119
1057
  * - A preceding `list` must be provably closed: CommonMark lets a same-marker
1120
1058
  * item continue the list across the blank line, and marked merges both into
1121
1059
  * one renumbered loose list (`listMayContinueAt`).
1060
+ * - Display math: `tokens` are the one-pass lex of `text` as it stands, so
1061
+ * a block the lex already made is a `math` token, and one that an
1062
+ * append could still close becomes one only once a closer line arrives,
1063
+ * which Markdown#lexTokens watches for. With `settle`, the scan instead
1064
+ * stops at a token whose display-math block an append could still close
1065
+ * (`mathBlockMayCloseAt`), so no append changes the tokens before the
1066
+ * boundary it finds. A pair whose closer line has already ended around a
1067
+ * whitespace-only body (`$$`, ` `, `$$`) is no block whatever follows, so
1068
+ * that scan goes on past it.
1122
1069
  *
1123
1070
  * `startIndex` resumes the scan at `tokens[startIndex]` (positions still
1124
1071
  * accumulate from `base`). The streaming freeze passes the frozen-prefix
1125
1072
  * token count: that prefix's boundary is permanent under append-only growth
1126
1073
  * (re-verified when frozen), so only the mutable tail can hold a new one.
1074
+ * `endIndex` ends the scan in front of `tokens[endIndex]`, for the last
1075
+ * boundary in front of a given token.
1127
1076
  */
1128
1077
  function stableBlockBoundary(
1129
1078
  text: string,
1130
1079
  base: number,
1131
1080
  tokens: Token[],
1132
- startIndex = 0,
1133
- ): { end: number; count: number } {
1081
+ {
1082
+ startIndex = 0,
1083
+ endIndex = tokens.length,
1084
+ settle = false,
1085
+ }: { startIndex?: number; endIndex?: number; settle?: boolean } = {},
1086
+ ): BlockBoundary {
1134
1087
  let pos = base;
1135
1088
  let end = 0;
1136
1089
  let count = 0;
1137
- for (let i = startIndex; i < tokens.length; i++) {
1138
- const raw = tokens[i].raw;
1090
+ for (let i = startIndex; i < endIndex; i++) {
1091
+ const token = tokens[i];
1092
+ const raw = token.raw;
1139
1093
  const tokenEnd = pos + raw.length;
1094
+ if (settle && token.type !== "math" && mathBlockMayCloseAt(text, pos)) break;
1140
1095
  if (raw.endsWith("\n\n")) {
1141
1096
  const prev = i > 0 ? tokens[i - 1] : undefined;
1142
1097
  if (prev === undefined || prev.type !== "list" || !listMayContinueAt(text, tokenEnd, prev.raw)) {
@@ -1146,84 +1101,38 @@ function stableBlockBoundary(
1146
1101
  }
1147
1102
  pos = tokenEnd;
1148
1103
  }
1149
- if (count === 0 || end >= text.length) return NO_BLOCK_BOUNDARY;
1104
+ if (count === 0 || end >= text.length) return { end: 0, count: 0 };
1150
1105
  const next = text.charCodeAt(end);
1151
- if (next === 0x20 /* space */ || next === 0x0a /* \n */) return NO_BLOCK_BOUNDARY;
1106
+ if (next === 0x20 /* space */ || next === 0x0a /* \n */) return { end: 0, count: 0 };
1107
+ WHITESPACE_LINE_RE.lastIndex = end;
1108
+ if (WHITESPACE_LINE_RE.test(text)) return { end: 0, count: 0 };
1152
1109
  return { end, count };
1153
1110
  }
1154
1111
 
1155
- // Bun's regex engine skips the start-anchor optimization for several of marked's
1156
- // block rules — `hr`, `lheading`, `table` and `html` are `^`-anchored
1157
- // alternations of quantified branches — so each failing `exec` rescans the whole
1158
- // remaining source instead of stopping at offset 0. Lexing is then quadratic in
1159
- // document length: an 800 KB message costs ~41 s under Bun where Node/V8 needs
1160
- // ~60 ms, and it runs on the render path, freezing the UI. Bounded windows keep
1161
- // every scan short and restore linear behavior (~0.7 s for that same message).
1162
- const LEX_WINDOW_BYTES = 2 * 1024;
1163
- // Under this size a single pass beats probing for window boundaries; the
1164
- // crossover measured on pathological Markdown sits around 16 KB.
1165
- const WINDOWED_LEX_MIN_BYTES = 16 * 1024;
1166
-
1167
1112
  /**
1168
- * Lex `text` in bounded windows, producing the exact token stream
1169
- * `markdownParser.lexer(text)` would.
1170
- *
1171
- * Window cuts come from marked itself: a throwaway BLOCK-ONLY probe lex of the
1172
- * window reports its last stable block boundary ({@link stableBlockBoundary})
1173
- * and only that confirmed segment is handed to the real lexer; a window
1174
- * holding no boundary doubles until it finds one or reaches the end. Probes
1175
- * never run inline tokenization (their inlineQueue is discarded) — a boundary
1176
- * is a property of block structure alone, and probe inline passes were the
1177
- * dominant cost of an earlier revision. Block tokenization runs per window
1178
- * while inline tokenization is deferred to the end — mirroring `Lexer.lex` —
1179
- * so a `[label]: dest` definition anywhere in the document still resolves for
1180
- * every inline span.
1181
- *
1182
- * A boundary requires some top-level token whose raw ends in `"\n\n"`, so a
1183
- * window that contains no blank line cannot cut: each round starts at the next
1184
- * `"\n\n"` (skipping straight to the end when there is none — e.g. a tail
1185
- * that is one long tight list) instead of probing sizes that cannot succeed.
1113
+ * Lex a whole document with the renderer's rules (math, custom rules,
1114
+ * strikethrough). Callers that split a document along its blocks (table
1115
+ * charts) lex through this so they see the blocks Markdown renders.
1186
1116
  */
1187
- function lexWindowed(text: string): Token[] {
1188
- const lexer = new Lexer(markdownParser.defaults);
1189
- let offset = 0;
1190
- while (offset < text.length) {
1191
- let segment = "";
1192
- const nextBlank = text.indexOf("\n\n", offset);
1193
- if (nextBlank === -1) {
1194
- segment = text.slice(offset);
1195
- } else {
1196
- const minSize = Math.max(LEX_WINDOW_BYTES, nextBlank + 2 - offset);
1197
- for (let size = minSize; segment.length === 0; size *= 2) {
1198
- if (offset + size >= text.length) {
1199
- segment = text.slice(offset);
1200
- break;
1201
- }
1202
- const probe = new Lexer(markdownParser.defaults);
1203
- probe.blockTokens(text.slice(offset, offset + size), probe.tokens);
1204
- const boundary = stableBlockBoundary(text, offset, probe.tokens);
1205
- if (boundary.count > 0) segment = text.slice(offset, boundary.end);
1206
- }
1207
- }
1208
- lexer.blockTokens(segment, lexer.tokens);
1209
- offset += segment.length;
1210
- }
1211
- for (const queued of lexer.inlineQueue) lexer.inlineTokens(queued.src, queued.tokens);
1212
- lexer.inlineQueue = [];
1213
- return lexer.tokens;
1117
+ export function lexDocument(text: string): TokensList {
1118
+ return markdownParser.lexer(text);
1214
1119
  }
1215
1120
 
1216
- /** Lex a whole document, windowing anything large enough for the quadratic scan to bite. */
1217
- function lexDocument(text: string): Token[] {
1218
- // A CR shifts every `raw` span (marked normalizes CRLF before tokenizing), so
1219
- // window offsets would address the wrong characters — lex those in one pass.
1220
- if (text.length < WINDOWED_LEX_MIN_BYTES || text.includes("\r")) return markdownParser.lexer(text);
1221
- return lexWindowed(text);
1121
+ /**
1122
+ * `source` as a fenced code block in `lang`: the fence is one backtick longer
1123
+ * than any backtick run in the source (at least three), so the block always
1124
+ * closes where intended. `open` leaves it unclosed, the source verbatim, for
1125
+ * a body that is still arriving.
1126
+ */
1127
+ export function fencedCode(lang: string, source: string, options?: { open?: boolean }): string {
1128
+ const longest = source.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 2) ?? 2;
1129
+ const fence = "`".repeat(longest + 1);
1130
+ return options?.open ? `${fence}${lang}\n${source}` : `${fence}${lang}\n${source.trimEnd()}\n${fence}`;
1222
1131
  }
1223
1132
 
1224
1133
  /** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
1225
1134
  export interface MarkdownLink {
1226
- /** Visible link text (equals `href` for autolinks and bare URLs). */
1135
+ /** Flattened visible label with whitespace collapsed to one row; falls back to `href` when empty. */
1227
1136
  text: string;
1228
1137
  /** Destination exactly as marked resolved it (references resolved, no normalization). */
1229
1138
  href: string;
@@ -1261,10 +1170,124 @@ export function extractMarkdownLinks(text: string): MarkdownLink[] {
1261
1170
  if (any.rows) for (const row of any.rows) for (const cell of row) walk(cell.tokens);
1262
1171
  }
1263
1172
  };
1264
- walk(lexDocument(text));
1173
+ walk(markdownParser.lexer(text));
1265
1174
  return links;
1266
1175
  }
1267
1176
 
1177
+ /**
1178
+ * Offset just past the destination that starts at or after `start` in `raw`
1179
+ * (CommonMark link destination grammar), plus where it begins. `null` when no
1180
+ * destination is there.
1181
+ */
1182
+ function scanLinkDestination(raw: string, start: number): { start: number; end: number } | null {
1183
+ let i = start;
1184
+ while (i < raw.length && (raw[i] === " " || raw[i] === "\t" || raw[i] === "\n" || raw[i] === "\r")) i++;
1185
+ if (i >= raw.length) return null;
1186
+ if (raw[i] === "<") {
1187
+ for (let j = i + 1; j < raw.length; j++) {
1188
+ if (raw[j] === "\\") j++;
1189
+ else if (raw[j] === ">") return { start: i, end: j + 1 };
1190
+ else if (raw[j] === "<" || raw[j] === "\n") return null;
1191
+ }
1192
+ return null;
1193
+ }
1194
+ let depth = 0;
1195
+ let j = i;
1196
+ for (; j < raw.length; j++) {
1197
+ const ch = raw[j]!;
1198
+ if (ch === "\\") {
1199
+ j++;
1200
+ continue;
1201
+ }
1202
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch.charCodeAt(0) < 0x20) break;
1203
+ if (ch === "(") depth++;
1204
+ else if (ch === ")") {
1205
+ if (depth === 0) break;
1206
+ depth--;
1207
+ }
1208
+ }
1209
+ return j > i ? { start: i, end: j } : null;
1210
+ }
1211
+
1212
+ /** A destination that stays one destination whatever bytes the target holds. */
1213
+ function formatLinkDestination(target: string): string {
1214
+ return /[\s()<>\\]/.test(target) ? `<${target.replaceAll(/[<>\\]/g, ch => encodeURIComponent(ch))}>` : target;
1215
+ }
1216
+
1217
+ function isListToken(token: Token): token is Tokens.List {
1218
+ return token.type === "list";
1219
+ }
1220
+
1221
+ function isTableToken(token: Token): token is Tokens.Table {
1222
+ return token.type === "table";
1223
+ }
1224
+
1225
+ /**
1226
+ * Rewrite the destinations of inline links and link reference definitions in
1227
+ * `text` to `resolve(href)`, leaving everything else byte-for-byte intact.
1228
+ * Hosts that render the Markdown source themselves (a native terminal's `md`
1229
+ * node) resolve relative destinations against their own idea of the working
1230
+ * directory; handing them the session-resolved target keeps links pointing at
1231
+ * what the author meant. Links inside code, images, autolinks and reference
1232
+ * uses (`[x][ref]`, rewritten through their definition) are untouched; a link
1233
+ * whose source cannot be located exactly is left as written.
1234
+ */
1235
+ export function rewriteMarkdownLinkDestinations(text: string, resolve: (href: string) => string | undefined): string {
1236
+ const edits: Array<{ start: number; end: number; target: string }> = [];
1237
+ let cursor = 0;
1238
+ const rewrite = (token: Token, labelEnd: number | undefined): void => {
1239
+ const at = text.indexOf(token.raw, cursor);
1240
+ if (at < 0) return;
1241
+ cursor = at + token.raw.length;
1242
+ const href = "href" in token && typeof token.href === "string" ? token.href : "";
1243
+ if (!href || labelEnd === undefined) return;
1244
+ const target = resolve(href);
1245
+ if (!target || target === href) return;
1246
+ const dest = scanLinkDestination(token.raw, labelEnd);
1247
+ if (dest) edits.push({ start: at + dest.start, end: at + dest.end, target });
1248
+ };
1249
+ const walk = (tokens: readonly Token[] | undefined): void => {
1250
+ if (!tokens) return;
1251
+ for (const token of tokens) {
1252
+ if (token.type === "link") {
1253
+ const label = "text" in token && typeof token.text === "string" ? `[${token.text}](` : undefined;
1254
+ rewrite(token, label && token.raw.startsWith(label) ? label.length : undefined);
1255
+ continue;
1256
+ }
1257
+ if (token.type === "def") {
1258
+ const close = token.raw.indexOf("]:");
1259
+ rewrite(token, close < 0 ? undefined : close + 2);
1260
+ continue;
1261
+ }
1262
+ const children = "tokens" in token && Array.isArray(token.tokens) ? token.tokens : undefined;
1263
+ const items = isListToken(token) ? token.items : undefined;
1264
+ const table = isTableToken(token) ? token : undefined;
1265
+ if (token.type === "image" || (!children && !items && !table)) {
1266
+ // A leaf (text, code span, fenced code, html, image…): step past it so a
1267
+ // later link's source is never matched inside it.
1268
+ const at = token.raw ? text.indexOf(token.raw, cursor) : -1;
1269
+ if (at >= 0) cursor = at + token.raw.length;
1270
+ continue;
1271
+ }
1272
+ walk(children);
1273
+ walk(items);
1274
+ if (table) {
1275
+ for (const cell of table.header) walk(cell.tokens);
1276
+ for (const row of table.rows) for (const cell of row) walk(cell.tokens);
1277
+ }
1278
+ }
1279
+ };
1280
+ walk(markdownParser.lexer(text));
1281
+ if (edits.length === 0) return text;
1282
+ let out = "";
1283
+ let last = 0;
1284
+ for (const edit of edits) {
1285
+ out += text.slice(last, edit.start) + formatLinkDestination(edit.target);
1286
+ last = edit.end;
1287
+ }
1288
+ return out + text.slice(last);
1289
+ }
1290
+
1268
1291
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
1269
1292
  export function clearRenderCache(): void {
1270
1293
  renderCache.clear();
@@ -1290,6 +1313,13 @@ function objectId(o: object): number {
1290
1313
  export interface DefaultTextStyle {
1291
1314
  /** Foreground color function */
1292
1315
  color?: (text: string) => string;
1316
+ /**
1317
+ * The `color` foreground is prose-level: semantically-themed markdown spans
1318
+ * (headings, links) keep their own `md*` theme colors instead of being
1319
+ * painted over by it, while plain text runs still use `color`. Unset keeps
1320
+ * the historical behavior where `color` paints every run.
1321
+ */
1322
+ proseFg?: boolean;
1293
1323
  /** Background color function */
1294
1324
  bgColor?: (text: string) => string;
1295
1325
  /** Bold text */
@@ -1355,18 +1385,42 @@ export interface MarkdownTheme {
1355
1385
  * Return null to fall back to fenced code rendering.
1356
1386
  */
1357
1387
  resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
1358
- symbols: SymbolTheme;
1388
+ /**
1389
+ * Glyphs for quote borders, rules, tables and color swatches. Optional so themes
1390
+ * built to the upstream pi-tui `MarkdownTheme` shape (which has no `symbols`)
1391
+ * still render; omitted symbols fall back to the active theme's set.
1392
+ */
1393
+ symbols?: SymbolTheme;
1359
1394
  }
1360
1395
 
1361
1396
  interface InlineStyleContext {
1362
1397
  applyText: (text: string) => string;
1363
1398
  stylePrefix: string;
1399
+ /** True when the context came from the component's own default style (vs. a caller-supplied one). */
1400
+ isDefault?: boolean;
1364
1401
  }
1365
1402
 
1366
- type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number };
1403
+ type ListToken = Token & {
1404
+ items: Array<{ raw: string; tokens?: Token[] }>;
1405
+ ordered: boolean;
1406
+ start?: number;
1407
+ loose: boolean;
1408
+ };
1367
1409
  type TableCellToken = { tokens?: Token[] };
1368
1410
  type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
1369
1411
 
1412
+ /** `item` as marked lexes it inside a loose list: tight `text` blocks become paragraphs. */
1413
+ function loosenListItem(item: Tokens.ListItem): Tokens.ListItem {
1414
+ if (item.loose) return item;
1415
+ return {
1416
+ ...item,
1417
+ loose: true,
1418
+ tokens: item.tokens.map(token =>
1419
+ token.type === "text" ? ({ ...token, type: "paragraph" } as Tokens.Paragraph) : token,
1420
+ ),
1421
+ };
1422
+ }
1423
+
1370
1424
  function formatHyperlink(text: string, target: string): string {
1371
1425
  if (!TERMINAL.hyperlinks || !target) {
1372
1426
  return text;
@@ -1631,6 +1685,15 @@ interface RenderSignature {
1631
1685
  textSizing: boolean;
1632
1686
  bgColorProbe: string;
1633
1687
  headingProbe: string;
1688
+ /** Fallback glyphs for themes without `symbols`; their identity alone cannot tell presets apart. */
1689
+ symbolsProbe: string;
1690
+ }
1691
+
1692
+ /** A shorter prefix the cached rows grew from: its length, its token count and the row count its rows end at. */
1693
+ interface PrefixMark {
1694
+ textEnd: number;
1695
+ tokenCount: number;
1696
+ lineEnd: number;
1634
1697
  }
1635
1698
 
1636
1699
  interface StreamPrefixLineCache extends RenderSignature {
@@ -1639,6 +1702,34 @@ interface StreamPrefixLineCache extends RenderSignature {
1639
1702
  // Private to the cache and never handed to callers (each frame copies it
1640
1703
  // into a fresh output array), so an advancing prefix appends in place.
1641
1704
  lines: string[];
1705
+ // The shorter prefixes the rows grew from, oldest first. Each ends on a
1706
+ // frozen block boundary, so its rows are a render of its tokens alone,
1707
+ // and a rewound prefix keeps the rows of the last one it still covers.
1708
+ marks: PrefixMark[];
1709
+ }
1710
+
1711
+ /**
1712
+ * `cache` cut back to the rows of its last prefix of at most `tokenCount`
1713
+ * tokens, in fresh arrays, or `undefined` when it has none. A put-aside prefix
1714
+ * may still hold `cache`, so its arrays are never shared.
1715
+ */
1716
+ function rewoundLineCache(
1717
+ cache: StreamPrefixLineCache | undefined,
1718
+ tokenCount: number,
1719
+ ): StreamPrefixLineCache | undefined {
1720
+ if (cache === undefined) return undefined;
1721
+ if (cache.tokenCount <= tokenCount) return { ...cache, lines: cache.lines.slice(), marks: cache.marks.slice() };
1722
+ let i = cache.marks.length - 1;
1723
+ while (i >= 0 && cache.marks[i]!.tokenCount > tokenCount) i--;
1724
+ if (i < 0) return undefined;
1725
+ const mark = cache.marks[i]!;
1726
+ return {
1727
+ ...cache,
1728
+ text: cache.text.slice(0, mark.textEnd),
1729
+ tokenCount: mark.tokenCount,
1730
+ lines: cache.lines.slice(0, mark.lineEnd),
1731
+ marks: cache.marks.slice(0, i),
1732
+ };
1642
1733
  }
1643
1734
  /**
1644
1735
  * Per-token row cache for the *unfrozen tail* (PoC H). The tail re-lexes every
@@ -1679,6 +1770,25 @@ interface TailRowCache extends RenderSignature {
1679
1770
  // type of token[i+1] when the rows were produced (blank/spacing gate).
1680
1771
  nextTypes: (string | undefined)[];
1681
1772
  }
1773
+ interface ListItemRowsCache {
1774
+ signature: RenderSignature;
1775
+ width: number;
1776
+ themeEpoch: number;
1777
+ ordered: boolean;
1778
+ start: number | undefined;
1779
+ loose: boolean;
1780
+ raws: string[];
1781
+ rows: RenderedLine[][];
1782
+ }
1783
+
1784
+ interface StreamingListCache {
1785
+ text: string;
1786
+ before: Token[];
1787
+ start: number;
1788
+ tailStart: number;
1789
+ stableCount: number;
1790
+ list: Tokens.List;
1791
+ }
1682
1792
  /**
1683
1793
  * Mutable per-token record collector passed to #renderContentLines while
1684
1794
  * rendering the streaming tail. The render loop fills `raws`/`nextTypes`
@@ -1691,6 +1801,17 @@ interface TailRenderRecorder {
1691
1801
  raws: (string | undefined)[];
1692
1802
  nextTypes: (string | undefined)[];
1693
1803
  }
1804
+ /**
1805
+ * The frozen streaming prefix as it stood before a rewind whose only cause was
1806
+ * a closer on the still-growing last line, with the row caches keyed on it.
1807
+ */
1808
+ interface RewoundPrefix {
1809
+ text: string;
1810
+ tokens: Token[];
1811
+ openers: readonly PrefixOpener[];
1812
+ lineCache: StreamPrefixLineCache | undefined;
1813
+ tailRowCache: TailRowCache | undefined;
1814
+ }
1694
1815
  interface StreamingHighlightCache extends RenderSignature {
1695
1816
  lang: string | undefined;
1696
1817
  text: string;
@@ -1719,6 +1840,8 @@ export class Markdown implements Component {
1719
1840
  #paddingY: number; // Top/bottom padding
1720
1841
  #defaultTextStyle?: DefaultTextStyle;
1721
1842
  #theme: MarkdownTheme;
1843
+ #symbols: SymbolTheme;
1844
+ #symbolsProbe: string;
1722
1845
  #defaultStylePrefix?: string;
1723
1846
  /** Number of spaces used to indent code block content. */
1724
1847
  #codeBlockIndent: number;
@@ -1741,6 +1864,20 @@ export class Markdown implements Component {
1741
1864
  #streamPrefixText?: string;
1742
1865
  #streamPrefixTokens?: Token[];
1743
1866
  #streamPrefixLineCache?: StreamPrefixLineCache;
1867
+ // The first display-math opener of each kind in the frozen prefix whose
1868
+ // block an append could still close. The prefix lexes them as the text
1869
+ // stands, which stays right until a tail line could close one of them
1870
+ // (mathBlockCloserIndex). Such a line turns the text from its kind's opener
1871
+ // on into one math block, so the prefix goes back to the boundary in front
1872
+ // of that opener, keeps the openers in front of it, and the rest is re-lexed.
1873
+ #streamPrefixOpeners: readonly PrefixOpener[] = NO_OPENERS;
1874
+ // The frozen prefix's leading run in front of the first of those openers:
1875
+ // no append can change its tokens, so getLastRenderStableText publishes it.
1876
+ #streamSettledText?: string;
1877
+ // The prefix before a rewind that only a closer on the still-growing last
1878
+ // line caused. Once a later text holds no closer line after it, that
1879
+ // prefix is right again, so #lexTokens puts it back.
1880
+ #streamRewound?: RewoundPrefix;
1744
1881
  // Guard-scan memo (PoC C): the ref-def/CR verdict with the exact text
1745
1882
  // length it was checked on. Reuse is sound only while setText has been
1746
1883
  // append-only since (tracked via the startsWith that setText performs): a
@@ -1765,6 +1902,8 @@ export class Markdown implements Component {
1765
1902
  // the blank-replacement branch of setText and the fallback branch of
1766
1903
  // #lexTokens.
1767
1904
  #tailRowCache?: TailRowCache;
1905
+ #listItemRowsCache?: ListItemRowsCache;
1906
+ #streamingListCache?: StreamingListCache;
1768
1907
  // True while #renderStreamingContentLines renders the frozen token range:
1769
1908
  // frozen code blocks highlight even in transient mode so their bytes match
1770
1909
  // the finalized render (they render once into the prefix line cache, so
@@ -1800,6 +1939,9 @@ export class Markdown implements Component {
1800
1939
  this.#paddingX = paddingX;
1801
1940
  this.#paddingY = paddingY;
1802
1941
  this.#theme = theme;
1942
+ this.#symbols = theme.symbols ?? getSymbolTheme();
1943
+ const { quoteBorder, hrChar, colorSwatch, table } = this.#symbols;
1944
+ this.#symbolsProbe = theme.symbols ? "" : [quoteBorder, hrChar, colorSwatch, ...Object.values(table)].join("");
1803
1945
  this.#defaultTextStyle = defaultTextStyle;
1804
1946
  this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
1805
1947
  }
@@ -1854,8 +1996,13 @@ export class Markdown implements Component {
1854
1996
  if (text === this.#text) return false;
1855
1997
  if (!text.startsWith(this.#text)) {
1856
1998
  // Non-append edit: the previous frame's guard verdict cannot be
1857
- // reused — the checked region may have changed anywhere.
1999
+ // reused — the checked region may have changed anywhere. The frozen
2000
+ // prefix, or one a rewind set aside, may still start the new text,
2001
+ // but the line after it was replaced, and a boundary holds only
2002
+ // while that line starts a block of its own.
1858
2003
  this.#appendOnlySinceLastScan = false;
2004
+ this.#dropStreamPrefix();
2005
+ this.#streamRewound = undefined;
1859
2006
  }
1860
2007
  this.#text = text;
1861
2008
  if (!text.trim()) {
@@ -1863,6 +2010,7 @@ export class Markdown implements Component {
1863
2010
  // the non-append edit, so drop the frozen stream state here or it
1864
2011
  // outlives the content it indexed.
1865
2012
  this.#dropStreamPrefix();
2013
+ this.#listItemRowsCache = undefined;
1866
2014
  // B+: the captured fast-path rows index the replaced content — drop
1867
2015
  // the recipe so a fresh stream cannot splice onto stale rows.
1868
2016
  this.#fastTail = undefined;
@@ -1877,6 +2025,14 @@ export class Markdown implements Component {
1877
2025
  this.#cachedLines = undefined;
1878
2026
  }
1879
2027
 
2028
+ releaseRenderCaches(): void {
2029
+ this.invalidate();
2030
+ this.#dropStreamPrefix();
2031
+ this.#streamingHighlightCache = undefined;
2032
+ this.#fastTail = undefined;
2033
+ this.#lastTailCapture = undefined;
2034
+ }
2035
+
1880
2036
  /**
1881
2037
  * The unrendered source as an `md` node, `stream` while the streaming
1882
2038
  * (transient) cache is on. Append-only growth yields a fresh node of the
@@ -1896,11 +2052,13 @@ export class Markdown implements Component {
1896
2052
 
1897
2053
  /**
1898
2054
  * Width-independent source prefix of the last render ending at a frozen
1899
- * Markdown block boundary. Only meaningful while streaming (transient
1900
- * render cache on); grows monotonically under append-only `setText`.
2055
+ * Markdown block boundary that no append can move: it stops in front of a
2056
+ * display-math opener whose block an append could still close. Only
2057
+ * meaningful while streaming (transient render cache on); grows
2058
+ * monotonically under append-only `setText`.
1901
2059
  */
1902
2060
  getLastRenderStableText(): string {
1903
- return this.#transientRenderCache ? (this.#streamPrefixText ?? "") : "";
2061
+ return this.#transientRenderCache ? (this.#streamSettledText ?? "") : "";
1904
2062
  }
1905
2063
 
1906
2064
  get transientRenderCache(): boolean {
@@ -1930,7 +2088,10 @@ export class Markdown implements Component {
1930
2088
  // render consumes it (see #lexTokens / the L2 hit path).
1931
2089
  this.#streamPrefixLineCache = undefined;
1932
2090
  this.#tailRowCache = undefined;
2091
+ this.#listItemRowsCache = undefined;
1933
2092
  this.#streamingHighlightCache = undefined;
2093
+ this.#streamingListCache = undefined;
2094
+ this.#streamRewound = undefined;
1934
2095
  }
1935
2096
  this.invalidate();
1936
2097
  }
@@ -1942,15 +2103,25 @@ export class Markdown implements Component {
1942
2103
  // raw-span offsets). Every fallback is correctness-preserving — only speed
1943
2104
  // differs; the render loop sees the identical token list either way.
1944
2105
  #lexTokens(text: string): Token[] {
1945
- // When a frozen prefix exists, it was already verified ref-def-free when
1946
- // frozen (#freezeStablePrefix only runs when canStream was true). The prefix
1947
- // ends at a "\n\n" block boundary (stableBlockBoundary), so the tail starts
1948
- // at a fresh line — scanning only the tail for ref defs is sufficient and
1949
- // avoids re-scanning the grown prefix every frame (O(n²) → O(n) overall).
2106
+ // A prefix is frozen only while canStream holds and its lex registered no
2107
+ // definitions, and it ends at a "\n\n" block boundary (stableBlockBoundary),
2108
+ // so the tail starts at a fresh line. The scan below then catches a
2109
+ // top-level definition or CR in the new text and the tail lex's links catch
2110
+ // a nested one, so the grown prefix is never re-scanned (O(n²) → O(n)).
2111
+ if (this.#streamRewound !== undefined) this.#restoreRewoundPrefix(text);
2112
+ const frozenText = this.#streamPrefixText;
2113
+ const frozenTokens = this.#streamPrefixTokens;
2114
+ const grewPastPrefix = frozenText !== undefined && text.length > frozenText.length && text.startsWith(frozenText);
2115
+ // A tail line that could close a display-math block the prefix holds open
2116
+ // makes the text from that block's opener on one `math` token, so the
2117
+ // prefix goes back to the boundary in front of the opener, and the rest
2118
+ // is lexed again. The check reads only the tail, which is lexed anyway.
2119
+ if (grewPastPrefix && frozenTokens !== undefined && this.#streamPrefixOpeners.length > 0) {
2120
+ this.#rewindForCloser(text, frozenText, frozenTokens);
2121
+ }
1950
2122
  const prefix = this.#streamPrefixText;
1951
2123
  const prefixTokens = this.#streamPrefixTokens;
1952
- const hasPrefix =
1953
- prefix !== undefined && prefixTokens !== undefined && text.length > prefix.length && text.startsWith(prefix);
2124
+ const hasPrefix = grewPastPrefix && prefix !== undefined && prefixTokens !== undefined;
1954
2125
  const refDefText = hasPrefix ? text.slice(prefix.length) : text;
1955
2126
  // Guard-scan memo (PoC C): while setText has been append-only and the
1956
2127
  // grown delta introduces no "[", "]", ":", "\n" or "\r", the previous
@@ -1996,37 +2167,201 @@ export class Markdown implements Component {
1996
2167
  }
1997
2168
  this.#lastScanLength = text.length;
1998
2169
  this.#lastScanCanStream = canStream;
2170
+ if (!canStream) this.#listItemRowsCache = undefined;
1999
2171
  this.#lastScanValid = true;
2000
2172
  this.#appendOnlySinceLastScan = true;
2173
+ if (canStream && retainPrefix) {
2174
+ const tokens = this.#resumeStreamingList(text);
2175
+ if (tokens !== undefined) {
2176
+ this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
2177
+ this.#checkpointStreamingList(text, tokens);
2178
+ return tokens;
2179
+ }
2180
+ }
2001
2181
  if (canStream && hasPrefix) {
2002
2182
  const tailTokens = lexDocument(refDefText);
2003
- const tokens = [...prefixTokens, ...tailTokens];
2004
- if (retainPrefix) this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
2005
- else this.#dropStreamPrefix();
2006
- return tokens;
2183
+ // A definition at any nesting depth registers for the whole document
2184
+ // and can resolve a reference in the frozen prefix, which was lexed
2185
+ // without it, so any definition in the tail sends the text to a full lex.
2186
+ if (Object.keys(tailTokens.links).length === 0) {
2187
+ const tokens = [...prefixTokens, ...tailTokens];
2188
+ if (retainPrefix) this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
2189
+ else this.#dropStreamPrefix();
2190
+ if (retainPrefix) this.#checkpointStreamingList(text, tokens);
2191
+ return tokens;
2192
+ }
2007
2193
  }
2008
2194
  const tokens = lexDocument(text);
2009
- if (canStream && retainPrefix) {
2195
+ const hasReferenceDefinitions = Object.keys(tokens.links).length > 0;
2196
+ if (hasReferenceDefinitions) this.#lastScanCanStream = false;
2197
+ // A definition frozen into the prefix would be missing from every later
2198
+ // tail lex, so a full lex that registered any definition freezes nothing.
2199
+ if (canStream && retainPrefix && !hasReferenceDefinitions) {
2010
2200
  this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
2201
+ this.#checkpointStreamingList(text, tokens);
2011
2202
  } else {
2012
2203
  this.#dropStreamPrefix();
2013
2204
  }
2014
2205
  return tokens;
2015
2206
  }
2016
2207
 
2017
- /** Drop the frozen lex prefix and the transient row caches keyed on it. */
2208
+ #resumeStreamingList(text: string): Token[] | undefined {
2209
+ const cached = this.#streamingListCache;
2210
+ if (cached === undefined || text.length <= cached.text.length || !text.startsWith(cached.text)) return undefined;
2211
+ const tail = lexDocument(text.slice(cached.tailStart));
2212
+ if (Object.keys(tail.links).length > 0) return undefined;
2213
+ const first = tail[0];
2214
+ if (first?.type !== "list") return undefined;
2215
+ const list = first as Tokens.List;
2216
+ if (!listMayContinueAt(list.raw, 0, cached.list.raw)) return undefined;
2217
+ const loose = cached.list.loose || list.loose;
2218
+ const completed = cached.list.items.slice(0, cached.stableCount);
2219
+ const items = [...completed, ...list.items];
2220
+ const merged: Tokens.List = {
2221
+ ...cached.list,
2222
+ raw: text.slice(cached.start, cached.tailStart + list.raw.length),
2223
+ loose,
2224
+ items: loose ? items.map(loosenListItem) : items,
2225
+ };
2226
+ return [...cached.before, merged, ...tail.slice(1)];
2227
+ }
2228
+
2229
+ #checkpointStreamingList(text: string, tokens: Token[]): void {
2230
+ const previous = this.#streamingListCache;
2231
+ this.#streamingListCache = undefined;
2232
+ let index = tokens.length - 1;
2233
+ while (tokens[index]?.type === "space") index--;
2234
+ const token = tokens[index];
2235
+ if (token?.type !== "list") return;
2236
+ const list = token as Tokens.List;
2237
+ let start = 0;
2238
+ for (let i = 0; i < index; i++) start += tokens[i]!.raw.length;
2239
+ // Late block delimiters can absorb an unfrozen preceding region. Bare
2240
+ // environments can also absorb list items through an equation prefix.
2241
+ if (start !== (this.#streamPrefixText?.length ?? 0) || BARE_ENV_BEGIN.test(list.raw)) return;
2242
+ const canResume = previous !== undefined && previous.start === start && text.startsWith(previous.text);
2243
+ let cursor = canResume ? previous.tailStart : start;
2244
+ let tailStart = start;
2245
+ let stableCount = 0;
2246
+ const indent = /^ */.exec(list.raw)![0].length;
2247
+ for (let i = canResume ? previous.stableCount : 0; i < list.items.length; i++) {
2248
+ const item = list.items[i]!;
2249
+ const position = text.indexOf(item.raw, cursor);
2250
+ if (position < cursor || position >= start + list.raw.length) return;
2251
+ // List continuation uses the first item's indentation. Resume only at
2252
+ // an item with that same indentation so the lexer sees the same context.
2253
+ if (/^ */.exec(item.raw)![0].length === indent) {
2254
+ tailStart = position;
2255
+ stableCount = i;
2256
+ }
2257
+ cursor = position + item.raw.length;
2258
+ }
2259
+ this.#streamingListCache = { text, before: tokens.slice(0, index), start, tailStart, stableCount, list };
2260
+ }
2261
+
2262
+ /**
2263
+ * Drop the frozen lex prefix and the transient row caches keyed on it. A
2264
+ * prefix set aside for #restoreRewoundPrefix checks itself against each
2265
+ * later text, so it stays until a non-append edit.
2266
+ */
2018
2267
  #dropStreamPrefix(): void {
2019
2268
  this.#streamPrefixText = undefined;
2020
2269
  this.#streamPrefixTokens = undefined;
2270
+ this.#streamPrefixOpeners = NO_OPENERS;
2271
+ this.#streamSettledText = undefined;
2021
2272
  this.#streamPrefixLineCache = undefined;
2022
2273
  this.#tailRowCache = undefined;
2274
+ this.#listItemRowsCache = undefined;
2275
+ this.#streamingListCache = undefined;
2276
+ }
2277
+
2278
+ /**
2279
+ * Rewind the frozen prefix `prefix` (its tokens `tokens`) for a line of the
2280
+ * tail after it that could close one of its openers: back to the boundary
2281
+ * in front of the first opener whose closer a tail line holds. When that
2282
+ * closer, and no other, is on the still-growing last line, the next chunk
2283
+ * can turn the line into text again, so the prefix is set aside for
2284
+ * #restoreRewoundPrefix.
2285
+ */
2286
+ #rewindForCloser(text: string, prefix: string, tokens: Token[]): void {
2287
+ let target: PrefixOpener | undefined;
2288
+ let lastLineOnly = false;
2289
+ for (const opener of this.#streamPrefixOpeners) {
2290
+ const closer = mathBlockCloserIndex(text, prefix.length, opener.kind);
2291
+ if (closer === undefined) continue;
2292
+ lastLineOnly = target === undefined && !text.includes("\n", closer);
2293
+ target ??= opener;
2294
+ }
2295
+ if (target === undefined) return;
2296
+ this.#streamRewound = lastLineOnly
2297
+ ? {
2298
+ text: prefix,
2299
+ tokens,
2300
+ openers: this.#streamPrefixOpeners,
2301
+ lineCache: this.#streamPrefixLineCache,
2302
+ tailRowCache: this.#tailRowCache,
2303
+ }
2304
+ : undefined;
2305
+ this.#rewindStreamPrefix(target.rewindEnd, target.rewindCount);
2306
+ }
2307
+
2308
+ /**
2309
+ * Cut the frozen prefix back to the boundary `end` characters and `count`
2310
+ * tokens in, keeping the openers in front of it and the rows of the prefix
2311
+ * that remains, and dropping the tail row cache keyed on the old prefix.
2312
+ */
2313
+ #rewindStreamPrefix(end: number, count: number): void {
2314
+ this.#streamPrefixLineCache = rewoundLineCache(this.#streamPrefixLineCache, count);
2315
+ this.#tailRowCache = undefined;
2316
+ this.#listItemRowsCache = undefined;
2317
+ this.#streamingListCache = undefined;
2318
+ if (count === 0) {
2319
+ this.#streamPrefixText = undefined;
2320
+ this.#streamPrefixTokens = undefined;
2321
+ this.#streamPrefixOpeners = NO_OPENERS;
2322
+ return;
2323
+ }
2324
+ this.#streamPrefixText = this.#streamPrefixText?.slice(0, end);
2325
+ this.#streamPrefixTokens = this.#streamPrefixTokens?.slice(0, count);
2326
+ this.#streamPrefixOpeners = this.#streamPrefixOpeners.filter(opener => opener.at < end);
2327
+ }
2328
+
2329
+ /**
2330
+ * Put back the prefix #rewindForCloser set aside once no line of `text`
2331
+ * after it could close one of its openers: the last line it rewound for
2332
+ * has grown into text, so the prefix lexes as the text stands again. A
2333
+ * closer line that has ended stays one under every append, so a prefix
2334
+ * with one after it can never come back.
2335
+ */
2336
+ #restoreRewoundPrefix(text: string): void {
2337
+ const rewound = this.#streamRewound;
2338
+ if (rewound === undefined) return;
2339
+ if (text.length <= rewound.text.length || !text.startsWith(rewound.text)) {
2340
+ this.#streamRewound = undefined;
2341
+ return;
2342
+ }
2343
+ for (const opener of rewound.openers) {
2344
+ const closer = mathBlockCloserIndex(text, rewound.text.length, opener.kind);
2345
+ if (closer === undefined) continue;
2346
+ if (text.includes("\n", closer)) this.#streamRewound = undefined;
2347
+ return;
2348
+ }
2349
+ this.#streamRewound = undefined;
2350
+ this.#streamPrefixText = rewound.text;
2351
+ this.#streamPrefixTokens = rewound.tokens;
2352
+ this.#streamPrefixOpeners = rewound.openers;
2353
+ this.#streamPrefixLineCache = rewound.lineCache;
2354
+ this.#tailRowCache = rewound.tailRowCache;
2355
+ this.#listItemRowsCache = undefined;
2356
+ this.#streamingListCache = undefined;
2023
2357
  }
2024
2358
 
2025
2359
  // Freeze the largest run of leading blocks that end on a hard "\n\n" boundary
2026
- // (complete and immutable under append-only growth) so the next streaming
2027
- // render re-lexes only the unfrozen tail. Caller guarantees no CR / no
2028
- // reference definitions, so each token's `raw` is a verbatim slice of `text`
2029
- // and the summed offsets address `text` exactly.
2360
+ // (complete, and immutable under append-only growth until a tail line could
2361
+ // close a display-math block it holds open) so the next streaming render
2362
+ // re-lexes only the unfrozen tail. Caller guarantees no CR / no reference
2363
+ // definitions, so each token's `raw` is a verbatim slice of `text` and the
2364
+ // summed offsets address `text` exactly.
2030
2365
  #freezeStablePrefix(text: string, tokens: Token[], opts: { preserveExisting: boolean }): void {
2031
2366
  // On the streaming-concat path (preserveExisting), tokens[0..prefixCount)
2032
2367
  // ARE the previously frozen prefix and the text above it is byte-
@@ -2037,19 +2372,50 @@ export class Markdown implements Component {
2037
2372
  // path (preserveExisting: false) re-derives the whole stream, so it
2038
2373
  // must keep walking from 0.
2039
2374
  const skipPrefix = opts.preserveExisting ? (this.#streamPrefixTokens?.length ?? 0) : 0;
2040
- const frozen = stableBlockBoundary(
2041
- text,
2042
- skipPrefix > 0 ? (this.#streamPrefixText?.length ?? 0) : 0,
2043
- tokens,
2044
- skipPrefix,
2045
- );
2046
- if (frozen.count > 0) {
2047
- this.#streamPrefixText = text.slice(0, frozen.end);
2048
- this.#streamPrefixTokens = tokens.slice(0, frozen.count);
2375
+ const base = skipPrefix > 0 ? (this.#streamPrefixText?.length ?? 0) : 0;
2376
+ const frozen = stableBlockBoundary(text, base, tokens, { startIndex: skipPrefix });
2377
+ if (frozen.count === 0) {
2378
+ if (!opts.preserveExisting) this.#dropStreamPrefix();
2049
2379
  return;
2050
2380
  }
2051
-
2052
- if (!opts.preserveExisting) this.#dropStreamPrefix();
2381
+ let openers = skipPrefix > 0 ? this.#streamPrefixOpeners : NO_OPENERS;
2382
+ if (openers.length === 0) {
2383
+ // The prefix so far is all settled, so the settled part grows up to the
2384
+ // last boundary in front of the first opener an append could close.
2385
+ const settled = stableBlockBoundary(text, base, tokens, { startIndex: skipPrefix, settle: true });
2386
+ if (settled.count > 0) {
2387
+ this.#streamSettledText = text.slice(0, settled.end);
2388
+ } else if (skipPrefix === 0) {
2389
+ this.#streamSettledText = undefined;
2390
+ }
2391
+ }
2392
+ // Record the first opener of each kind in the newly frozen tokens that an
2393
+ // append could still close, with the boundary a closer of its kind
2394
+ // rewinds to: the last one in front of it, or the prefix end when the new
2395
+ // tokens hold none. It is never in front of an earlier opener's, so the
2396
+ // first opener whose closer a tail line holds has the earliest boundary.
2397
+ let pos = base;
2398
+ for (let i = skipPrefix; i < frozen.count && openers.length < 2; i++) {
2399
+ const token = tokens[i];
2400
+ if (token.type !== "math") {
2401
+ const kind = mathBlockOpenerAt(text, pos);
2402
+ if (kind !== undefined && !openers.some(opener => opener.kind === kind) && mathBlockMayCloseAt(text, pos)) {
2403
+ const boundary = stableBlockBoundary(text, base, tokens, { startIndex: skipPrefix, endIndex: i });
2404
+ let rewindEnd = boundary.count > 0 ? boundary.end : base;
2405
+ let rewindCount = boundary.count > 0 ? boundary.count : skipPrefix;
2406
+ const earlier = openers.at(-1);
2407
+ if (earlier !== undefined && earlier.rewindEnd > rewindEnd) {
2408
+ rewindEnd = earlier.rewindEnd;
2409
+ rewindCount = earlier.rewindCount;
2410
+ }
2411
+ openers = [...openers, { kind, at: pos, rewindEnd, rewindCount }];
2412
+ }
2413
+ }
2414
+ pos += token.raw.length;
2415
+ }
2416
+ this.#streamPrefixText = text.slice(0, frozen.end);
2417
+ this.#streamPrefixTokens = tokens.slice(0, frozen.count);
2418
+ this.#streamPrefixOpeners = openers;
2053
2419
  }
2054
2420
 
2055
2421
  render(width: number): readonly string[] {
@@ -2131,11 +2497,16 @@ export class Markdown implements Component {
2131
2497
  (!markerDelta && recipe.rowRaw.endsWith("$") && /^[0-9]/.test(deltaTabs));
2132
2498
  // A delta opening a pairing char when the captured row ENDS with the
2133
2499
  // same char can re-pair across the seam: cold lex of the joined run
2134
- // makes ONE token (x *a**b* → em("a**b")), the splice keeps two.
2135
- // An image marker (`x!` + `[a](u)`) re-pairs the same way.
2500
+ // makes ONE token (x *a**b* → em("a**b")), the splice keeps two. So
2501
+ // can an emphasis delimiter in the delta and the same char anywhere
2502
+ // in the row, once the joined text makes the row's one flanking
2503
+ // (`a_{3` + `} + b_{` renders `{3} + b` emphasized): the delta lexed
2504
+ // alone has nothing to pair with. An image marker (`x!` + `[a](u)`)
2505
+ // re-pairs the same way.
2136
2506
  const pairSeamHazard =
2137
2507
  markerDelta &&
2138
2508
  ((/^[*~`]/.test(deltaTabs) && /[*~`]$/.test(recipe.rowRaw)) ||
2509
+ FAST_EMPHASIS_DELIMITERS.some(c => deltaTabs.includes(c) && recipe.rowRaw.includes(c)) ||
2139
2510
  // "x!" + "[a](u)": cold lexes text("x") + image(alt); the splice would
2140
2511
  // keep "x!" + a styled link byte-run.
2141
2512
  (deltaTabs.startsWith("[") && recipe.rowRaw.endsWith("!")));
@@ -2161,7 +2532,7 @@ export class Markdown implements Component {
2161
2532
  : renderTextWithSwatches(
2162
2533
  normalizeHtmlEntitiesForTerminal(deltaTabs),
2163
2534
  applyText,
2164
- this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH,
2535
+ this.#symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH,
2165
2536
  ));
2166
2537
  const wrapped = wrapTextWithAnsi(grown, contentWidth);
2167
2538
  const fastPaddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
@@ -2214,8 +2585,14 @@ export class Markdown implements Component {
2214
2585
  // removed): the guard-scan memo's checked region is no longer
2215
2586
  // byte-identical, and a cached false verdict may have been based
2216
2587
  // on the very CR/ref-def line that was deleted. Invalidate so the
2217
- // next #lexTokens re-derives on the repaired buffer.
2588
+ // next #lexTokens re-derives on the repaired buffer. The text past
2589
+ // the frozen prefix is no append of what was frozen against either
2590
+ // (a fence line right after the prefix leaves the tail opening on
2591
+ // blank lines a one-pass lex joins to the ones above), so drop it,
2592
+ // and any prefix a rewind set aside.
2218
2593
  this.#lastScanValid = false;
2594
+ this.#dropStreamPrefix();
2595
+ this.#streamRewound = undefined;
2219
2596
  }
2220
2597
 
2221
2598
  // L2: module-level LRU — survives component disposal/recreation across
@@ -2327,6 +2704,7 @@ export class Markdown implements Component {
2327
2704
  textSizing: TERMINAL.textSizing,
2328
2705
  bgColorProbe,
2329
2706
  headingProbe,
2707
+ symbolsProbe: this.#symbolsProbe,
2330
2708
  };
2331
2709
  }
2332
2710
  // All-primitive signature — compare via the canonical render-cache encoding.
@@ -2335,7 +2713,7 @@ export class Markdown implements Component {
2335
2713
  }
2336
2714
 
2337
2715
  #renderCacheKey(normalizedText: string, signature: RenderSignature): string {
2338
- return `${normalizedText}\x00${signature.width}\x00${signature.paddingX}\x00${signature.paddingY}\x00${signature.codeBlockIndent}\x00${signature.themeId}\x00${signature.defaultTextStyleId}\x00${signature.imageProtocol}\x00${signature.hyperlinks ? 1 : 0}\x00${signature.textSizing ? 1 : 0}\x00${signature.bgColorProbe}\x00${signature.headingProbe}`;
2716
+ return `${normalizedText}\x00${signature.width}\x00${signature.paddingX}\x00${signature.paddingY}\x00${signature.codeBlockIndent}\x00${signature.themeId}\x00${signature.defaultTextStyleId}\x00${signature.imageProtocol}\x00${signature.hyperlinks ? 1 : 0}\x00${signature.textSizing ? 1 : 0}\x00${signature.bgColorProbe}\x00${signature.headingProbe}\x00${signature.symbolsProbe}`;
2339
2717
  }
2340
2718
 
2341
2719
  #renderStreamingContentLines(
@@ -2344,6 +2722,7 @@ export class Markdown implements Component {
2344
2722
  signature: RenderSignature,
2345
2723
  contentWidth: number,
2346
2724
  ): string[] {
2725
+ if (tokens[tokens.length - 1]?.type !== "list") this.#listItemRowsCache = undefined;
2347
2726
  const stableText = this.#streamPrefixText;
2348
2727
  const stableTokenCount = this.#streamPrefixTokens?.length ?? 0;
2349
2728
  if (stableText === undefined || stableTokenCount === 0 || !normalizedText.startsWith(stableText)) {
@@ -2356,10 +2735,15 @@ export class Markdown implements Component {
2356
2735
  // of prefix + tail, so no array handed to a caller is ever mutated.
2357
2736
  const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, stableText, signature);
2358
2737
  let prefixLines: string[] = [];
2738
+ let marks: PrefixMark[] = [];
2359
2739
  let renderedUntil = 0;
2360
2740
  if (reusablePrefix && reusablePrefix.tokenCount <= stableTokenCount) {
2361
2741
  prefixLines = reusablePrefix.lines;
2742
+ marks = reusablePrefix.marks;
2362
2743
  renderedUntil = reusablePrefix.tokenCount;
2744
+ if (renderedUntil < stableTokenCount) {
2745
+ marks.push({ textEnd: reusablePrefix.text.length, tokenCount: renderedUntil, lineEnd: prefixLines.length });
2746
+ }
2363
2747
  }
2364
2748
 
2365
2749
  if (renderedUntil < stableTokenCount) {
@@ -2382,6 +2766,7 @@ export class Markdown implements Component {
2382
2766
  text: stableText,
2383
2767
  tokenCount: stableTokenCount,
2384
2768
  lines: prefixLines,
2769
+ marks,
2385
2770
  };
2386
2771
 
2387
2772
  if (renderedUntil >= tokens.length) return prefixLines.slice();
@@ -2431,11 +2816,11 @@ export class Markdown implements Component {
2431
2816
  }
2432
2817
 
2433
2818
  const recorder: TailRenderRecorder = {
2434
- // [suppressed] render-cache length preallocation
2819
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2435
2820
  rows: new Array(tokens.length - spliceEnd).fill(undefined),
2436
- // [suppressed] render-cache length preallocation
2821
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2437
2822
  raws: new Array(tokens.length - spliceEnd).fill(undefined),
2438
- // [suppressed] render-cache length preallocation
2823
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2439
2824
  nextTypes: new Array(tokens.length - spliceEnd).fill(undefined),
2440
2825
  };
2441
2826
  const fresh = this.#renderContentLines(tokens, spliceEnd, tokens.length, contentWidth, signature, recorder);
@@ -2448,11 +2833,11 @@ export class Markdown implements Component {
2448
2833
  // `start`), so a mostly-frozen document allocates only for the
2449
2834
  // unfrozen tail instead of the whole token list every frame.
2450
2835
  const tailCount = tokens.length - start;
2451
- // [suppressed] render-cache length preallocation
2836
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2452
2837
  const rows: (readonly string[] | undefined)[] = new Array(tailCount).fill(undefined);
2453
- // [suppressed] render-cache length preallocation
2838
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2454
2839
  const raws: (string | undefined)[] = new Array(tailCount).fill(undefined);
2455
- // [suppressed] render-cache length preallocation
2840
+ // oxlint-disable-next-line unicorn/no-new-array -- render-cache length preallocation
2456
2841
  const nextTypes: (string | undefined)[] = new Array(tailCount).fill(undefined);
2457
2842
  if (cache !== undefined && cache.tokenStart === start) {
2458
2843
  for (let i = start; i < Math.min(cache.cachedThrough, spliceEnd); i++) {
@@ -2914,6 +3299,37 @@ export class Markdown implements Component {
2914
3299
  return {
2915
3300
  applyText: (text: string) => this.#applyDefaultStyle(text),
2916
3301
  stylePrefix: this.#getDefaultStylePrefix(),
3302
+ isDefault: true,
3303
+ };
3304
+ }
3305
+
3306
+ /**
3307
+ * Style context for semantically-themed spans (headings, links) under a
3308
+ * `proseFg` default style: plain runs keep the prose foreground, but the
3309
+ * span's own `md*` theme color must stay the innermost (winning) SGR, so
3310
+ * `applyText` keeps decorations (bold/italic/…) while dropping the
3311
+ * prose color. Non-`proseFg` styles and caller-supplied contexts pass
3312
+ * through unchanged.
3313
+ */
3314
+ #proseFgExemptContext(styleContext: InlineStyleContext | undefined): InlineStyleContext | undefined {
3315
+ // A caller-supplied context (blockquote identity styling) already governs
3316
+ // this subtree and wins; exemption applies only to the component's own
3317
+ // default style (undefined resolves to it downstream), and stays applied
3318
+ // through nested recursion.
3319
+ if (styleContext && !styleContext.isDefault) return styleContext;
3320
+ if (!this.#defaultTextStyle?.color || !this.#defaultTextStyle?.proseFg) return styleContext;
3321
+ const applyExceptColor = (text: string): string => {
3322
+ let styled = text;
3323
+ if (this.#defaultTextStyle?.bold) styled = this.#theme.bold(styled);
3324
+ if (this.#defaultTextStyle?.italic) styled = this.#theme.italic(styled);
3325
+ if (this.#defaultTextStyle?.strikethrough) styled = this.#theme.strikethrough(styled);
3326
+ if (this.#defaultTextStyle?.underline) styled = this.#theme.underline(styled);
3327
+ return styled;
3328
+ };
3329
+ return {
3330
+ applyText: applyExceptColor,
3331
+ stylePrefix: this.#getStylePrefix(applyExceptColor),
3332
+ isDefault: true,
2917
3333
  };
2918
3334
  }
2919
3335
 
@@ -2945,7 +3361,7 @@ export class Markdown implements Component {
2945
3361
  case "heading": {
2946
3362
  const headingLevel = token.depth;
2947
3363
  const headingPrefix = `${"#".repeat(headingLevel)} `;
2948
- const headingText = this.#renderInlineTokens(token.tokens || [], styleContext);
3364
+ const headingText = this.#renderInlineTokens(token.tokens || [], this.#proseFgExemptContext(styleContext));
2949
3365
  const headingPlainText = plainInlineTokens(token.tokens || []);
2950
3366
  let styledHeading: string;
2951
3367
  if (headingLevel === 1 && TERMINAL.textSizing) {
@@ -3108,7 +3524,7 @@ export class Markdown implements Component {
3108
3524
 
3109
3525
  /** Render a horizontal rule line themed to `width`, matching `sourceChar` when given. */
3110
3526
  #renderHrLine(width: number, sourceChar = ""): string {
3111
- const fillChar = getHrChar(sourceChar, this.#theme.symbols.hrChar);
3527
+ const fillChar = getHrChar(sourceChar, this.#symbols.hrChar);
3112
3528
  return this.#theme.hr(fillChar.repeat(Math.min(width, 80)));
3113
3529
  }
3114
3530
 
@@ -3140,7 +3556,7 @@ export class Markdown implements Component {
3140
3556
  } else {
3141
3557
  const styledLine = applyQuoteStyle(quoteLine.text);
3142
3558
  for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
3143
- lines.push(renderedLine(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine));
3559
+ lines.push(renderedLine(this.#theme.quoteBorder(`${this.#symbols.quoteBorder} `) + wrappedLine));
3144
3560
  }
3145
3561
  }
3146
3562
  }
@@ -3201,7 +3617,10 @@ export class Markdown implements Component {
3201
3617
  const segments: string[] = text.split("\n");
3202
3618
  return segments.map((segment: string) => (segment === "" ? "" : applyText(segment))).join("\n");
3203
3619
  };
3204
- const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
3620
+ const swatchGlyph = this.#symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
3621
+ // Set by a line break: the next line's own leading whitespace is dropped.
3622
+ // Every token consumes it, so the space after a styled span that starts
3623
+ // the line stays; a token that renders nothing passes it on.
3205
3624
  let trimLeadingWhitespace = false;
3206
3625
  const htmlState = createHtmlNormalizationState();
3207
3626
  const markHtmlItemWhenContent = (text: string): void => {
@@ -3209,6 +3628,8 @@ export class Markdown implements Component {
3209
3628
  };
3210
3629
 
3211
3630
  for (const token of collapseInlineHtml(tokens)) {
3631
+ const lineStart: boolean = trimLeadingWhitespace;
3632
+ trimLeadingWhitespace = false;
3212
3633
  if (isMathToken(token)) {
3213
3634
  markHtmlItemWhenContent(token.text);
3214
3635
  result += applyTextWithNewlines(renderMathToken(token.text));
@@ -3216,9 +3637,8 @@ export class Markdown implements Component {
3216
3637
  }
3217
3638
  switch (token.type) {
3218
3639
  case "text": {
3219
- const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
3640
+ const rawText = lineStart ? token.text.replace(/^\s+/, "") : token.text;
3220
3641
  const text = normalizeHtmlEntitiesForTerminal(rawText);
3221
- trimLeadingWhitespace = false;
3222
3642
  markHtmlItemWhenContent(text);
3223
3643
  if (token.tokens) markHtmlItemWhenContent(plainInlineTokens(token.tokens));
3224
3644
  // Text tokens in list items can have nested tokens for inline formatting
@@ -3259,7 +3679,10 @@ export class Markdown implements Component {
3259
3679
 
3260
3680
  case "link": {
3261
3681
  markHtmlItemWhenContent(token.text);
3262
- const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
3682
+ const linkText = this.#renderInlineTokens(
3683
+ token.tokens || [],
3684
+ this.#proseFgExemptContext(resolvedStyleContext),
3685
+ );
3263
3686
  const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
3264
3687
  const href = typeof token.href === "string" ? token.href : "";
3265
3688
  const target = (href && this.#theme.resolveLink?.(href)) || href;
@@ -3299,8 +3722,8 @@ export class Markdown implements Component {
3299
3722
  result += applyTextWithNewlines(cleaned);
3300
3723
  if (cleaned.endsWith("\n")) {
3301
3724
  trimLeadingWhitespace = true;
3302
- } else if (cleaned.length > 0) {
3303
- trimLeadingWhitespace = false;
3725
+ } else if (cleaned.length === 0) {
3726
+ trimLeadingWhitespace = lineStart;
3304
3727
  }
3305
3728
  }
3306
3729
  break;
@@ -3308,11 +3731,12 @@ export class Markdown implements Component {
3308
3731
  default:
3309
3732
  // Handle any other inline token types as plain text
3310
3733
  if ("text" in token && typeof token.text === "string") {
3311
- const rawText = trimLeadingWhitespace ? token.text.replace(/^\s+/, "") : token.text;
3734
+ const rawText = lineStart ? token.text.replace(/^\s+/, "") : token.text;
3312
3735
  const text = normalizeHtmlEntitiesForTerminal(rawText);
3313
- trimLeadingWhitespace = false;
3314
3736
  markHtmlItemWhenContent(text);
3315
3737
  result += applyTextWithNewlines(text);
3738
+ } else {
3739
+ trimLeadingWhitespace = lineStart;
3316
3740
  }
3317
3741
  }
3318
3742
  }
@@ -3336,6 +3760,41 @@ export class Markdown implements Component {
3336
3760
  const indent = " ".repeat(depth);
3337
3761
  // Use the list's start property (defaults to 1 for ordered lists)
3338
3762
  const startNumber = token.start ?? 1;
3763
+ const signature =
3764
+ depth === 0 &&
3765
+ styleContext === undefined &&
3766
+ this.#defaultTextStyle === undefined &&
3767
+ this.#transientRenderCache &&
3768
+ !this.#renderingStablePrefix &&
3769
+ this.#lastScanCanStream &&
3770
+ canCacheMarkdownListItems(this.#theme)
3771
+ ? this.#activeRenderSignature
3772
+ : undefined;
3773
+ const themeEpoch = signature === undefined ? undefined : getThemeEpoch();
3774
+ const previous = this.#listItemRowsCache;
3775
+ const reusable =
3776
+ signature !== undefined &&
3777
+ themeEpoch !== undefined &&
3778
+ previous !== undefined &&
3779
+ previous.width === width &&
3780
+ previous.themeEpoch === themeEpoch &&
3781
+ previous.ordered === token.ordered &&
3782
+ previous.start === token.start &&
3783
+ previous.loose === token.loose &&
3784
+ this.#signatureEquals(previous.signature, signature)
3785
+ ? previous
3786
+ : undefined;
3787
+ let reusableCount = 0;
3788
+ if (reusable !== undefined) {
3789
+ while (
3790
+ reusableCount < token.items.length &&
3791
+ token.items[reusableCount]?.raw === reusable.raws[reusableCount]
3792
+ ) {
3793
+ reusableCount++;
3794
+ }
3795
+ }
3796
+ const raws: string[] | undefined = signature === undefined ? undefined : [];
3797
+ const rows: RenderedLine[][] | undefined = signature === undefined ? undefined : [];
3339
3798
  const pushWrapped = (line: RenderedLine, firstPrefix: string, continuationPrefix: string): void => {
3340
3799
  if (line.literalCode) {
3341
3800
  const wrappedLiteralRows = wrapTextWithAnsi(line.text, Math.max(1, width));
@@ -3371,6 +3830,14 @@ export class Markdown implements Component {
3371
3830
 
3372
3831
  for (let i = 0; i < token.items.length; i++) {
3373
3832
  const item = token.items[i];
3833
+ if (i < reusableCount && reusable !== undefined && raws !== undefined && rows !== undefined) {
3834
+ const cached = reusable.rows[i]!;
3835
+ for (const row of cached) lines.push(row);
3836
+ raws.push(item.raw);
3837
+ rows.push(cached);
3838
+ continue;
3839
+ }
3840
+ const rowStart = lines.length;
3374
3841
  const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
3375
3842
  const firstPrefix = indent + this.#theme.listBullet(bullet);
3376
3843
  // Continuation rows align under the item text, so the hang matches the
@@ -3400,6 +3867,22 @@ export class Markdown implements Component {
3400
3867
  } else {
3401
3868
  lines.push(renderedLine(firstPrefix));
3402
3869
  }
3870
+ if (raws !== undefined && rows !== undefined) {
3871
+ raws.push(item.raw);
3872
+ rows.push(lines.slice(rowStart));
3873
+ }
3874
+ }
3875
+ if (signature !== undefined && themeEpoch !== undefined && raws !== undefined && rows !== undefined) {
3876
+ this.#listItemRowsCache = {
3877
+ signature,
3878
+ width,
3879
+ themeEpoch,
3880
+ ordered: token.ordered,
3881
+ start: token.start,
3882
+ loose: token.loose,
3883
+ raws,
3884
+ rows,
3885
+ };
3403
3886
  }
3404
3887
 
3405
3888
  return lines;
@@ -3575,7 +4058,7 @@ export class Markdown implements Component {
3575
4058
  let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
3576
4059
 
3577
4060
  if (minCellsWidth > availableForCells) {
3578
- // [suppressed] column-width allocation
4061
+ // oxlint-disable-next-line unicorn/no-new-array -- column-width allocation
3579
4062
  minColumnWidths = new Array(numCols).fill(1);
3580
4063
  const remaining = availableForCells - numCols;
3581
4064
 
@@ -3642,7 +4125,7 @@ export class Markdown implements Component {
3642
4125
  }
3643
4126
  }
3644
4127
 
3645
- const t = this.#theme.symbols.table;
4128
+ const t = this.#symbols.table;
3646
4129
  const h = t.horizontal;
3647
4130
  const v = t.vertical;
3648
4131