@linxiraos/pi-tui 1.1.27 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (373) hide show
  1. package/CHANGELOG.md +47 -463
  2. package/README.md +7 -5
  3. package/dist/types/app-keybindings.d.ts +1 -1
  4. package/dist/types/apps/cleanse-board.d.ts +1 -1
  5. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  6. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  7. package/dist/types/apps/git/git-tui.d.ts +2 -2
  8. package/dist/types/apps/git/help.d.ts +3 -1
  9. package/dist/types/apps/if-bench-board.d.ts +2 -2
  10. package/dist/types/apps/setup-model-picker.d.ts +1 -1
  11. package/dist/types/autocomplete.d.ts +5 -2
  12. package/dist/types/charts/chart-plan.d.ts +674 -0
  13. package/dist/types/charts/chart-svg.d.ts +59 -0
  14. package/dist/types/charts/table-data.d.ts +175 -0
  15. package/dist/types/chat/assistant-message.d.ts +5 -0
  16. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  17. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  18. package/dist/types/chat/execution-shared.d.ts +6 -5
  19. package/dist/types/chat/fence-figure.d.ts +42 -0
  20. package/dist/types/chat/figure-markdown.d.ts +42 -0
  21. package/dist/types/chat/messages.d.ts +1 -1
  22. package/dist/types/chat/svg-figure.d.ts +33 -0
  23. package/dist/types/chat/svg-source.d.ts +49 -0
  24. package/dist/types/chat/table-chart.d.ts +89 -0
  25. package/dist/types/chat/tool-execution.d.ts +2 -0
  26. package/dist/types/chat/user-message.d.ts +15 -2
  27. package/dist/types/chrome/shared.d.ts +9 -0
  28. package/dist/types/chrome/transcript-container.d.ts +6 -0
  29. package/dist/types/components/box.d.ts +1 -0
  30. package/dist/types/components/disclosure.d.ts +1 -0
  31. package/dist/types/components/editor.d.ts +15 -4
  32. package/dist/types/components/form.d.ts +1 -0
  33. package/dist/types/components/image.d.ts +12 -44
  34. package/dist/types/components/key-value-list.d.ts +1 -0
  35. package/dist/types/components/layout/row.d.ts +1 -0
  36. package/dist/types/components/layout/stack.d.ts +1 -0
  37. package/dist/types/components/markdown.d.ts +36 -3
  38. package/dist/types/components/scroll-view.d.ts +1 -0
  39. package/dist/types/components/section.d.ts +1 -0
  40. package/dist/types/components/table.d.ts +1 -0
  41. package/dist/types/components/text.d.ts +1 -0
  42. package/dist/types/components/tree-view.d.ts +7 -0
  43. package/dist/types/components/truncated-text.d.ts +1 -0
  44. package/dist/types/fuzzy.d.ts +29 -1
  45. package/dist/types/key-hint-format.d.ts +1 -1
  46. package/dist/types/keybinding-matchers.d.ts +3 -0
  47. package/dist/types/keybindings.d.ts +1 -1
  48. package/dist/types/kitty-graphics.d.ts +2 -2
  49. package/dist/types/loop-watchdog.d.ts +8 -7
  50. package/dist/types/multiplexers/cmux.d.ts +16 -0
  51. package/dist/types/multiplexers/herdr.d.ts +26 -0
  52. package/dist/types/multiplexers/notify.d.ts +13 -0
  53. package/dist/types/multiplexers/orca.d.ts +12 -0
  54. package/dist/types/multiplexers/screen.d.ts +9 -0
  55. package/dist/types/multiplexers/tmux.d.ts +29 -0
  56. package/dist/types/multiplexers/types.d.ts +100 -0
  57. package/dist/types/multiplexers/wmux.d.ts +12 -0
  58. package/dist/types/multiplexers/zellij.d.ts +12 -0
  59. package/dist/types/native/backend.d.ts +3 -2
  60. package/dist/types/native/blobs.d.ts +7 -3
  61. package/dist/types/native/encode.d.ts +3 -4
  62. package/dist/types/native/overlay.d.ts +5 -0
  63. package/dist/types/overlays/agent-hub-projection.d.ts +6 -5
  64. package/dist/types/overlays/agent-hub-types.d.ts +9 -0
  65. package/dist/types/overlays/agent-hub.d.ts +2 -0
  66. package/dist/types/overlays/agent-transcript-viewer.d.ts +11 -2
  67. package/dist/types/overlays/agents-hub.d.ts +9 -0
  68. package/dist/types/overlays/annotation-overlay.d.ts +19 -2
  69. package/dist/types/overlays/annotation-types.d.ts +2 -0
  70. package/dist/types/overlays/ask-dialog.d.ts +5 -2
  71. package/dist/types/overlays/history-search.d.ts +10 -0
  72. package/dist/types/overlays/hook-selector.d.ts +9 -4
  73. package/dist/types/overlays/jobs-panel.d.ts +1 -7
  74. package/dist/types/overlays/model-browser.d.ts +54 -7
  75. package/dist/types/overlays/model-hub.d.ts +23 -0
  76. package/dist/types/overlays/oauth-selector.d.ts +0 -1
  77. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  78. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  79. package/dist/types/overlays/session-selector.d.ts +6 -2
  80. package/dist/types/overlays/settings-defs.d.ts +1 -0
  81. package/dist/types/overlays/usage-dashboard.d.ts +8 -4
  82. package/dist/types/overlays/usage-display.d.ts +3 -1
  83. package/dist/types/prompt/composer.d.ts +1 -1
  84. package/dist/types/prompt/custom-editor.d.ts +11 -8
  85. package/dist/types/prompt/image-source.d.ts +2 -1
  86. package/dist/types/prompt/interactive-context-helpers.d.ts +4 -1
  87. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  88. package/dist/types/prompt/welcome.d.ts +6 -1
  89. package/dist/types/render/composite.d.ts +2 -0
  90. package/dist/types/render/output-pane.d.ts +1 -0
  91. package/dist/types/render/render-utils.d.ts +1 -0
  92. package/dist/types/render/tool-card.d.ts +6 -0
  93. package/dist/types/render/width-aware-text.d.ts +2 -0
  94. package/dist/types/setup/scenes/types.d.ts +1 -0
  95. package/dist/types/status-line/component.d.ts +3 -0
  96. package/dist/types/status-line/host.d.ts +4 -1
  97. package/dist/types/status-line/segments.d.ts +2 -0
  98. package/dist/types/status-line/types.d.ts +14 -3
  99. package/dist/types/terminal-capabilities.d.ts +27 -35
  100. package/dist/types/terminal-multiplexer.d.ts +127 -14
  101. package/dist/types/terminal.d.ts +22 -6
  102. package/dist/types/theme/active-symbols.d.ts +1 -1
  103. package/dist/types/theme/schema.d.ts +11 -2
  104. package/dist/types/theme/symbols.d.ts +1 -1
  105. package/dist/types/theme/theme-class.d.ts +7 -0
  106. package/dist/types/theme/theme.d.ts +1 -1
  107. package/dist/types/theme/tui-adapters.d.ts +3 -0
  108. package/dist/types/tmux.d.ts +11 -4
  109. package/dist/types/tools/daemon.d.ts +1 -1
  110. package/dist/types/tools/eval.d.ts +22 -3
  111. package/dist/types/tools/glob.d.ts +4 -0
  112. package/dist/types/tools/native-view.d.ts +47 -0
  113. package/dist/types/tools/output-meta.d.ts +11 -1
  114. package/dist/types/tools/renderer.d.ts +37 -2
  115. package/dist/types/tools/streaming-output.d.ts +20 -1
  116. package/dist/types/tools/task.d.ts +0 -4
  117. package/dist/types/tools/write.d.ts +9 -2
  118. package/dist/types/tools/yield.d.ts +22 -0
  119. package/dist/types/tui.d.ts +31 -0
  120. package/dist/types/utils.d.ts +6 -0
  121. package/dist/types/vim.d.ts +19 -5
  122. package/package.json +13 -9
  123. package/src/app-keybindings.d.ts +1 -1
  124. package/src/app-keybindings.ts +1 -1
  125. package/src/apps/autoresearch-dashboard.ts +7 -3
  126. package/src/apps/cleanse-board.ts +2 -2
  127. package/src/apps/cleanse-picker.ts +1 -1
  128. package/src/apps/debug/log-viewer.ts +188 -72
  129. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  130. package/src/apps/debug/raw-sse.ts +39 -5
  131. package/src/apps/git/diff-pane.ts +26 -5
  132. package/src/apps/git/git-tui.ts +2 -2
  133. package/src/apps/git/help.ts +29 -20
  134. package/src/apps/if-bench-board.ts +2 -2
  135. package/src/apps/ps-top.ts +2 -2
  136. package/src/apps/setup-model-picker.ts +1 -1
  137. package/src/autocomplete.d.ts +5 -2
  138. package/src/autocomplete.ts +67 -43
  139. package/src/charts/chart-plan.d.ts +674 -0
  140. package/src/charts/chart-plan.ts +5081 -0
  141. package/src/charts/chart-svg.d.ts +59 -0
  142. package/src/charts/chart-svg.ts +4657 -0
  143. package/src/charts/table-data.d.ts +175 -0
  144. package/src/charts/table-data.ts +1478 -0
  145. package/src/chat/advisor-message.ts +11 -0
  146. package/src/chat/assistant-message.d.ts +5 -0
  147. package/src/chat/assistant-message.ts +219 -50
  148. package/src/chat/chat-transcript-builder.d.ts +2 -0
  149. package/src/chat/chat-transcript-builder.ts +5 -3
  150. package/src/chat/compaction-summary-message.d.ts +1 -0
  151. package/src/chat/compaction-summary-message.ts +8 -0
  152. package/src/chat/execution-shared.d.ts +6 -5
  153. package/src/chat/execution-shared.ts +42 -25
  154. package/src/chat/fence-figure.d.ts +42 -0
  155. package/src/chat/fence-figure.ts +80 -0
  156. package/src/chat/figure-markdown.d.ts +42 -0
  157. package/src/chat/figure-markdown.ts +174 -0
  158. package/src/chat/messages.d.ts +1 -1
  159. package/src/chat/messages.ts +1 -1
  160. package/src/chat/read-tool-group.ts +6 -4
  161. package/src/chat/skill-message.ts +6 -0
  162. package/src/chat/svg-figure.d.ts +33 -0
  163. package/src/chat/svg-figure.ts +399 -0
  164. package/src/chat/svg-source.d.ts +49 -0
  165. package/src/chat/svg-source.ts +188 -0
  166. package/src/chat/table-chart.d.ts +89 -0
  167. package/src/chat/table-chart.ts +294 -0
  168. package/src/chat/tool-execution.d.ts +2 -0
  169. package/src/chat/tool-execution.ts +80 -6
  170. package/src/chat/user-message.d.ts +15 -2
  171. package/src/chat/user-message.ts +55 -3
  172. package/src/chrome/shared.ts +53 -0
  173. package/src/chrome/transcript-container.d.ts +6 -0
  174. package/src/chrome/transcript-container.ts +93 -13
  175. package/src/components/box.d.ts +1 -0
  176. package/src/components/box.ts +5 -0
  177. package/src/components/disclosure.d.ts +1 -0
  178. package/src/components/disclosure.ts +8 -0
  179. package/src/components/editor.d.ts +15 -4
  180. package/src/components/editor.ts +294 -46
  181. package/src/components/form.d.ts +1 -0
  182. package/src/components/form.ts +8 -0
  183. package/src/components/image.d.ts +12 -44
  184. package/src/components/image.ts +47 -151
  185. package/src/components/key-value-list.d.ts +1 -0
  186. package/src/components/key-value-list.ts +4 -0
  187. package/src/components/layout/row.d.ts +1 -0
  188. package/src/components/layout/row.ts +7 -0
  189. package/src/components/layout/stack.d.ts +1 -0
  190. package/src/components/layout/stack.ts +7 -0
  191. package/src/components/markdown.d.ts +36 -3
  192. package/src/components/markdown.ts +433 -318
  193. package/src/components/scroll-view.d.ts +1 -0
  194. package/src/components/scroll-view.ts +5 -0
  195. package/src/components/section.d.ts +1 -0
  196. package/src/components/section.ts +5 -0
  197. package/src/components/settings-list.ts +2 -0
  198. package/src/components/table.d.ts +1 -0
  199. package/src/components/table.ts +4 -0
  200. package/src/components/text.d.ts +1 -0
  201. package/src/components/text.ts +7 -0
  202. package/src/components/tree-view.d.ts +7 -0
  203. package/src/components/tree-view.ts +38 -8
  204. package/src/components/truncated-text.d.ts +1 -0
  205. package/src/components/truncated-text.ts +5 -0
  206. package/src/desktop-notify.ts +1 -1
  207. package/src/fuzzy.d.ts +29 -1
  208. package/src/fuzzy.ts +115 -30
  209. package/src/key-hint-format.d.ts +1 -1
  210. package/src/key-hint-format.ts +1 -1
  211. package/src/keybinding-matchers.d.ts +3 -0
  212. package/src/keybinding-matchers.ts +6 -2
  213. package/src/keybindings.d.ts +1 -1
  214. package/src/keybindings.ts +1 -1
  215. package/src/kitty-graphics.d.ts +2 -2
  216. package/src/kitty-graphics.ts +4 -4
  217. package/src/loop-watchdog.d.ts +8 -7
  218. package/src/loop-watchdog.ts +18 -13
  219. package/src/multiplexers/cmux.d.ts +16 -0
  220. package/src/multiplexers/cmux.ts +29 -0
  221. package/src/multiplexers/herdr.d.ts +26 -0
  222. package/src/multiplexers/herdr.ts +64 -0
  223. package/src/multiplexers/notify.d.ts +13 -0
  224. package/src/multiplexers/notify.ts +24 -0
  225. package/src/multiplexers/orca.d.ts +12 -0
  226. package/src/multiplexers/orca.ts +16 -0
  227. package/src/multiplexers/screen.d.ts +9 -0
  228. package/src/multiplexers/screen.ts +15 -0
  229. package/src/multiplexers/tmux.d.ts +29 -0
  230. package/src/multiplexers/tmux.ts +40 -0
  231. package/src/multiplexers/types.d.ts +100 -0
  232. package/src/multiplexers/types.ts +110 -0
  233. package/src/multiplexers/wmux.d.ts +12 -0
  234. package/src/multiplexers/wmux.ts +16 -0
  235. package/src/multiplexers/zellij.d.ts +12 -0
  236. package/src/multiplexers/zellij.ts +24 -0
  237. package/src/native/backend.d.ts +3 -2
  238. package/src/native/backend.ts +174 -18
  239. package/src/native/blobs.d.ts +7 -3
  240. package/src/native/blobs.ts +47 -12
  241. package/src/native/encode.d.ts +3 -4
  242. package/src/native/encode.ts +58 -31
  243. package/src/native/overlay.d.ts +5 -0
  244. package/src/native/overlay.ts +13 -0
  245. package/src/native/reconcile.ts +4 -0
  246. package/src/overlays/agent-hub-projection.d.ts +6 -5
  247. package/src/overlays/agent-hub-projection.ts +124 -13
  248. package/src/overlays/agent-hub-types.d.ts +9 -0
  249. package/src/overlays/agent-hub-types.ts +6 -0
  250. package/src/overlays/agent-hub.ts +44 -29
  251. package/src/overlays/agent-transcript-viewer.ts +91 -23
  252. package/src/overlays/agents-hub.ts +28 -8
  253. package/src/overlays/annotation-overlay.ts +293 -90
  254. package/src/overlays/annotation-types.ts +2 -0
  255. package/src/overlays/ask-dialog.ts +7 -10
  256. package/src/overlays/btw-history-panel.ts +12 -1
  257. package/src/overlays/copy-selector.ts +30 -8
  258. package/src/overlays/extensions/state-manager.ts +52 -12
  259. package/src/overlays/history-search.ts +100 -7
  260. package/src/overlays/hook-selector.ts +12 -5
  261. package/src/overlays/jobs-panel.ts +28 -46
  262. package/src/overlays/model-browser.ts +341 -110
  263. package/src/overlays/model-hub.ts +300 -49
  264. package/src/overlays/move-overlay.ts +28 -14
  265. package/src/overlays/oauth-selector.ts +54 -70
  266. package/src/overlays/plan-review-overlay.ts +194 -70
  267. package/src/overlays/plugin-settings.ts +3 -3
  268. package/src/overlays/report-panel.ts +2 -2
  269. package/src/overlays/rewind-selector.ts +86 -17
  270. package/src/overlays/session-observer-registry.d.ts +2 -0
  271. package/src/overlays/session-observer-registry.ts +10 -2
  272. package/src/overlays/session-selector.ts +64 -26
  273. package/src/overlays/settings-defs.ts +4 -3
  274. package/src/overlays/settings-selector.ts +33 -8
  275. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  276. package/src/overlays/stats-notice.ts +1 -1
  277. package/src/overlays/tree-selector.ts +41 -20
  278. package/src/overlays/usage-dashboard.ts +100 -50
  279. package/src/overlays/usage-display.ts +10 -4
  280. package/src/prompt/attachment-chips.ts +15 -11
  281. package/src/prompt/composer-attachments.ts +6 -4
  282. package/src/prompt/composer.ts +4 -15
  283. package/src/prompt/custom-editor.ts +54 -26
  284. package/src/prompt/image-source.ts +2 -1
  285. package/src/prompt/interactive-context-helpers.ts +30 -18
  286. package/src/prompt/macos-spelling.ts +7 -0
  287. package/src/prompt/magic-keywords.ts +11 -2
  288. package/src/prompt/model-mention-autocomplete.ts +14 -14
  289. package/src/prompt/tips.txt +6 -5
  290. package/src/prompt/welcome.ts +55 -12
  291. package/src/prompt/word-completion.ts +38 -5
  292. package/src/render/composite.d.ts +2 -0
  293. package/src/render/composite.ts +64 -0
  294. package/src/render/output-pane.d.ts +1 -0
  295. package/src/render/output-pane.ts +6 -0
  296. package/src/render/render-utils.d.ts +1 -0
  297. package/src/render/render-utils.ts +11 -0
  298. package/src/render/tool-card.d.ts +6 -0
  299. package/src/render/tool-card.ts +15 -0
  300. package/src/render/width-aware-text.d.ts +2 -0
  301. package/src/render/width-aware-text.ts +5 -0
  302. package/src/setup/scenes/model.ts +2 -1
  303. package/src/setup/scenes/types.ts +1 -0
  304. package/src/status-line/component.ts +292 -78
  305. package/src/status-line/host.ts +4 -1
  306. package/src/status-line/segments.ts +40 -22
  307. package/src/status-line/types.ts +15 -3
  308. package/src/terminal-capabilities.d.ts +27 -35
  309. package/src/terminal-capabilities.ts +75 -189
  310. package/src/terminal-multiplexer.d.ts +127 -14
  311. package/src/terminal-multiplexer.ts +112 -33
  312. package/src/terminal.d.ts +22 -6
  313. package/src/terminal.ts +139 -37
  314. package/src/theme/active-symbols.d.ts +1 -1
  315. package/src/theme/active-symbols.ts +1 -1
  316. package/src/theme/schema-validation.ts +1 -0
  317. package/src/theme/schema.d.ts +11 -2
  318. package/src/theme/schema.ts +14 -1
  319. package/src/theme/session-color.ts +1 -7
  320. package/src/theme/symbols.d.ts +1 -1
  321. package/src/theme/symbols.ts +6 -1
  322. package/src/theme/theme-class.d.ts +7 -0
  323. package/src/theme/theme-class.ts +26 -1
  324. package/src/theme/theme-schema.json +5 -1
  325. package/src/theme/theme.d.ts +1 -1
  326. package/src/theme/theme.ts +8 -4
  327. package/src/theme/tui-adapters.d.ts +3 -0
  328. package/src/theme/tui-adapters.ts +68 -0
  329. package/src/tmux.d.ts +11 -4
  330. package/src/tmux.ts +47 -11
  331. package/src/tools/ast-edit.ts +59 -35
  332. package/src/tools/ast-grep.ts +35 -16
  333. package/src/tools/bash.ts +92 -42
  334. package/src/tools/daemon.d.ts +1 -1
  335. package/src/tools/daemon.ts +1 -1
  336. package/src/tools/default-renderer.ts +35 -19
  337. package/src/tools/edit.ts +142 -120
  338. package/src/tools/eval-format/index.ts +28 -2
  339. package/src/tools/eval-format/javascript.ts +7 -5
  340. package/src/tools/eval.d.ts +22 -3
  341. package/src/tools/eval.ts +364 -208
  342. package/src/tools/fetch.ts +5 -6
  343. package/src/tools/glob.d.ts +4 -0
  344. package/src/tools/glob.ts +33 -65
  345. package/src/tools/grep.ts +81 -48
  346. package/src/tools/index.ts +2 -0
  347. package/src/tools/lsp.ts +81 -59
  348. package/src/tools/mcp.ts +111 -68
  349. package/src/tools/native-view.d.ts +47 -0
  350. package/src/tools/native-view.ts +102 -1
  351. package/src/tools/output-meta.d.ts +11 -1
  352. package/src/tools/output-meta.ts +23 -3
  353. package/src/tools/read.ts +7 -5
  354. package/src/tools/renderer.d.ts +37 -2
  355. package/src/tools/renderer.ts +42 -2
  356. package/src/tools/streaming-output.d.ts +20 -1
  357. package/src/tools/streaming-output.ts +149 -50
  358. package/src/tools/task.d.ts +0 -4
  359. package/src/tools/task.ts +112 -59
  360. package/src/tools/todo.ts +119 -30
  361. package/src/tools/wait.ts +34 -24
  362. package/src/tools/web-search.ts +1 -1
  363. package/src/tools/write.d.ts +9 -2
  364. package/src/tools/write.ts +147 -95
  365. package/src/tools/yield.d.ts +22 -0
  366. package/src/tools/yield.ts +225 -0
  367. package/src/tui.d.ts +31 -0
  368. package/src/tui.ts +309 -160
  369. package/src/utils.d.ts +6 -0
  370. package/src/utils.ts +19 -0
  371. package/src/vim.d.ts +19 -5
  372. package/src/vim.ts +333 -18
  373. package/src/windows-input-mode.ts +8 -0
@@ -0,0 +1,399 @@
1
+ /**
2
+ * A ```svg fence drawn as an inline terminal image. The source is rasterized
3
+ * off the JS thread and shown through {@link Image}; while the fence streams,
4
+ * each throttled revision repairs the partial source and replaces the
5
+ * previous raster, which stays on screen until the next one lands.
6
+ *
7
+ * Sizing is natural rather than preview-capped: one terminal row spans
8
+ * {@link UNITS_PER_ROW} SVG user units, so `font-size="14"` text lands near
9
+ * the terminal's own glyph size and the figure takes as many columns as its
10
+ * drawing needs — up to the full width, and a viewport-relative height. The
11
+ * raster is drawn at the terminal's device pixels per unit, padded to whole
12
+ * cells, and redrawn when the room it is shown in changes, so the terminal
13
+ * places it 1:1: any resampling, even 0.99×, blurs every glyph edge.
14
+ */
15
+ import { rasterizeSvg } from "@linxiraos/pi-natives";
16
+ import { hexToOklch, hueDistance, logger, oklchCusp, oklchToHex } from "@linxiraos/pi-utils";
17
+ import { Image, type ImageBudget, imageMaxColumns } from "../components/image";
18
+ import { fencedCode, type Markdown } from "../components/markdown";
19
+ import { type CellDimensions, getCellDimensions, getImageDimensions } from "../terminal-capabilities";
20
+ import { getThemeEpoch, type ThemeColor, theme } from "../theme";
21
+ import type { Component } from "../tui";
22
+ import { closePartialSvg, prepareSvg } from "./svg-source";
23
+
24
+ /** SVG user units per terminal row; a 1000-unit-wide drawing spans ~125 columns of 1:2 cells. */
25
+ const UNITS_PER_ROW = 16;
26
+ /** Least time between rasters of a fence that is still streaming. */
27
+ const STREAM_INTERVAL_MS = 200;
28
+ /** Longest raster edge; the native rasterizer caps the surface at 4096² pixels. */
29
+ const MAX_EDGE_PX = 4096;
30
+ /** Share of the terminal's rows one figure may fill, so it fits on screen whole. */
31
+ const MAX_VIEWPORT_SHARE = 0.8;
32
+
33
+ /**
34
+ * Foreground theme colors behind the figure tokens (`var(--name)`); `surface`
35
+ * is the selection background and `c1`…`c6` come from {@link seriesColors}.
36
+ */
37
+ const PALETTE: Readonly<Record<string, ThemeColor>> = {
38
+ fg: "text",
39
+ muted: "muted",
40
+ border: "borderMuted",
41
+ accent: "accent",
42
+ success: "success",
43
+ warning: "warning",
44
+ error: "error",
45
+ };
46
+
47
+ /** Hue families (OKLCH degrees) the series colors draw from: blue, orange, green, purple, pink, teal. */
48
+ const SERIES_HUES = [255, 55, 145, 305, 0, 200];
49
+ /** Gold, for a sixth series when the accent's hue crowds out two families. */
50
+ const RESERVE_HUE = 90;
51
+ /** Least hue distance between a family and the accent's hue for both to lead series. */
52
+ const MIN_SERIES_HUE_GAP = 30;
53
+ /** Chroma below which the accent reads as gray and blue leads instead. */
54
+ const MIN_ACCENT_CHROMA = 0.04;
55
+ /** Lightness band of series colors: deep enough for labels on light surfaces, bright enough on dark ones. */
56
+ const SERIES_LIGHTNESS = { light: [0.52, 0.66], dark: [0.68, 0.82] } as const;
57
+ /** Chroma ceiling: vivid, not neon. */
58
+ const SERIES_CHROMA = 0.19;
59
+
60
+ /** The active theme's color per figure token, for resolving a figure with `prepareSvg`. */
61
+ export function svgFigurePalette(): Record<string, string> {
62
+ const palette: Record<string, string> = { surface: theme.getBgHex("selectedBg") };
63
+ for (const token in PALETTE) palette[token] = theme.getColorHex(PALETTE[token]!);
64
+ seriesColors(palette.accent!, theme.isLight).forEach((color, at) => {
65
+ palette[`c${at + 1}`] = color;
66
+ });
67
+ return palette;
68
+ }
69
+
70
+ /**
71
+ * Six series colors of even visual weight, in the manner of Apple's system
72
+ * palette: led by the theme accent's hue (blue when the accent is gray), each
73
+ * next hue the family farthest from those already taken. Every hue sits at
74
+ * its gamut cusp clamped into the theme's {@link SERIES_LIGHTNESS} band, so
75
+ * orange stays orange and blue stays deep.
76
+ */
77
+ function seriesColors(accent: string, light: boolean): string[] {
78
+ const { c, h } = hexToOklch(accent);
79
+ const lead = c >= MIN_ACCENT_CHROMA ? h : SERIES_HUES[0]!;
80
+ const hues = [lead];
81
+ const pool = SERIES_HUES.filter(hue => hueDistance(hue, lead) >= MIN_SERIES_HUE_GAP);
82
+ while (pool.length > 0) {
83
+ const gaps = pool.map(hue => Math.min(...hues.map(taken => hueDistance(hue, taken))));
84
+ hues.push(pool.splice(gaps.indexOf(Math.max(...gaps)), 1)[0]!);
85
+ }
86
+ if (hues.length < 6) hues.push(RESERVE_HUE);
87
+ const [low, high] = SERIES_LIGHTNESS[light ? "light" : "dark"];
88
+ return hues.slice(0, 6).map(hue => {
89
+ const cusp = oklchCusp(hue);
90
+ return oklchToHex({ l: Math.min(high, Math.max(low, cusp.l)), c: Math.min(SERIES_CHROMA, cusp.c), h: hue });
91
+ });
92
+ }
93
+
94
+ let figuresEnabled = true;
95
+
96
+ /** Turn lifting ```svg fences into images on or off (the host's `tui.renderSvg`); the host rebuilds the transcript after. */
97
+ export function setSvgFigureRendering(enabled: boolean): void {
98
+ figuresEnabled = enabled;
99
+ }
100
+
101
+ /** Whether assistant ```svg fences render as images where the terminal can show them. */
102
+ export function svgFigureRendering(): boolean {
103
+ return figuresEnabled;
104
+ }
105
+
106
+ export interface SvgFigureOptions {
107
+ /**
108
+ * Markdown for the fenced-code fallback shown when the final source does not
109
+ * render. Without it the figure decorates content shown elsewhere (a chart
110
+ * under its table): it stays blank until drawn, and when it cannot be.
111
+ */
112
+ markdown?: (text: string) => Markdown;
113
+ /** Shared inline-image budget; superseded rasters are released from it. */
114
+ budget?: ImageBudget;
115
+ /** The figure's rows changed outside a text update: a raster landed or rendering fell back to code. */
116
+ onChange: () => void;
117
+ }
118
+
119
+ /** Cells a figure may fill. */
120
+ interface Limits {
121
+ columns: number;
122
+ rows: number;
123
+ }
124
+
125
+ /** One rasterized revision and the inputs that produced it. */
126
+ interface Raster {
127
+ source: string;
128
+ /** Cell size the raster was padded to; its pixels are whole cells of it. */
129
+ cell: CellDimensions;
130
+ limits: Limits;
131
+ themeEpoch: number;
132
+ /** Base64 PNG. */
133
+ data: string;
134
+ widthPx: number;
135
+ heightPx: number;
136
+ /** Budget key; one per revision so a new raster is a new terminal image. */
137
+ key: string;
138
+ }
139
+
140
+ /** Inputs of a raster attempt, successful or not. */
141
+ interface Attempt {
142
+ source: string;
143
+ cell: CellDimensions;
144
+ limits: Limits;
145
+ themeEpoch: number;
146
+ final: boolean;
147
+ }
148
+
149
+ let nextFigureId = 0;
150
+
151
+ /** Cells a figure rendered at `width` may fill: the image's width, a share of the viewport, and the raster edge cap. */
152
+ function limitsFor(width: number, cell: CellDimensions): Limits {
153
+ const viewportRows = Math.floor((process.stdout.rows || 24) * MAX_VIEWPORT_SHARE);
154
+ return {
155
+ columns: Math.min(imageMaxColumns(width), Math.floor(MAX_EDGE_PX / cell.widthPx)),
156
+ rows: Math.max(1, Math.min(viewportRows, Math.floor(MAX_EDGE_PX / cell.heightPx))),
157
+ };
158
+ }
159
+
160
+ export class SvgFigure implements Component {
161
+ readonly #options: SvgFigureOptions;
162
+ readonly #id = nextFigureId++;
163
+ #source = "";
164
+ /** No more text arrives for this fence: no throttling, and a failed raster falls back to code. */
165
+ #final = false;
166
+ #revision = 0;
167
+ #raster: Raster | undefined;
168
+ #attempt: Attempt | undefined;
169
+ /** Final source that could not be drawn; rendered as a fenced code block. */
170
+ #failed: string | undefined;
171
+ #rasterizing = false;
172
+ #startedAt = Number.NEGATIVE_INFINITY;
173
+ #timer: NodeJS.Timeout | undefined;
174
+ /** A {@link #schedule} is queued for the end of the current task. */
175
+ #queued = false;
176
+ #disposed = false;
177
+ /** Last render width; rasters wait for the first render to know their room. */
178
+ #width: number | undefined;
179
+ #image: { raster: Raster; component: Image } | undefined;
180
+ #fallback: { source: string; md: Markdown } | undefined;
181
+ #placeholder: readonly string[] | undefined;
182
+
183
+ constructor(options: SvgFigureOptions) {
184
+ this.#options = options;
185
+ }
186
+
187
+ /** Feed the fence body; `final` once the fence closed or the message stopped streaming. */
188
+ update(source: string, final: boolean): void {
189
+ if (source === this.#source && final === this.#final) return;
190
+ this.#source = source;
191
+ this.#final = final;
192
+ // Deferred: a block built mid-stream is marked streaming right after it
193
+ // is built, and must not first rasterize (and fail) its prefix as final.
194
+ if (this.#queued) return;
195
+ this.#queued = true;
196
+ queueMicrotask(() => {
197
+ this.#queued = false;
198
+ this.#schedule();
199
+ });
200
+ }
201
+
202
+ /** Whether the final source still waits for its raster (or for failing to render). */
203
+ get pending(): boolean {
204
+ return this.#final && this.#raster?.source !== this.#source && this.#failed !== this.#source;
205
+ }
206
+
207
+ render(width: number): readonly string[] {
208
+ // Width, cell size and theme are render-time inputs; a change re-rasterizes.
209
+ this.#width = width;
210
+ this.#schedule();
211
+ if (this.#failed === this.#source) return this.#fallbackRows(width);
212
+ const raster = this.#raster;
213
+ if (!raster) {
214
+ if (!this.#options.markdown) return [];
215
+ this.#placeholder ??= [` ${theme.fg("dim", "Drawing SVG…")}`];
216
+ return this.#placeholder;
217
+ }
218
+ return this.#imageFor(raster).render(width);
219
+ }
220
+
221
+ invalidate(): void {
222
+ this.#placeholder = undefined;
223
+ this.#image?.component.invalidate();
224
+ // Rebuilt through the Markdown factory, which may carry a new theme.
225
+ this.#fallback = undefined;
226
+ }
227
+
228
+ dispose(): void {
229
+ this.#disposed = true;
230
+ clearTimeout(this.#timer);
231
+ this.#timer = undefined;
232
+ }
233
+
234
+ debugState(): Record<string, unknown> {
235
+ return {
236
+ final: this.#final,
237
+ failed: this.#failed === this.#source,
238
+ raster: this.#raster ? `${this.#raster.widthPx}x${this.#raster.heightPx}` : null,
239
+ current: this.#raster?.source === this.#source,
240
+ };
241
+ }
242
+
243
+ /** Whether the current source, cell size, theme or room at `width` has not been attempted yet. */
244
+ #stale(width: number): boolean {
245
+ const attempt = this.#attempt;
246
+ if (this.#failed === this.#source) return false;
247
+ const cell = getCellDimensions();
248
+ const limits = limitsFor(width, cell);
249
+ return (
250
+ attempt === undefined ||
251
+ attempt.source !== this.#source ||
252
+ attempt.cell.widthPx !== cell.widthPx ||
253
+ attempt.cell.heightPx !== cell.heightPx ||
254
+ attempt.themeEpoch !== getThemeEpoch() ||
255
+ // A streaming attempt that failed is retried once as final.
256
+ (this.#final && !attempt.final && this.#raster?.source !== this.#source) ||
257
+ ((attempt.limits.columns !== limits.columns || attempt.limits.rows !== limits.rows) && this.#outgrown(limits))
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Whether the current raster would come out differently in `limits`. One
263
+ * below both of its limits drew at its natural size and redraws only once
264
+ * it no longer fits; one that filled a limit redraws whenever they change.
265
+ */
266
+ #outgrown(limits: Limits): boolean {
267
+ const raster = this.#raster;
268
+ if (raster?.source !== this.#source) return false;
269
+ const columns = raster.widthPx / raster.cell.widthPx;
270
+ const rows = raster.heightPx / raster.cell.heightPx;
271
+ if (columns < raster.limits.columns && rows < raster.limits.rows) {
272
+ return columns > limits.columns || rows > limits.rows;
273
+ }
274
+ return raster.limits.columns !== limits.columns || raster.limits.rows !== limits.rows;
275
+ }
276
+
277
+ /** Start a raster when one is due: single-flight, throttled while streaming, immediate once final. */
278
+ #schedule(): void {
279
+ const width = this.#width;
280
+ if (this.#disposed || this.#rasterizing || width === undefined) return;
281
+ if (this.#final && this.#timer) {
282
+ clearTimeout(this.#timer);
283
+ this.#timer = undefined;
284
+ }
285
+ if (this.#timer || !this.#stale(width)) return;
286
+ const wait = this.#final ? 0 : this.#startedAt + STREAM_INTERVAL_MS - performance.now();
287
+ if (wait > 0) {
288
+ this.#timer = setTimeout(() => {
289
+ this.#timer = undefined;
290
+ this.#schedule();
291
+ }, wait);
292
+ this.#timer.unref?.();
293
+ return;
294
+ }
295
+ void this.#rasterize(width);
296
+ }
297
+
298
+ async #rasterize(width: number): Promise<void> {
299
+ const cell = getCellDimensions();
300
+ const attempt: Attempt = {
301
+ source: this.#source,
302
+ cell,
303
+ limits: limitsFor(width, cell),
304
+ themeEpoch: getThemeEpoch(),
305
+ final: this.#final,
306
+ };
307
+ this.#attempt = attempt;
308
+ const svg = closePartialSvg(attempt.source);
309
+ let png: Uint8Array | undefined;
310
+ if (svg !== null) {
311
+ this.#rasterizing = true;
312
+ this.#startedAt = performance.now();
313
+ try {
314
+ const prepared = prepareSvg(svg, svgFigurePalette());
315
+ png = await rasterizeSvg(
316
+ new TextEncoder().encode(prepared),
317
+ attempt.limits.columns * cell.widthPx,
318
+ attempt.limits.rows * cell.heightPx,
319
+ cell.heightPx / UNITS_PER_ROW,
320
+ cell,
321
+ );
322
+ } catch (error) {
323
+ // A streaming prefix that does not parse yet keeps the previous raster.
324
+ if (attempt.final) logger.debug("SVG figure did not render", { error: String(error) });
325
+ } finally {
326
+ this.#rasterizing = false;
327
+ }
328
+ }
329
+ if (this.#disposed) return;
330
+ const data = png?.toBase64();
331
+ const size = data ? getImageDimensions(data, "image/png") : null;
332
+ if (data && size) {
333
+ this.#show(attempt, data, size.widthPx, size.heightPx);
334
+ } else if (attempt.final) {
335
+ this.#failed = attempt.source;
336
+ this.#options.onChange();
337
+ }
338
+ // The source, cell size, theme or room moved on while this raster ran.
339
+ this.#schedule();
340
+ }
341
+
342
+ #show(attempt: Attempt, data: string, widthPx: number, heightPx: number): void {
343
+ const previous = this.#raster;
344
+ this.#raster = {
345
+ source: attempt.source,
346
+ cell: attempt.cell,
347
+ limits: attempt.limits,
348
+ themeEpoch: attempt.themeEpoch,
349
+ data,
350
+ widthPx,
351
+ heightPx,
352
+ key: `svg${this.#id}:${++this.#revision}`,
353
+ };
354
+ // A streaming revision is gone for good: free its terminal image now
355
+ // rather than letting it crowd older images out of the store. A
356
+ // re-raster of the same source (cell size, theme or room change) may still be
357
+ // standing in scrollback, so the residency sweep keeps deciding for it.
358
+ if (previous && previous.source !== attempt.source) this.#options.budget?.release(previous.key);
359
+ this.#options.onChange();
360
+ }
361
+
362
+ /**
363
+ * The image for `raster` over the whole cells it was padded to. At the
364
+ * cell size and room it was drawn for that box is its pixels exactly; a
365
+ * raster outdated by a resize is scaled by {@link Image} until its redraw lands.
366
+ */
367
+ #imageFor(raster: Raster): Image {
368
+ const current = this.#image;
369
+ if (current?.raster === raster) return current.component;
370
+ const columns = raster.widthPx / raster.cell.widthPx;
371
+ const rows = raster.heightPx / raster.cell.heightPx;
372
+ const component = new Image(
373
+ raster.data,
374
+ "image/png",
375
+ { fallbackColor: (text: string) => theme.fg("toolOutput", text) },
376
+ {
377
+ maxWidthCells: columns,
378
+ maxHeightCells: rows,
379
+ filename: "svg",
380
+ budget: this.#options.budget,
381
+ imageKey: raster.key,
382
+ requestRender: this.#options.onChange,
383
+ },
384
+ { widthPx: raster.widthPx, heightPx: raster.heightPx },
385
+ );
386
+ this.#image = { raster, component };
387
+ return component;
388
+ }
389
+
390
+ /** The source as the fenced code block it was written as, fenced longer than any backtick run inside. */
391
+ #fallbackRows(width: number): readonly string[] {
392
+ const source = this.#source;
393
+ if (!this.#options.markdown) return [];
394
+ if (this.#fallback?.source !== source) {
395
+ this.#fallback = { source, md: this.#options.markdown(fencedCode("svg", source)) };
396
+ }
397
+ return this.#fallback.md.render(width);
398
+ }
399
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Source-level handling of ```svg fences in assistant Markdown: splitting
3
+ * prose around them ({@link splitSvgFences}), turning a fence that is still
4
+ * streaming into a document an SVG parser accepts ({@link closePartialSvg}),
5
+ * and resolving the theme tokens figures color themselves with
6
+ * ({@link prepareSvg}). `FigureMarkdown` lifts the fences out as `SvgFigure`
7
+ * images.
8
+ */
9
+ /** One run of an assistant text block: prose, or the body of a ```svg fence. */
10
+ export type FigureSegment = {
11
+ readonly kind: "markdown";
12
+ readonly text: string;
13
+ }
14
+ /** `closed` once the fence's closing line arrived. */
15
+ | {
16
+ readonly kind: "svg";
17
+ readonly source: string;
18
+ readonly closed: boolean;
19
+ };
20
+ /** Whether `markdown` holds a ```svg fence {@link splitSvgFences} would lift. */
21
+ export declare function hasSvgFence(markdown: string): boolean;
22
+ /**
23
+ * Split `markdown` into prose and ```svg fence bodies, in order. Only
24
+ * top-level fences lift; an svg fence nested in another fence, a list's
25
+ * indented block or a blockquote stays prose. Blank prose runs are dropped.
26
+ * An svg fence still open at the end is the last segment, `closed: false`.
27
+ */
28
+ export declare function splitSvgFences(markdown: string): FigureSegment[];
29
+ /**
30
+ * Turn a possibly truncated SVG source into a well-formed document: cut the
31
+ * trailing construct still being written (a tag without its `>`, an
32
+ * unterminated comment, CDATA section, processing instruction or declaration,
33
+ * a dangling `&entity`) and close every element left open, innermost first.
34
+ * A complete document passes through unchanged. Returns null until the root
35
+ * `<svg …>` start tag is complete — before that there is nothing to draw.
36
+ */
37
+ export declare function closePartialSvg(source: string): string | null;
38
+ /**
39
+ * Make a figure's source what an SVG rasterizer draws as written, in the
40
+ * reader's theme. Rasterizers resolve neither CSS custom properties nor an
41
+ * inherited text color, and reject a root without the SVG namespace, so:
42
+ * - `var(--name)` / `var(--name, fallback)` become `palette[name]` (an
43
+ * unknown name takes its fallback, else `palette.fg`);
44
+ * - a root `<svg>` lacking them gets `color` = `palette.fg` (so
45
+ * `currentColor` follows the theme), a sans-serif `font-family` (instead
46
+ * of the rasterizer's Times), `xmlns`, and `xmlns:xlink` when the source
47
+ * uses `xlink:` attributes.
48
+ */
49
+ export declare function prepareSvg(svg: string, palette: Readonly<Record<string, string>>): string;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Source-level handling of ```svg fences in assistant Markdown: splitting
3
+ * prose around them ({@link splitSvgFences}), turning a fence that is still
4
+ * streaming into a document an SVG parser accepts ({@link closePartialSvg}),
5
+ * and resolving the theme tokens figures color themselves with
6
+ * ({@link prepareSvg}). `FigureMarkdown` lifts the fences out as `SvgFigure`
7
+ * images.
8
+ */
9
+
10
+ /** One run of an assistant text block: prose, or the body of a ```svg fence. */
11
+ export type FigureSegment =
12
+ | { readonly kind: "markdown"; readonly text: string }
13
+ /** `closed` once the fence's closing line arrived. */
14
+ | { readonly kind: "svg"; readonly source: string; readonly closed: boolean };
15
+
16
+ /** A top-level (≤3-space indent) ```svg / ~~~svg fence opener, info string compared ASCII-case-insensitively. */
17
+ const SVG_FENCE = /^ {0,3}(?:`{3,}|~{3,})[ \t]*svg(?:[ \t]|$)/im;
18
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
19
+
20
+ /** Whether `markdown` holds a ```svg fence {@link splitSvgFences} would lift. */
21
+ export function hasSvgFence(markdown: string): boolean {
22
+ return markdown.includes("svg") && SVG_FENCE.test(markdown);
23
+ }
24
+
25
+ /**
26
+ * Split `markdown` into prose and ```svg fence bodies, in order. Only
27
+ * top-level fences lift; an svg fence nested in another fence, a list's
28
+ * indented block or a blockquote stays prose. Blank prose runs are dropped.
29
+ * An svg fence still open at the end is the last segment, `closed: false`.
30
+ */
31
+ export function splitSvgFences(markdown: string): FigureSegment[] {
32
+ const segments: FigureSegment[] = [];
33
+ /** Open fence: its marker run, and where an svg fence's body starts (-1 for other fences). */
34
+ let fence: { marker: string; body: number } | undefined;
35
+ let proseStart = 0;
36
+ let lineStart = 0;
37
+ while (lineStart <= markdown.length) {
38
+ const newline = markdown.indexOf("\n", lineStart);
39
+ const lineEnd = newline < 0 ? markdown.length : newline;
40
+ const next = newline < 0 ? markdown.length + 1 : newline + 1;
41
+ const line = markdown.slice(lineStart, lineEnd);
42
+ if (fence) {
43
+ if (closesFence(line, fence.marker)) {
44
+ if (fence.body >= 0) {
45
+ segments.push({ kind: "svg", source: markdown.slice(fence.body, lineStart), closed: true });
46
+ proseStart = Math.min(next, markdown.length);
47
+ }
48
+ fence = undefined;
49
+ }
50
+ } else {
51
+ const open = FENCE_OPEN.exec(line);
52
+ // A backtick fence's info string cannot hold backticks (CommonMark).
53
+ if (open && !(open[1]![0] === "`" && open[2]!.includes("`"))) {
54
+ const isSvg = open[2]!.trim().split(/[ \t]/, 1)[0]!.toLowerCase() === "svg";
55
+ if (isSvg) {
56
+ const prose = markdown.slice(proseStart, lineStart);
57
+ if (prose.trim()) segments.push({ kind: "markdown", text: prose });
58
+ fence = { marker: open[1]!, body: Math.min(next, markdown.length) };
59
+ } else {
60
+ fence = { marker: open[1]!, body: -1 };
61
+ }
62
+ }
63
+ }
64
+ lineStart = next;
65
+ }
66
+ if (fence && fence.body >= 0) {
67
+ segments.push({ kind: "svg", source: markdown.slice(fence.body), closed: false });
68
+ } else if (markdown.slice(proseStart).trim()) {
69
+ segments.push({ kind: "markdown", text: markdown.slice(proseStart) });
70
+ }
71
+ return segments;
72
+ }
73
+
74
+ /** Whether `line` closes a fence opened by `marker`: same character, at least as long, nothing after. */
75
+ function closesFence(line: string, marker: string): boolean {
76
+ let index = 0;
77
+ while (index < 3 && line[index] === " ") index++;
78
+ let run = 0;
79
+ while (line[index + run] === marker[0]) run++;
80
+ return run >= marker.length && line.slice(index + run).trim() === "";
81
+ }
82
+
83
+ /**
84
+ * Turn a possibly truncated SVG source into a well-formed document: cut the
85
+ * trailing construct still being written (a tag without its `>`, an
86
+ * unterminated comment, CDATA section, processing instruction or declaration,
87
+ * a dangling `&entity`) and close every element left open, innermost first.
88
+ * A complete document passes through unchanged. Returns null until the root
89
+ * `<svg …>` start tag is complete — before that there is nothing to draw.
90
+ */
91
+ export function closePartialSvg(source: string): string | null {
92
+ const open: string[] = [];
93
+ let rootSeen = false;
94
+ let end = 0;
95
+ let index = 0;
96
+ for (;;) {
97
+ const lt = source.indexOf("<", index);
98
+ if (lt < 0) {
99
+ // Trailing text: an entity reference still being written would not parse.
100
+ const amp = source.lastIndexOf("&");
101
+ end = amp >= index && !source.includes(";", amp) ? amp : source.length;
102
+ break;
103
+ }
104
+ end = lt;
105
+ const close = constructEnd(source, lt);
106
+ if (close < 0) break;
107
+ if (source.startsWith("</", lt)) {
108
+ const name = source.slice(lt + 2, close - 1).trim();
109
+ const at = open.lastIndexOf(name);
110
+ if (at >= 0) open.length = at;
111
+ } else if (source[lt + 1] !== "!" && source[lt + 1] !== "?") {
112
+ const name = /^<([^\s/>]+)/.exec(source.slice(lt, close))?.[1];
113
+ if (name !== undefined) {
114
+ if (source[close - 2] !== "/") open.push(name);
115
+ if (!rootSeen && (name === "svg" || name.endsWith(":svg"))) rootSeen = true;
116
+ }
117
+ }
118
+ index = close;
119
+ end = close;
120
+ }
121
+ if (!rootSeen) return null;
122
+ let document = source.slice(0, end);
123
+ for (let at = open.length - 1; at >= 0; at--) document += `</${open[at]}>`;
124
+ return document;
125
+ }
126
+
127
+ /** `var(--name)` / `var(--name, fallback)`; the fallback may hold one level of parentheses (`rgb(…)`). */
128
+ const VAR_REFERENCE = /var\(\s*--([\w-]+)\s*(?:,\s*((?:[^()]|\([^()]*\))*))?\)/g;
129
+ /** The first `<svg …>` start tag (namespace prefix allowed). */
130
+ const ROOT_TAG = /<(?:[\w.-]+:)?svg(?=[\s/>])[^>]*>/;
131
+
132
+ /**
133
+ * Make a figure's source what an SVG rasterizer draws as written, in the
134
+ * reader's theme. Rasterizers resolve neither CSS custom properties nor an
135
+ * inherited text color, and reject a root without the SVG namespace, so:
136
+ * - `var(--name)` / `var(--name, fallback)` become `palette[name]` (an
137
+ * unknown name takes its fallback, else `palette.fg`);
138
+ * - a root `<svg>` lacking them gets `color` = `palette.fg` (so
139
+ * `currentColor` follows the theme), a sans-serif `font-family` (instead
140
+ * of the rasterizer's Times), `xmlns`, and `xmlns:xlink` when the source
141
+ * uses `xlink:` attributes.
142
+ */
143
+ export function prepareSvg(svg: string, palette: Readonly<Record<string, string>>): string {
144
+ const fg = palette.fg ?? "currentColor";
145
+ const resolved = svg.replace(
146
+ VAR_REFERENCE,
147
+ (_match, name: string, fallback: string | undefined) => palette[name] ?? (fallback?.trim() || fg),
148
+ );
149
+ const usesXlink = resolved.includes("xlink:");
150
+ return resolved.replace(ROOT_TAG, tag => {
151
+ let added = "";
152
+ if (!/\scolor\s*=/.test(tag)) added += ` color="${fg}"`;
153
+ if (!/\sfont-family\s*=/.test(tag)) added += ` font-family="sans-serif"`;
154
+ if (!/\sxmlns\s*=/.test(tag)) added += ` xmlns="http://www.w3.org/2000/svg"`;
155
+ if (usesXlink && !/\sxmlns:xlink\s*=/.test(tag)) added += ` xmlns:xlink="http://www.w3.org/1999/xlink"`;
156
+ return added ? tag.replace(/^<[^\s/>]+/, `$&${added}`) : tag;
157
+ });
158
+ }
159
+
160
+ /** Index just past the markup construct starting at `<` (`lt`), or -1 when it is not complete yet. */
161
+ function constructEnd(source: string, lt: number): number {
162
+ const after = (terminator: string, from: number): number => {
163
+ const at = source.indexOf(terminator, from);
164
+ return at < 0 ? -1 : at + terminator.length;
165
+ };
166
+ if (source.startsWith("<!--", lt)) return after("-->", lt + 4);
167
+ if (source.startsWith("<![CDATA[", lt)) return after("]]>", lt + 9);
168
+ if (source.startsWith("<?", lt)) return after("?>", lt + 2);
169
+ // Tags and declarations end at the first `>` outside a quoted value; a
170
+ // doctype's internal subset (`[…]`) may hold `>` of its own.
171
+ let quote: string | undefined;
172
+ let subset = false;
173
+ for (let at = lt + 1; at < source.length; at++) {
174
+ const char = source[at];
175
+ if (quote !== undefined) {
176
+ if (char === quote) quote = undefined;
177
+ } else if (char === '"' || char === "'") {
178
+ quote = char;
179
+ } else if (char === "[" && source[lt + 1] === "!") {
180
+ subset = true;
181
+ } else if (char === "]") {
182
+ subset = false;
183
+ } else if (char === ">" && !subset) {
184
+ return at + 1;
185
+ }
186
+ }
187
+ return -1;
188
+ }