pygpt-net 2.6.54__py3-none-any.whl → 2.6.56__py3-none-any.whl

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 (371) hide show
  1. pygpt_net/CHANGELOG.txt +11 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +26 -22
  4. pygpt_net/controller/audio/audio.py +0 -0
  5. pygpt_net/controller/calendar/calendar.py +0 -0
  6. pygpt_net/controller/calendar/note.py +0 -0
  7. pygpt_net/controller/chat/chat.py +0 -0
  8. pygpt_net/controller/chat/handler/openai_stream.py +2 -1
  9. pygpt_net/controller/chat/handler/worker.py +0 -0
  10. pygpt_net/controller/chat/remote_tools.py +0 -0
  11. pygpt_net/controller/chat/render.py +0 -0
  12. pygpt_net/controller/chat/text.py +0 -0
  13. pygpt_net/controller/ctx/common.py +0 -0
  14. pygpt_net/controller/debug/debug.py +26 -2
  15. pygpt_net/controller/debug/fixtures.py +1 -1
  16. pygpt_net/controller/dialogs/confirm.py +15 -1
  17. pygpt_net/controller/dialogs/debug.py +2 -0
  18. pygpt_net/controller/lang/mapping.py +0 -0
  19. pygpt_net/controller/launcher/launcher.py +0 -0
  20. pygpt_net/controller/mode/mode.py +0 -0
  21. pygpt_net/controller/presets/presets.py +0 -0
  22. pygpt_net/controller/realtime/realtime.py +0 -0
  23. pygpt_net/controller/theme/theme.py +0 -0
  24. pygpt_net/controller/ui/mode.py +0 -0
  25. pygpt_net/controller/ui/tabs.py +0 -0
  26. pygpt_net/core/agents/agents.py +3 -1
  27. pygpt_net/core/agents/custom.py +150 -0
  28. pygpt_net/core/agents/provider.py +0 -0
  29. pygpt_net/core/builder/__init__.py +12 -0
  30. pygpt_net/core/builder/graph.py +478 -0
  31. pygpt_net/core/calendar/calendar.py +0 -0
  32. pygpt_net/core/ctx/ctx.py +2 -1
  33. pygpt_net/core/ctx/output.py +0 -0
  34. pygpt_net/core/debug/agent.py +0 -0
  35. pygpt_net/core/debug/agent_builder.py +29 -0
  36. pygpt_net/core/debug/console/console.py +0 -0
  37. pygpt_net/core/debug/db.py +0 -0
  38. pygpt_net/core/debug/debug.py +0 -0
  39. pygpt_net/core/debug/events.py +0 -0
  40. pygpt_net/core/debug/indexes.py +0 -0
  41. pygpt_net/core/debug/kernel.py +0 -0
  42. pygpt_net/core/debug/tabs.py +0 -0
  43. pygpt_net/core/filesystem/filesystem.py +0 -0
  44. pygpt_net/core/fixtures/__init__ +0 -0
  45. pygpt_net/core/fixtures/stream/__init__.py +0 -0
  46. pygpt_net/core/fixtures/stream/generator.py +0 -0
  47. pygpt_net/core/models/models.py +0 -0
  48. pygpt_net/core/render/plain/pid.py +0 -0
  49. pygpt_net/core/render/plain/renderer.py +26 -4
  50. pygpt_net/core/render/web/body.py +46 -4
  51. pygpt_net/core/render/web/debug.py +0 -0
  52. pygpt_net/core/render/web/helpers.py +0 -0
  53. pygpt_net/core/render/web/pid.py +0 -0
  54. pygpt_net/core/render/web/renderer.py +15 -20
  55. pygpt_net/core/tabs/tab.py +0 -0
  56. pygpt_net/core/tabs/tabs.py +0 -0
  57. pygpt_net/core/text/utils.py +0 -0
  58. pygpt_net/css.qrc +0 -0
  59. pygpt_net/css_rc.py +0 -0
  60. pygpt_net/data/config/config.json +7 -7
  61. pygpt_net/data/config/models.json +3 -3
  62. pygpt_net/data/css/web-blocks.css +9 -0
  63. pygpt_net/data/css/web-blocks.dark.css +6 -0
  64. pygpt_net/data/css/web-blocks.darkest.css +6 -0
  65. pygpt_net/data/css/web-chatgpt.css +14 -6
  66. pygpt_net/data/css/web-chatgpt.dark.css +6 -0
  67. pygpt_net/data/css/web-chatgpt.darkest.css +6 -0
  68. pygpt_net/data/css/web-chatgpt.light.css +6 -0
  69. pygpt_net/data/css/web-chatgpt_wide.css +14 -6
  70. pygpt_net/data/css/web-chatgpt_wide.dark.css +6 -0
  71. pygpt_net/data/css/web-chatgpt_wide.darkest.css +6 -0
  72. pygpt_net/data/css/web-chatgpt_wide.light.css +6 -0
  73. pygpt_net/data/fixtures/fake_stream.txt +14 -1
  74. pygpt_net/data/icons/case.svg +0 -0
  75. pygpt_net/data/icons/chat1.svg +0 -0
  76. pygpt_net/data/icons/chat2.svg +0 -0
  77. pygpt_net/data/icons/chat3.svg +0 -0
  78. pygpt_net/data/icons/chat4.svg +0 -0
  79. pygpt_net/data/icons/fit.svg +0 -0
  80. pygpt_net/data/icons/note1.svg +0 -0
  81. pygpt_net/data/icons/note2.svg +0 -0
  82. pygpt_net/data/icons/note3.svg +0 -0
  83. pygpt_net/data/icons/stt.svg +0 -0
  84. pygpt_net/data/icons/translate.svg +0 -0
  85. pygpt_net/data/icons/tts.svg +0 -0
  86. pygpt_net/data/icons/url.svg +0 -0
  87. pygpt_net/data/icons/vision.svg +0 -0
  88. pygpt_net/data/icons/web_off.svg +0 -0
  89. pygpt_net/data/icons/web_on.svg +0 -0
  90. pygpt_net/data/js/app/async.js +166 -0
  91. pygpt_net/data/js/app/bridge.js +88 -0
  92. pygpt_net/data/js/app/common.js +212 -0
  93. pygpt_net/data/js/app/config.js +223 -0
  94. pygpt_net/data/js/app/custom.js +961 -0
  95. pygpt_net/data/js/app/data.js +84 -0
  96. pygpt_net/data/js/app/dom.js +322 -0
  97. pygpt_net/data/js/app/events.js +400 -0
  98. pygpt_net/data/js/app/highlight.js +542 -0
  99. pygpt_net/data/js/app/logger.js +305 -0
  100. pygpt_net/data/js/app/markdown.js +1137 -0
  101. pygpt_net/data/js/app/math.js +167 -0
  102. pygpt_net/data/js/app/nodes.js +395 -0
  103. pygpt_net/data/js/app/queue.js +260 -0
  104. pygpt_net/data/js/app/raf.js +250 -0
  105. pygpt_net/data/js/app/runtime.js +582 -0
  106. pygpt_net/data/js/app/scroll.js +433 -0
  107. pygpt_net/data/js/app/stream.js +2708 -0
  108. pygpt_net/data/js/app/template.js +287 -0
  109. pygpt_net/data/js/app/tool.js +87 -0
  110. pygpt_net/data/js/app/ui.js +86 -0
  111. pygpt_net/data/js/app/user.js +380 -0
  112. pygpt_net/data/js/app/utils.js +64 -0
  113. pygpt_net/data/js/app.min.js +880 -0
  114. pygpt_net/data/js/markdown-it/markdown-it-katex.min.js +1 -1
  115. pygpt_net/data/js/markdown-it/markdown-it.min.js +0 -0
  116. pygpt_net/data/locale/locale.de.ini +0 -0
  117. pygpt_net/data/locale/locale.en.ini +7 -0
  118. pygpt_net/data/locale/locale.es.ini +0 -0
  119. pygpt_net/data/locale/locale.fr.ini +0 -0
  120. pygpt_net/data/locale/locale.it.ini +0 -0
  121. pygpt_net/data/locale/locale.pl.ini +0 -0
  122. pygpt_net/data/locale/locale.uk.ini +0 -0
  123. pygpt_net/data/locale/locale.zh.ini +0 -0
  124. pygpt_net/data/locale/plugin.agent.de.ini +0 -0
  125. pygpt_net/data/locale/plugin.agent.en.ini +0 -0
  126. pygpt_net/data/locale/plugin.agent.es.ini +0 -0
  127. pygpt_net/data/locale/plugin.agent.fr.ini +0 -0
  128. pygpt_net/data/locale/plugin.agent.it.ini +0 -0
  129. pygpt_net/data/locale/plugin.agent.pl.ini +0 -0
  130. pygpt_net/data/locale/plugin.agent.uk.ini +0 -0
  131. pygpt_net/data/locale/plugin.agent.zh.ini +0 -0
  132. pygpt_net/data/locale/plugin.audio_input.de.ini +0 -0
  133. pygpt_net/data/locale/plugin.audio_input.en.ini +0 -0
  134. pygpt_net/data/locale/plugin.audio_input.es.ini +0 -0
  135. pygpt_net/data/locale/plugin.audio_input.fr.ini +0 -0
  136. pygpt_net/data/locale/plugin.audio_input.it.ini +0 -0
  137. pygpt_net/data/locale/plugin.audio_input.pl.ini +0 -0
  138. pygpt_net/data/locale/plugin.audio_input.uk.ini +0 -0
  139. pygpt_net/data/locale/plugin.audio_input.zh.ini +0 -0
  140. pygpt_net/data/locale/plugin.audio_output.de.ini +0 -0
  141. pygpt_net/data/locale/plugin.audio_output.en.ini +0 -0
  142. pygpt_net/data/locale/plugin.audio_output.es.ini +0 -0
  143. pygpt_net/data/locale/plugin.audio_output.fr.ini +0 -0
  144. pygpt_net/data/locale/plugin.audio_output.it.ini +0 -0
  145. pygpt_net/data/locale/plugin.audio_output.pl.ini +0 -0
  146. pygpt_net/data/locale/plugin.audio_output.uk.ini +0 -0
  147. pygpt_net/data/locale/plugin.audio_output.zh.ini +0 -0
  148. pygpt_net/data/locale/plugin.cmd_api.de.ini +0 -0
  149. pygpt_net/data/locale/plugin.cmd_api.en.ini +0 -0
  150. pygpt_net/data/locale/plugin.cmd_api.es.ini +0 -0
  151. pygpt_net/data/locale/plugin.cmd_api.fr.ini +0 -0
  152. pygpt_net/data/locale/plugin.cmd_api.it.ini +0 -0
  153. pygpt_net/data/locale/plugin.cmd_api.pl.ini +0 -0
  154. pygpt_net/data/locale/plugin.cmd_api.uk.ini +0 -0
  155. pygpt_net/data/locale/plugin.cmd_api.zh.ini +0 -0
  156. pygpt_net/data/locale/plugin.cmd_code_interpreter.de.ini +0 -0
  157. pygpt_net/data/locale/plugin.cmd_code_interpreter.en.ini +0 -0
  158. pygpt_net/data/locale/plugin.cmd_code_interpreter.es.ini +0 -0
  159. pygpt_net/data/locale/plugin.cmd_code_interpreter.fr.ini +0 -0
  160. pygpt_net/data/locale/plugin.cmd_code_interpreter.it.ini +0 -0
  161. pygpt_net/data/locale/plugin.cmd_code_interpreter.pl.ini +0 -0
  162. pygpt_net/data/locale/plugin.cmd_code_interpreter.uk.ini +0 -0
  163. pygpt_net/data/locale/plugin.cmd_code_interpreter.zh.ini +0 -0
  164. pygpt_net/data/locale/plugin.cmd_custom.de.ini +0 -0
  165. pygpt_net/data/locale/plugin.cmd_custom.en.ini +0 -0
  166. pygpt_net/data/locale/plugin.cmd_custom.es.ini +0 -0
  167. pygpt_net/data/locale/plugin.cmd_custom.fr.ini +0 -0
  168. pygpt_net/data/locale/plugin.cmd_custom.it.ini +0 -0
  169. pygpt_net/data/locale/plugin.cmd_custom.pl.ini +0 -0
  170. pygpt_net/data/locale/plugin.cmd_custom.uk.ini +0 -0
  171. pygpt_net/data/locale/plugin.cmd_custom.zh.ini +0 -0
  172. pygpt_net/data/locale/plugin.cmd_files.de.ini +0 -0
  173. pygpt_net/data/locale/plugin.cmd_files.en.ini +0 -0
  174. pygpt_net/data/locale/plugin.cmd_files.es.ini +0 -0
  175. pygpt_net/data/locale/plugin.cmd_files.fr.ini +0 -0
  176. pygpt_net/data/locale/plugin.cmd_files.it.ini +0 -0
  177. pygpt_net/data/locale/plugin.cmd_files.pl.ini +0 -0
  178. pygpt_net/data/locale/plugin.cmd_files.uk.ini +0 -0
  179. pygpt_net/data/locale/plugin.cmd_files.zh.ini +0 -0
  180. pygpt_net/data/locale/plugin.cmd_history.de.ini +0 -0
  181. pygpt_net/data/locale/plugin.cmd_history.en.ini +0 -0
  182. pygpt_net/data/locale/plugin.cmd_history.es.ini +0 -0
  183. pygpt_net/data/locale/plugin.cmd_history.fr.ini +0 -0
  184. pygpt_net/data/locale/plugin.cmd_history.it.ini +0 -0
  185. pygpt_net/data/locale/plugin.cmd_history.pl.ini +0 -0
  186. pygpt_net/data/locale/plugin.cmd_history.uk.ini +0 -0
  187. pygpt_net/data/locale/plugin.cmd_history.zh.ini +0 -0
  188. pygpt_net/data/locale/plugin.cmd_mouse_control.de.ini +0 -0
  189. pygpt_net/data/locale/plugin.cmd_mouse_control.en.ini +0 -0
  190. pygpt_net/data/locale/plugin.cmd_mouse_control.es.ini +0 -0
  191. pygpt_net/data/locale/plugin.cmd_mouse_control.fr.ini +0 -0
  192. pygpt_net/data/locale/plugin.cmd_mouse_control.it.ini +0 -0
  193. pygpt_net/data/locale/plugin.cmd_mouse_control.pl.ini +0 -0
  194. pygpt_net/data/locale/plugin.cmd_mouse_control.uk.ini +0 -0
  195. pygpt_net/data/locale/plugin.cmd_mouse_control.zh.ini +0 -0
  196. pygpt_net/data/locale/plugin.cmd_serial.de.ini +0 -0
  197. pygpt_net/data/locale/plugin.cmd_serial.en.ini +0 -0
  198. pygpt_net/data/locale/plugin.cmd_serial.es.ini +0 -0
  199. pygpt_net/data/locale/plugin.cmd_serial.fr.ini +0 -0
  200. pygpt_net/data/locale/plugin.cmd_serial.it.ini +0 -0
  201. pygpt_net/data/locale/plugin.cmd_serial.pl.ini +0 -0
  202. pygpt_net/data/locale/plugin.cmd_serial.uk.ini +0 -0
  203. pygpt_net/data/locale/plugin.cmd_serial.zh.ini +0 -0
  204. pygpt_net/data/locale/plugin.cmd_system.de.ini +0 -0
  205. pygpt_net/data/locale/plugin.cmd_system.en.ini +0 -0
  206. pygpt_net/data/locale/plugin.cmd_system.es.ini +0 -0
  207. pygpt_net/data/locale/plugin.cmd_system.fr.ini +0 -0
  208. pygpt_net/data/locale/plugin.cmd_system.it.ini +0 -0
  209. pygpt_net/data/locale/plugin.cmd_system.pl.ini +0 -0
  210. pygpt_net/data/locale/plugin.cmd_system.uk.ini +0 -0
  211. pygpt_net/data/locale/plugin.cmd_system.zh.ini +0 -0
  212. pygpt_net/data/locale/plugin.cmd_web.de.ini +0 -0
  213. pygpt_net/data/locale/plugin.cmd_web.en.ini +0 -0
  214. pygpt_net/data/locale/plugin.cmd_web.es.ini +0 -0
  215. pygpt_net/data/locale/plugin.cmd_web.fr.ini +0 -0
  216. pygpt_net/data/locale/plugin.cmd_web.it.ini +0 -0
  217. pygpt_net/data/locale/plugin.cmd_web.pl.ini +0 -0
  218. pygpt_net/data/locale/plugin.cmd_web.uk.ini +0 -0
  219. pygpt_net/data/locale/plugin.cmd_web.zh.ini +0 -0
  220. pygpt_net/data/locale/plugin.crontab.de.ini +0 -0
  221. pygpt_net/data/locale/plugin.crontab.en.ini +0 -0
  222. pygpt_net/data/locale/plugin.crontab.es.ini +0 -0
  223. pygpt_net/data/locale/plugin.crontab.fr.ini +0 -0
  224. pygpt_net/data/locale/plugin.crontab.it.ini +0 -0
  225. pygpt_net/data/locale/plugin.crontab.pl.ini +0 -0
  226. pygpt_net/data/locale/plugin.crontab.uk.ini +0 -0
  227. pygpt_net/data/locale/plugin.crontab.zh.ini +0 -0
  228. pygpt_net/data/locale/plugin.experts.de.ini +0 -0
  229. pygpt_net/data/locale/plugin.experts.en.ini +0 -0
  230. pygpt_net/data/locale/plugin.experts.es.ini +0 -0
  231. pygpt_net/data/locale/plugin.experts.fr.ini +0 -0
  232. pygpt_net/data/locale/plugin.experts.it.ini +0 -0
  233. pygpt_net/data/locale/plugin.experts.pl.ini +0 -0
  234. pygpt_net/data/locale/plugin.experts.uk.ini +0 -0
  235. pygpt_net/data/locale/plugin.experts.zh.ini +0 -0
  236. pygpt_net/data/locale/plugin.extra_prompt.de.ini +0 -0
  237. pygpt_net/data/locale/plugin.extra_prompt.en.ini +0 -0
  238. pygpt_net/data/locale/plugin.extra_prompt.es.ini +0 -0
  239. pygpt_net/data/locale/plugin.extra_prompt.fr.ini +0 -0
  240. pygpt_net/data/locale/plugin.extra_prompt.it.ini +0 -0
  241. pygpt_net/data/locale/plugin.extra_prompt.pl.ini +0 -0
  242. pygpt_net/data/locale/plugin.extra_prompt.uk.ini +0 -0
  243. pygpt_net/data/locale/plugin.extra_prompt.zh.ini +0 -0
  244. pygpt_net/data/locale/plugin.idx_llama_index.de.ini +0 -0
  245. pygpt_net/data/locale/plugin.idx_llama_index.en.ini +0 -0
  246. pygpt_net/data/locale/plugin.idx_llama_index.es.ini +0 -0
  247. pygpt_net/data/locale/plugin.idx_llama_index.fr.ini +0 -0
  248. pygpt_net/data/locale/plugin.idx_llama_index.it.ini +0 -0
  249. pygpt_net/data/locale/plugin.idx_llama_index.pl.ini +0 -0
  250. pygpt_net/data/locale/plugin.idx_llama_index.uk.ini +0 -0
  251. pygpt_net/data/locale/plugin.idx_llama_index.zh.ini +0 -0
  252. pygpt_net/data/locale/plugin.mailer.en.ini +0 -0
  253. pygpt_net/data/locale/plugin.mcp.en.ini +0 -0
  254. pygpt_net/data/locale/plugin.openai_dalle.de.ini +0 -0
  255. pygpt_net/data/locale/plugin.openai_dalle.en.ini +0 -0
  256. pygpt_net/data/locale/plugin.openai_dalle.es.ini +0 -0
  257. pygpt_net/data/locale/plugin.openai_dalle.fr.ini +0 -0
  258. pygpt_net/data/locale/plugin.openai_dalle.it.ini +0 -0
  259. pygpt_net/data/locale/plugin.openai_dalle.pl.ini +0 -0
  260. pygpt_net/data/locale/plugin.openai_dalle.uk.ini +0 -0
  261. pygpt_net/data/locale/plugin.openai_dalle.zh.ini +0 -0
  262. pygpt_net/data/locale/plugin.openai_vision.de.ini +0 -0
  263. pygpt_net/data/locale/plugin.openai_vision.en.ini +0 -0
  264. pygpt_net/data/locale/plugin.openai_vision.es.ini +0 -0
  265. pygpt_net/data/locale/plugin.openai_vision.fr.ini +0 -0
  266. pygpt_net/data/locale/plugin.openai_vision.it.ini +0 -0
  267. pygpt_net/data/locale/plugin.openai_vision.pl.ini +0 -0
  268. pygpt_net/data/locale/plugin.openai_vision.uk.ini +0 -0
  269. pygpt_net/data/locale/plugin.openai_vision.zh.ini +0 -0
  270. pygpt_net/data/locale/plugin.osm.en.ini +0 -0
  271. pygpt_net/data/locale/plugin.real_time.de.ini +0 -0
  272. pygpt_net/data/locale/plugin.real_time.en.ini +0 -0
  273. pygpt_net/data/locale/plugin.real_time.es.ini +0 -0
  274. pygpt_net/data/locale/plugin.real_time.fr.ini +0 -0
  275. pygpt_net/data/locale/plugin.real_time.it.ini +0 -0
  276. pygpt_net/data/locale/plugin.real_time.pl.ini +0 -0
  277. pygpt_net/data/locale/plugin.real_time.uk.ini +0 -0
  278. pygpt_net/data/locale/plugin.real_time.zh.ini +0 -0
  279. pygpt_net/data/locale/plugin.voice_control.de.ini +0 -0
  280. pygpt_net/data/locale/plugin.voice_control.en.ini +0 -0
  281. pygpt_net/data/locale/plugin.voice_control.es.ini +0 -0
  282. pygpt_net/data/locale/plugin.voice_control.fr.ini +0 -0
  283. pygpt_net/data/locale/plugin.voice_control.it.ini +0 -0
  284. pygpt_net/data/locale/plugin.voice_control.pl.ini +0 -0
  285. pygpt_net/data/locale/plugin.voice_control.uk.ini +0 -0
  286. pygpt_net/data/locale/plugin.voice_control.zh.ini +0 -0
  287. pygpt_net/data/locale/plugin.wolfram.en.ini +0 -0
  288. pygpt_net/fonts.qrc +0 -0
  289. pygpt_net/fonts_rc.py +0 -0
  290. pygpt_net/icons.qrc +0 -0
  291. pygpt_net/icons_rc.py +0 -0
  292. pygpt_net/item/agent.py +62 -0
  293. pygpt_net/item/builder_layout.py +62 -0
  294. pygpt_net/js.qrc +24 -1
  295. pygpt_net/js_rc.py +51394 -33687
  296. pygpt_net/plugin/base/worker.py +0 -0
  297. pygpt_net/plugin/mcp/__init__.py +0 -0
  298. pygpt_net/plugin/mcp/config.py +0 -0
  299. pygpt_net/plugin/mcp/plugin.py +0 -0
  300. pygpt_net/plugin/mcp/worker.py +0 -0
  301. pygpt_net/plugin/osm/__init__.py +0 -0
  302. pygpt_net/plugin/osm/config.py +0 -0
  303. pygpt_net/plugin/osm/plugin.py +0 -0
  304. pygpt_net/plugin/osm/worker.py +0 -0
  305. pygpt_net/plugin/wolfram/__init__.py +0 -0
  306. pygpt_net/plugin/wolfram/config.py +0 -0
  307. pygpt_net/plugin/wolfram/plugin.py +0 -0
  308. pygpt_net/plugin/wolfram/worker.py +0 -0
  309. pygpt_net/provider/api/anthropic/tools.py +0 -0
  310. pygpt_net/provider/api/google/__init__.py +0 -0
  311. pygpt_net/provider/api/google/video.py +0 -0
  312. pygpt_net/provider/api/openai/agents/experts.py +0 -0
  313. pygpt_net/provider/api/openai/agents/remote_tools.py +0 -0
  314. pygpt_net/provider/api/openai/remote_tools.py +0 -0
  315. pygpt_net/provider/api/openai/responses.py +0 -0
  316. pygpt_net/provider/api/x_ai/__init__.py +0 -0
  317. pygpt_net/provider/api/x_ai/remote.py +0 -0
  318. pygpt_net/provider/core/agent/__init__.py +10 -0
  319. pygpt_net/provider/core/agent/base.py +51 -0
  320. pygpt_net/provider/core/agent/json_file.py +200 -0
  321. pygpt_net/provider/core/config/patch.py +18 -0
  322. pygpt_net/provider/core/config/patches/__init__.py +0 -0
  323. pygpt_net/provider/core/config/patches/patch_before_2_6_42.py +0 -0
  324. pygpt_net/provider/core/ctx/db_sqlite/storage.py +0 -0
  325. pygpt_net/provider/core/model/patches/__init__.py +0 -0
  326. pygpt_net/provider/core/model/patches/patch_before_2_6_42.py +0 -0
  327. pygpt_net/provider/core/preset/patch.py +0 -0
  328. pygpt_net/provider/core/preset/patches/__init__.py +0 -0
  329. pygpt_net/provider/core/preset/patches/patch_before_2_6_42.py +0 -0
  330. pygpt_net/provider/llms/base.py +0 -0
  331. pygpt_net/provider/llms/deepseek_api.py +0 -0
  332. pygpt_net/provider/llms/google.py +0 -0
  333. pygpt_net/provider/llms/hugging_face_api.py +0 -0
  334. pygpt_net/provider/llms/hugging_face_embedding.py +0 -0
  335. pygpt_net/provider/llms/hugging_face_router.py +0 -0
  336. pygpt_net/provider/llms/local.py +0 -0
  337. pygpt_net/provider/llms/mistral.py +0 -0
  338. pygpt_net/provider/llms/open_router.py +0 -0
  339. pygpt_net/provider/llms/perplexity.py +0 -0
  340. pygpt_net/provider/llms/utils.py +0 -0
  341. pygpt_net/provider/llms/voyage.py +0 -0
  342. pygpt_net/provider/llms/x_ai.py +0 -0
  343. pygpt_net/tools/agent_builder/__init__.py +12 -0
  344. pygpt_net/tools/agent_builder/tool.py +292 -0
  345. pygpt_net/tools/agent_builder/ui/__init__.py +0 -0
  346. pygpt_net/tools/agent_builder/ui/dialogs.py +152 -0
  347. pygpt_net/tools/agent_builder/ui/list.py +228 -0
  348. pygpt_net/tools/code_interpreter/ui/html.py +0 -0
  349. pygpt_net/tools/code_interpreter/ui/widgets.py +0 -0
  350. pygpt_net/tools/html_canvas/tool.py +23 -6
  351. pygpt_net/tools/html_canvas/ui/widgets.py +224 -2
  352. pygpt_net/ui/layout/chat/chat.py +0 -0
  353. pygpt_net/ui/main.py +10 -9
  354. pygpt_net/ui/menu/debug.py +39 -1
  355. pygpt_net/ui/widget/builder/__init__.py +12 -0
  356. pygpt_net/ui/widget/builder/editor.py +2001 -0
  357. pygpt_net/ui/widget/draw/painter.py +0 -0
  358. pygpt_net/ui/widget/element/labels.py +9 -4
  359. pygpt_net/ui/widget/lists/db.py +0 -0
  360. pygpt_net/ui/widget/lists/debug.py +0 -0
  361. pygpt_net/ui/widget/tabs/body.py +0 -0
  362. pygpt_net/ui/widget/textarea/input.py +17 -8
  363. pygpt_net/ui/widget/textarea/output.py +21 -1
  364. pygpt_net/ui/widget/textarea/web.py +29 -2
  365. pygpt_net/utils.py +40 -0
  366. {pygpt_net-2.6.54.dist-info → pygpt_net-2.6.56.dist-info}/METADATA +13 -2
  367. {pygpt_net-2.6.54.dist-info → pygpt_net-2.6.56.dist-info}/RECORD +86 -47
  368. pygpt_net/data/js/app.js +0 -5869
  369. {pygpt_net-2.6.54.dist-info → pygpt_net-2.6.56.dist-info}/LICENSE +0 -0
  370. {pygpt_net-2.6.54.dist-info → pygpt_net-2.6.56.dist-info}/WHEEL +0 -0
  371. {pygpt_net-2.6.54.dist-info → pygpt_net-2.6.56.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,2708 @@
1
+ // ==========================================================================
2
+ // Stream engine
3
+ // ==========================================================================
4
+
5
+ // Precompiled, module-scoped regexes to avoid per-call allocations in hot paths.
6
+ const RE_SAFE_BREAK = /\s|[.,;:!?()\[\]{}'"«»„”“—–\-…>]/;
7
+ const RE_STRUCT_BOUNDARY = /\n(\n|[-*]\s|\d+\.\s|#{1,6}\s|>\s)/;
8
+ const RE_MD_INLINE_TRIGGER = /(\*\*|__|[_`]|~~|\[[^\]]+\]\([^)]+\))/;
9
+ const RE_LINE_END = /[\n\r]$/;
10
+
11
+ // StreamEngine manages streaming text and code rendering in the UI.
12
+ class StreamEngine {
13
+
14
+ // Constructor: wire dependencies and initialize all streaming state.
15
+ constructor(cfg, dom, renderer, math, highlighter, codeScroll, scrollMgr, raf, asyncer, logger) {
16
+ // Store collaborators
17
+ this.cfg = cfg;
18
+ this.dom = dom;
19
+ this.renderer = renderer;
20
+ this.math = math;
21
+ this.highlighter = highlighter;
22
+ this.codeScroll = codeScroll;
23
+ this.scrollMgr = scrollMgr;
24
+ this.raf = raf;
25
+ this.asyncer = asyncer;
26
+ this.logger = logger || new Logger(cfg);
27
+
28
+ // Rope-like buffer: streamBuf holds the materialized prefix; _sbParts keeps recent tail parts; _sbLen tracks their length.
29
+ this.streamBuf = '';
30
+ this._sbParts = [];
31
+ this._sbLen = 0;
32
+
33
+ // NOTE: materialize tail only when it grows large, to avoid frequent huge string copies.
34
+ this._tailMaterializeAt = ((this.cfg && this.cfg.STREAM && (this.cfg.STREAM.MATERIALIZE_TAIL_AT_LEN | 0)) || 262144); // 256 KiB
35
+
36
+ // Fence (code block) parsing state
37
+ this.fenceOpen = false;
38
+ this.fenceMark = '`';
39
+ this.fenceLen = 3;
40
+ this.fenceTail = '';
41
+ this.fenceBuf = '';
42
+ this.lastSnapshotTs = 0;
43
+ this.nextSnapshotStep = cfg.PROFILE_TEXT.base;
44
+ this.snapshotScheduled = false;
45
+ this.snapshotRAF = 0;
46
+
47
+ // Simple counters for currently open code stream
48
+ this.codeStream = {
49
+ open: false,
50
+ lines: 0,
51
+ chars: 0
52
+ };
53
+ this.activeCode = null;
54
+
55
+ // Flags that control post-processing
56
+ this.suppressPostFinalizePass = false;
57
+
58
+ this._promoteScheduled = false;
59
+
60
+ // Ensure first fence-open is materialized immediately when stream starts with code.
61
+ this._firstCodeOpenSnapDone = false;
62
+
63
+ // Streaming mode flag.
64
+ this.isStreaming = false;
65
+
66
+ // Tracks whether renderSnapshot injected a one-off synthetic EOL for parsing an open fence.
67
+ this._lastInjectedEOL = false;
68
+
69
+ this._customFenceSpecs = [];
70
+ this._fenceCustom = null;
71
+
72
+ // Plain streaming state for non-code text
73
+ this.plain = {
74
+ active: false,
75
+ container: null,
76
+ anchor: null,
77
+ lastMDTs: 0, // last inline-parse ts
78
+ noMdNL: 0, // number of consecutive newlines with no markdown markers (acts as "plain lines")
79
+ suppressInline: false, // disables inline parsing during long text streaks or when fully plain
80
+ forceFullMDOnce: false, // request one full MD snapshot after markdown detected
81
+ enabled: false, // plain-text mode is enabled only after threshold of "no markdown lines"
82
+ _carry: '' // trailing carry used to keep partial last word out of DOM until a safe break
83
+ };
84
+
85
+ // Precompiled quick markdown detector (inline + common block markers)
86
+ this._mdQuickRe = /(\*\*|__|~~|`|!\[|\[[^\]]+\]\([^)]+\)|^> |\n> |\n#{1,6}\s|\n[-*+]\s|\n\d+\.\s)/m;
87
+
88
+ // Reuse compiled regex in hot paths (GC friendly).
89
+ this._reSafeBreak = RE_SAFE_BREAK;
90
+ this._reStructBoundary = RE_STRUCT_BOUNDARY;
91
+ this._reMDInlineTrigger = RE_MD_INLINE_TRIGGER;
92
+ this._reLineEnd = RE_LINE_END;
93
+
94
+ // Reusable <template> for parsing small HTML snippets (avoids creating many templates).
95
+ // Note: a single template is safe here because operations on it are serialized.
96
+ this._tpl = (typeof document !== 'undefined') ? document.createElement('template') : null;
97
+
98
+ // Fast character classifiers used for safe-boundary decisions (kept stable and GC-friendly).
99
+ this._isWordChar = (ch) => {
100
+ if (!ch) return false;
101
+ const c = ch.charCodeAt(0);
102
+ // ASCII letters/digits
103
+ if ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122)) return true;
104
+ // Latin-1 + Latin Extended (covers most European diacritics)
105
+ if (c >= 0x00C0 && c <= 0x02AF) return true;
106
+ return false;
107
+ };
108
+ this._isSafeBreakChar = (ch) => {
109
+ if (!ch) return false;
110
+ // Reuse precompiled regex to avoid per-call RegExp allocations.
111
+ return this._reSafeBreak.test(ch);
112
+ };
113
+
114
+ // DEBUG
115
+ this._d('init', {
116
+ materializeTailAt: this._tailMaterializeAt,
117
+ hasTpl: !!this._tpl
118
+ });
119
+ }
120
+
121
+ // Debug helper: write structured log lines for the stream engine.
122
+ _d(tag, data) {
123
+ try {
124
+ const lg = this.logger || (this.cfg && this.cfg.logger) || (window.runtime && runtime.logger) || null;
125
+ if (!lg || typeof lg.debug !== 'function') return;
126
+ lg.debug_obj("STREAM", tag, data);
127
+ } catch (_) {}
128
+ }
129
+
130
+ // Register custom fence (code block) open/close tokens.
131
+ setCustomFenceSpecs(specs) {
132
+ // Keep a shallow copy to avoid external mutation.
133
+ this._customFenceSpecs = Array.isArray(specs) ? specs.slice() : [];
134
+ // DEBUG
135
+ this._d('customFence.set', {
136
+ count: (this._customFenceSpecs || []).length
137
+ });
138
+ }
139
+
140
+ // Append incoming chunk to the lightweight tail buffer.
141
+ _appendChunk(s) {
142
+ if (!s) return;
143
+ // DEBUG
144
+ this._d('chunk.append', {
145
+ len: s.length,
146
+ nl: Utils.countNewlines(s),
147
+ head: String(s).slice(0, 160),
148
+ tail: String(s).slice(-160),
149
+ hasAngle: /[<>]/.test(String(s)),
150
+ hasFenceToken: /```|~~~/.test(String(s))
151
+ });
152
+ // Store piece and increase length counter; join later to avoid frequent string copies.
153
+ this._sbParts.push(s);
154
+ this._sbLen += s.length;
155
+
156
+ // NOTE: materialize tail only when it grows beyond the threshold to keep part count bounded.
157
+ if (this._sbLen >= this._tailMaterializeAt) {
158
+ this._materializeTail();
159
+ }
160
+ }
161
+
162
+ // NOTE: materialize current tail parts into streamBuf only on demand (threshold/finish).
163
+ _materializeTail() {
164
+ // DEBUG
165
+ this._d('tail.materialize', {
166
+ streamBufLen: this.streamBuf.length,
167
+ parts: this._sbParts.length,
168
+ sbLen: this._sbLen
169
+ });
170
+ if (this._sbLen > 0) {
171
+ this.streamBuf += (this._sbParts.length === 1 ? this._sbParts[0] : this._sbParts.join(''));
172
+ this._sbParts.length = 0;
173
+ this._sbLen = 0;
174
+ }
175
+ }
176
+
177
+ // Return the current total streamed length (materialized + tail parts).
178
+ getStreamLength() {
179
+ return (this.streamBuf.length + this._sbLen);
180
+ }
181
+
182
+ // NOTE: Zero-copy: return full text without forcing a permanent concatenation into streamBuf.
183
+ getStreamText() {
184
+ // Join tail on the fly for the caller only.
185
+ if (this._sbLen > 0) {
186
+ return this.streamBuf + (this._sbParts.length === 1 ? this._sbParts[0] : this._sbParts.join(''));
187
+ }
188
+ return this.streamBuf;
189
+ }
190
+
191
+ // Compute delta since prevLen without materializing the whole buffer.
192
+ // This avoids building large temporary strings when we only need the tail slice.
193
+ getDeltaSince(prevLen) {
194
+ // Fast bail-outs
195
+ const total = this.getStreamLength();
196
+ if (prevLen >= total) return '';
197
+ const bufLen = this.streamBuf.length;
198
+
199
+ // If prevLen is at or before the materialized prefix, delta is the whole current tail.
200
+ if (prevLen <= bufLen) {
201
+ if (this._sbLen === 0) return '';
202
+ const out = (this._sbParts.length === 1 ? this._sbParts[0] : this._sbParts.join(''));
203
+ // DEBUG (only if interesting)
204
+ if (/[<>]/.test(out)) this._d('delta.since', {
205
+ prevLen,
206
+ deltaLen: out.length,
207
+ head: out.slice(0, 80),
208
+ tail: out.slice(-80)
209
+ });
210
+ return out;
211
+ }
212
+
213
+ // Otherwise delta starts inside the tail parts.
214
+ let off = prevLen - bufLen;
215
+ let out = null; // lazy array to minimize allocations
216
+ for (let i = 0; i < this._sbParts.length; i++) {
217
+ const p = this._sbParts[i];
218
+ const plen = p.length;
219
+ if (off >= plen) {
220
+ off -= plen;
221
+ continue;
222
+ }
223
+ const slice = off > 0 ? p.slice(off) : p;
224
+ if (out === null) out = [slice];
225
+ else out.push(slice);
226
+ off = 0;
227
+ }
228
+ if (!out) return '';
229
+ const ret = (out.length === 1 ? out[0] : out.join(''));
230
+ // DEBUG (only if interesting)
231
+ if (/[<>]/.test(ret)) this._d('delta.since', {
232
+ prevLen,
233
+ deltaLen: ret.length,
234
+ head: ret.slice(0, 80),
235
+ tail: ret.slice(-80)
236
+ });
237
+ return ret;
238
+ }
239
+
240
+ // Drop all buffers (materialized and tail).
241
+ _clearStreamBuffer() {
242
+ // DEBUG
243
+ this._d('buf.clear', {
244
+ streamBufLen: this.streamBuf.length,
245
+ parts: this._sbParts.length,
246
+ sbLen: this._sbLen
247
+ });
248
+ this.streamBuf = '';
249
+ this._sbParts.length = 0;
250
+ this._sbLen = 0;
251
+ }
252
+
253
+ // Plain helpers
254
+
255
+ // Compute threshold (lines without Markdown) required to enable plain-text mode.
256
+ _plainThreshold() {
257
+ const STREAM = (this.cfg && this.cfg.STREAM) ? this.cfg.STREAM : {};
258
+ const thr = (STREAM.PLAIN_ACTIVATE_AFTER_LINES != null) ? STREAM.PLAIN_ACTIVATE_AFTER_LINES : 10;
259
+ return Math.max(1, thr | 0);
260
+ }
261
+
262
+ // Reset "plain text streaming" state.
263
+ _plainReset() {
264
+ this.plain.active = false;
265
+ this.plain.container = null;
266
+ this.plain.anchor = null;
267
+ this.plain.lastMDTs = 0;
268
+ this.plain.noMdNL = 0;
269
+ this.plain.suppressInline = false;
270
+ this.plain.forceFullMDOnce = false;
271
+ this.plain.enabled = false;
272
+ this.plain._carry = '';
273
+ // DEBUG
274
+ this._d('plain.reset', {});
275
+ }
276
+
277
+ // Ensure the plain text streaming host container is present and correctly placed.
278
+ _plainEnsureContainer(snap) {
279
+ // Reuse the existing container when still attached.
280
+ if (this.plain.container && this.plain.container.isConnected && this.plain.anchor && this.plain.anchor.parentNode === this.plain.container) {
281
+ // Ensure parent is correct (inside pending wrapper if any)
282
+ const needParent = this._choosePlainParent(snap);
283
+ if (needParent && this.plain.container.parentNode !== needParent) {
284
+ try {
285
+ needParent.appendChild(this.plain.container);
286
+ } catch (_) {}
287
+ }
288
+ return this.plain.container;
289
+ }
290
+
291
+ // Decide where to place the host (pending wrapper if present; else root)
292
+ const parent = this._choosePlainParent(snap) || snap;
293
+
294
+ // Inline host to avoid layout line breaks between consecutive hosts.
295
+ const host = document.createElement('span');
296
+ host.setAttribute('data-plain-stream', '1');
297
+ host.style.whiteSpace = 'pre-wrap';
298
+ host.style.display = 'inline';
299
+ host.style.wordBreak = 'normal';
300
+ host.style.overflowWrap = 'normal';
301
+
302
+ // Text node acts as the visible tail; comment is a stable anchor.
303
+ const tail = document.createTextNode('');
304
+ const anchor = document.createComment('ps-tail');
305
+ host.appendChild(tail);
306
+ host.appendChild(anchor);
307
+
308
+ try {
309
+ parent.appendChild(host);
310
+ } catch (_) {
311
+ snap.appendChild(host);
312
+ }
313
+
314
+ this.plain.container = host;
315
+ this.plain.anchor = anchor;
316
+ this.plain.active = true;
317
+
318
+ this._d('plain.ensureHost', {
319
+ created: true
320
+ });
321
+ return host;
322
+ }
323
+
324
+ // Compute a safe flush index for appending into DOM: keep trailing partial word out of DOM until a safe break.
325
+ _findSafeFlushIndex(text) {
326
+ if (!text) return 0;
327
+ // Newline → always flush whole slice, but still avoid splitting an angle-like token
328
+ if (text.indexOf('\n') !== -1 || text.indexOf('\r') !== -1) {
329
+ if (/[<>]/.test(text)) this._d('plain.flushIdx.nl', {
330
+ textLen: text.length
331
+ });
332
+ return this._retractIfInsideAngleToken(text, text.length);
333
+ }
334
+
335
+ const PLAIN = (this.cfg && this.cfg.STREAM && this.cfg.STREAM.PLAIN) ? this.cfg.STREAM.PLAIN : {};
336
+ const LOOKBACK = (PLAIN.COHESION_LOOKBACK != null) ? PLAIN.COHESION_LOOKBACK : 96;
337
+ const STICKY = (PLAIN.COHESION_STICKY_TAIL != null) ? PLAIN.COHESION_STICKY_TAIL : 8;
338
+ const FLUSH_AT = (PLAIN.COHESION_FLUSH_AT_LEN != null) ? PLAIN.COHESION_FLUSH_AT_LEN : 512;
339
+
340
+ if (text.length >= FLUSH_AT) {
341
+ const at = Math.max(0, text.length - STICKY);
342
+ if (/[<>]/.test(text)) this._d('plain.flushIdx.hard', {
343
+ textLen: text.length,
344
+ at
345
+ });
346
+ return this._retractIfInsideAngleToken(text, at);
347
+ }
348
+
349
+ const start = Math.max(0, text.length - LOOKBACK);
350
+ for (let i = text.length - 1; i >= start; i--) {
351
+ const ch = text[i];
352
+ if (this._isSafeBreakChar(ch)) {
353
+ if (/[<>]/.test(text)) this._d('plain.flushIdx.safe', {
354
+ textLen: text.length,
355
+ i,
356
+ ch
357
+ });
358
+ return this._retractIfInsideAngleToken(text, i + 1);
359
+ }
360
+ }
361
+ const at = Math.max(0, text.length - STICKY);
362
+ if (/[<>]/.test(text)) this._d('plain.flushIdx.sticky', {
363
+ textLen: text.length,
364
+ at
365
+ });
366
+ return this._retractIfInsideAngleToken(text, at);
367
+ }
368
+
369
+ // Pick the proper parent for the plain streaming host:
370
+ // - if there is a pending custom-markup wrapper, append inside it,
371
+ // - otherwise append directly to the snapshot root.
372
+ _choosePlainParent(snap) {
373
+ try {
374
+ if (!snap || !snap.querySelectorAll) return snap;
375
+ const pending = snap.querySelectorAll('[data-cm][data-cm-pending="1"]');
376
+ if (pending && pending.length) return pending[pending.length - 1];
377
+ } catch (_) {}
378
+ return snap;
379
+ }
380
+
381
+ // Prevent splitting inside angle-bracketed tokens like <think>, <tool>, </…>, <!…>, <?…>
382
+ _retractIfInsideAngleToken(text, flushIdx) {
383
+ const PLAIN = (this.cfg && this.cfg.STREAM && this.cfg.STREAM.PLAIN) ? this.cfg.STREAM.PLAIN : {};
384
+ const ENABLED = (PLAIN.PROTECT_ANGLE_TOKENS !== false);
385
+ if (!ENABLED) return flushIdx;
386
+ if (!text || flushIdx <= 0 || flushIdx > text.length) return flushIdx;
387
+
388
+ const LOOK = (PLAIN.ANGLE_LOOKBACK != null) ? PLAIN.ANGLE_LOOKBACK : 128;
389
+ const from = Math.max(0, flushIdx - LOOK);
390
+ const seg = text.slice(from, flushIdx);
391
+
392
+ // Last '<' without a following '>' means we are still inside an opener like <think
393
+ const lt = seg.lastIndexOf('<');
394
+ if (lt !== -1 && seg.indexOf('>', lt + 1) === -1) {
395
+ const next = seg.charAt(lt + 1);
396
+ const looksLikeTag = !!next && (
397
+ (next >= 'A' && next <= 'Z') ||
398
+ (next >= 'a' && next <= 'z') ||
399
+ next === '!' || next === '/' || next === '?'
400
+ );
401
+ if (looksLikeTag) return from + lt; // retract to '<'
402
+ }
403
+
404
+ // If the next char at flushIdx would start a '<X' opener, keep it in carry anyway
405
+ if (flushIdx < text.length) {
406
+ const ch = text.charAt(flushIdx),
407
+ ch2 = text.charAt(flushIdx + 1);
408
+ if (ch === '<' && ch2 && (
409
+ (ch2 >= 'A' && ch2 <= 'Z') ||
410
+ (ch2 >= 'a' && ch2 <= 'z') ||
411
+ ch2 === '!' || ch2 === '/' || ch2 === '?'
412
+ )) return flushIdx;
413
+ }
414
+ return flushIdx;
415
+ }
416
+
417
+ // Append a delta string into the plain text streaming host, managing safe boundaries and inline MD promotion.
418
+ _plainAppendDelta(snap, delta) {
419
+ if (!delta) return;
420
+ const host = this._plainEnsureContainer(snap);
421
+
422
+ let combined = (this.plain._carry || '') + String(delta);
423
+ if (!combined) return;
424
+
425
+ const flushIdx = this._findSafeFlushIndex(combined);
426
+ let toAppend = combined.slice(0, flushIdx);
427
+ let carryRemainder = combined.slice(flushIdx);
428
+
429
+ // Do not push very short, unsafe heads that would split a word (no NL, 1–2 chars).
430
+ const PLAIN = (this.cfg && this.cfg.STREAM && this.cfg.STREAM.PLAIN) ? this.cfg.STREAM.PLAIN : {};
431
+ const MIN_ATOMIC = (PLAIN.MIN_ATOMIC_CHARS != null) ? PLAIN.MIN_ATOMIC_CHARS : 3;
432
+
433
+ const isWord = (ch) => {
434
+ if (!ch) return false;
435
+ const c = ch.charCodeAt(0);
436
+ if ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122)) return true;
437
+ return (c >= 0x00C0 && c <= 0x02AF);
438
+ };
439
+ const lastA = toAppend ? toAppend.charAt(toAppend.length - 1) : '';
440
+ const firstB = carryRemainder ? carryRemainder.charAt(0) : '';
441
+ const looksUnsafeSplit = (!/\r|\n/.test(toAppend)) && isWord(lastA) && isWord(firstB);
442
+
443
+ if (toAppend && looksUnsafeSplit && toAppend.length < MIN_ATOMIC) {
444
+ // Hold until we get a safer boundary
445
+ this.plain._carry = toAppend + carryRemainder;
446
+ return;
447
+ }
448
+
449
+ this.plain._carry = carryRemainder;
450
+ if (!toAppend) return;
451
+
452
+ // Append into tail text node inside current host
453
+ let tn = this.plain.anchor ? this.plain.anchor.previousSibling : null;
454
+ if (!tn || tn.nodeType !== Node.TEXT_NODE || tn.parentNode !== host) {
455
+ tn = document.createTextNode('');
456
+ try {
457
+ host.insertBefore(tn, this.plain.anchor);
458
+ } catch (_) {
459
+ host.appendChild(tn);
460
+ }
461
+ }
462
+ tn.appendData(toAppend);
463
+
464
+ // Let custom markup see the delta on the whole snapshot root (finalize closers fast).
465
+ try {
466
+ const CM = this.renderer && this.renderer.customMarkup;
467
+ const MDinline = this.renderer ? (this.renderer.MD_STREAM || this.renderer.MD || null) : null;
468
+ if (CM && typeof CM.maybeApplyStreamOnDelta === 'function') {
469
+ CM.maybeApplyStreamOnDelta(snap, toAppend, MDinline);
470
+ }
471
+ } catch (_) {}
472
+
473
+ this._plainMaybeInlineMarkdown(toAppend, false);
474
+ this.scrollMgr.scheduleScroll(true);
475
+ }
476
+
477
+ // Promote markdown inline in the tail when small and cheap; skip during long plain streaks.
478
+ _plainMaybeInlineMarkdown(delta, force) {
479
+ if (!this.plain.active || !this.plain.container || !this.plain.anchor) return;
480
+ if (this.plain.suppressInline && !force) {
481
+ // DEBUG
482
+ this._d('plain.inline.skip.suppressed', {
483
+ force
484
+ });
485
+ return;
486
+ }
487
+
488
+ // Read tuning knobs for plain streaming inline parsing.
489
+ const PLAIN = (this.cfg && this.cfg.STREAM && this.cfg.STREAM.PLAIN) ? this.cfg.STREAM.PLAIN : {};
490
+ const MIN_INTERVAL = (PLAIN.MD_MIN_INTERVAL_MS != null) ? PLAIN.MD_MIN_INTERVAL_MS : 120;
491
+ const MIN_TAIL = (PLAIN.INLINE_MIN_CHARS != null) ? PLAIN.INLINE_MIN_CHARS : 64;
492
+ const WINDOW_MAX = (PLAIN.WINDOW_MAX_CHARS != null) ? PLAIN.WINDOW_MAX_CHARS : 2048;
493
+ const RESERVE_TAIL = (PLAIN.RESERVE_TAIL_CHARS != null) ? PLAIN.RESERVE_TAIL_CHARS : 256;
494
+
495
+ const now = Utils.now();
496
+ // Throttle how often we try to parse inline.
497
+ if (!force && (now - (this.plain.lastMDTs || 0)) < MIN_INTERVAL) {
498
+ // DEBUG
499
+ this._d('plain.inline.skip.throttle', {
500
+ since: (now - (this.plain.lastMDTs || 0)),
501
+ MIN_INTERVAL
502
+ });
503
+ return;
504
+ }
505
+
506
+ // Take the tail text node and inspect it.
507
+ const tn = this.plain.anchor.previousSibling;
508
+ if (!tn || tn.nodeType !== Node.TEXT_NODE) return;
509
+ const text = tn.nodeValue || '';
510
+ if (!text) return;
511
+
512
+ // If "force" is set due to markdown+newline heuristic, we let full MD snapshot handle promotion.
513
+ if (force) {
514
+ this._d('plain.inline.skip.forceFull', {});
515
+ return;
516
+ }
517
+
518
+ // If tail is too small and no newline, skip to save work.
519
+ if (text.length < MIN_TAIL && (!delta || delta.indexOf('\n') === -1)) {
520
+ // DEBUG
521
+ this._d('plain.inline.skip.small', {
522
+ textLen: text.length,
523
+ MIN_TAIL
524
+ });
525
+ return;
526
+ }
527
+
528
+ // Quick trigger: only attempt if there is a known inline marker (precompiled).
529
+ const candidate = (delta && this._reMDInlineTrigger.test(delta)) || this._reMDInlineTrigger.test(text);
530
+ if (!candidate) {
531
+ // DEBUG
532
+ this._d('plain.inline.skip.noCandidate', {
533
+ deltaHas: !!(delta && this._reMDInlineTrigger.test(delta))
534
+ });
535
+ return;
536
+ }
537
+
538
+ // Keep the last part as raw tail, only promote the head to HTML.
539
+ let cut = text.length;
540
+ if (text.length > WINDOW_MAX) {
541
+ const target = text.length - RESERVE_TAIL;
542
+ const nl = text.lastIndexOf('\n', Math.max(0, target));
543
+ if (nl >= 32) cut = nl + 1;
544
+ else cut = Math.max(WINDOW_MAX, text.length - RESERVE_TAIL);
545
+ }
546
+
547
+ let head = text.slice(0, cut);
548
+ let rest = text.slice(cut);
549
+
550
+ // Avoid splitting in the middle of a word: if head ends with a word-char and rest starts with a word-char,
551
+ // pull back to the last safe break char inside head.
552
+ if (head && rest) {
553
+ const last = head[head.length - 1];
554
+ const first = rest[0];
555
+ if (this._isWordChar(last) && this._isWordChar(first)) {
556
+ let backCut = -1;
557
+ const LOOKBACK = 96;
558
+ const start = Math.max(0, head.length - LOOKBACK);
559
+ for (let i = head.length - 1; i >= start; i--) {
560
+ if (this._isSafeBreakChar(head[i])) {
561
+ backCut = i + 1;
562
+ break;
563
+ }
564
+ }
565
+ if (backCut >= 0 && backCut < head.length) {
566
+ rest = head.slice(backCut) + rest;
567
+ head = head.slice(0, backCut);
568
+ }
569
+ }
570
+ }
571
+
572
+ // Render inline MD to HTML (fallback to escaping).
573
+ let html = '';
574
+ try {
575
+ if (this.renderer && typeof this.renderer.renderInlineStreaming === 'function') {
576
+ html = this.renderer.renderInlineStreaming(head);
577
+ } else if (this.renderer && this.renderer.MD_STREAM && typeof this.renderer.MD_STREAM.renderInline === 'function') {
578
+ html = this.renderer.MD_STREAM.renderInline(head);
579
+ } else {
580
+ html = Utils.escapeHtml(head);
581
+ }
582
+ } catch (_) {
583
+ html = Utils.escapeHtml(head);
584
+ }
585
+
586
+ // DEBUG
587
+ this._d('plain.inline.promote', {
588
+ headLen: head.length,
589
+ restLen: rest.length,
590
+ htmlLen: html.length
591
+ });
592
+
593
+ // Replace the head text by its HTML while keeping the remaining tail as raw text.
594
+ try {
595
+ // Reuse a single template to avoid many allocations.
596
+ if (this._tpl) {
597
+ this._tpl.innerHTML = html;
598
+ const frag = document.createDocumentFragment();
599
+ while (this._tpl.content.firstChild) frag.appendChild(this._tpl.content.firstChild);
600
+ const host = this.plain.container;
601
+ host.insertBefore(frag, tn);
602
+ tn.nodeValue = rest;
603
+ } else {
604
+ // Fallback if document/template is not available.
605
+ const tpl = document.createElement('template');
606
+ tpl.innerHTML = html;
607
+ const frag = tpl.content;
608
+ const host = this.plain.container;
609
+ host.insertBefore(frag, tn);
610
+ tn.nodeValue = rest;
611
+ }
612
+ } catch (_) {}
613
+
614
+ this.plain.lastMDTs = now;
615
+ }
616
+
617
+ // Reset most of the engine state for a brand new stream.
618
+ reset() {
619
+ // DEBUG
620
+ this._d('reset', {});
621
+ this._clearStreamBuffer();
622
+ this.fenceOpen = false;
623
+ this.fenceMark = '`';
624
+ this.fenceLen = 3;
625
+ this.fenceTail = '';
626
+ this.fenceBuf = '';
627
+ this.lastSnapshotTs = 0;
628
+ this.nextSnapshotStep = this.profile().base;
629
+ this.snapshotScheduled = false;
630
+ this.snapshotRAF = 0;
631
+ this.codeStream = {
632
+ open: false,
633
+ lines: 0,
634
+ chars: 0
635
+ };
636
+ this.activeCode = null;
637
+ this.suppressPostFinalizePass = false;
638
+ this._promoteScheduled = false;
639
+ this._firstCodeOpenSnapDone = false;
640
+
641
+ this._lastInjectedEOL = false;
642
+ this._fenceCustom = null;
643
+
644
+ this._plainReset();
645
+ }
646
+
647
+ // Convert active highlighted block back to plain text (when aborting).
648
+ defuseActiveToPlain() {
649
+ if (!this.activeCode || !this.activeCode.codeEl || !this.activeCode.codeEl.isConnected) return;
650
+ const codeEl = this.activeCode.codeEl;
651
+ // Merge frozen + tail into a single plain text content.
652
+ const fullText = (this.activeCode.frozenEl?.textContent || '') + (this.activeCode.tailEl?.textContent || '');
653
+ // DEBUG
654
+ this._d('code.defuseActive', {
655
+ fullLen: fullText.length
656
+ });
657
+ try {
658
+ codeEl.textContent = fullText;
659
+ codeEl.removeAttribute('data-highlighted');
660
+ codeEl.classList.remove('hljs');
661
+ codeEl.dataset._active_stream = '0';
662
+ // Stop auto-follow on this code block.
663
+ const st = this.codeScroll.state(codeEl);
664
+ st.autoFollow = false;
665
+ } catch (_) {}
666
+ this.activeCode = null;
667
+ }
668
+
669
+ // Find any stray "active" code blocks and turn them into normal code blocks.
670
+ defuseOrphanActiveBlocks(root) {
671
+ try {
672
+ const scope = root || document;
673
+ const nodes = scope.querySelectorAll('pre code[data-_active_stream="1"]');
674
+ let n = 0;
675
+ nodes.forEach(codeEl => {
676
+ if (!codeEl.isConnected) return;
677
+ // Gather full text either from split spans or plain content.
678
+ let text = '';
679
+ const frozen = codeEl.querySelector('.hl-frozen');
680
+ const tail = codeEl.querySelector('.hl-tail');
681
+ if (frozen || tail) text = (frozen?.textContent || '') + (tail?.textContent || '');
682
+ else text = codeEl.textContent || '';
683
+ // Replace with plain text and reset flags/classes.
684
+ codeEl.textContent = text;
685
+ codeEl.removeAttribute('data-highlighted');
686
+ codeEl.classList.remove('hljs');
687
+ codeEl.dataset._active_stream = '0';
688
+ try {
689
+ this.codeScroll.attachHandlers(codeEl);
690
+ } catch (_) {}
691
+ n++;
692
+ });
693
+ // DEBUG
694
+ if (n) this._d('code.defuseOrphans', {
695
+ count: n
696
+ });
697
+ } catch (e) {}
698
+ }
699
+
700
+ // Abort the current stream and reset state; optionally finalize code, clear buffers/UI, etc.
701
+ abortAndReset(opts) {
702
+ // Merge options with defaults.
703
+ const o = Object.assign({
704
+ finalizeActive: true,
705
+ clearBuffer: true,
706
+ clearMsg: false,
707
+ defuseOrphans: true,
708
+ reason: '',
709
+ suppressLog: false
710
+ }, (opts || {}));
711
+
712
+ // DEBUG
713
+ this._d('abort', o);
714
+
715
+ // Cancel scheduled RAF tasks for this engine.
716
+ try {
717
+ this.raf.cancelGroup('StreamEngine');
718
+ } catch (_) {}
719
+ try {
720
+ this.raf.cancel('SE:snapshot');
721
+ } catch (_) {}
722
+ this.snapshotScheduled = false;
723
+ this.snapshotRAF = 0;
724
+
725
+ // Finalize or defuse active code block if any.
726
+ const hadActive = !!this.activeCode;
727
+ try {
728
+ if (this.activeCode) {
729
+ if (o.finalizeActive === true) this.finalizeActiveCode();
730
+ else this.defuseActiveToPlain();
731
+ }
732
+ } catch (e) {}
733
+
734
+ // Clean up any orphan "active" blocks in the DOM.
735
+ if (o.defuseOrphans) {
736
+ try {
737
+ this.defuseOrphanActiveBlocks();
738
+ } catch (e) {}
739
+ }
740
+
741
+ // Optionally clear buffers and reset fence state.
742
+ if (o.clearBuffer) {
743
+ this._clearStreamBuffer();
744
+ this.fenceOpen = false;
745
+ this.fenceMark = '`';
746
+ this.fenceLen = 3;
747
+ this.fenceTail = '';
748
+ this.fenceBuf = '';
749
+ this.codeStream.open = false;
750
+ this.codeStream.lines = 0;
751
+ this.codeStream.chars = 0;
752
+ window.__lastSnapshotLen = 0;
753
+ }
754
+ // Optionally clear current message UI.
755
+ if (o.clearMsg === true) {
756
+ try {
757
+ this.dom.resetEphemeral();
758
+ } catch (_) {}
759
+ }
760
+
761
+ this._plainReset();
762
+ }
763
+
764
+ // Return the active performance profile depending on whether code fence is open.
765
+ profile() {
766
+ return this.fenceOpen ? this.cfg.PROFILE_CODE : this.cfg.PROFILE_TEXT;
767
+ }
768
+
769
+ // Reset the adaptive budget/step for snapshots.
770
+ resetBudget() {
771
+ this.nextSnapshotStep = this.profile().base;
772
+ // DEBUG
773
+ this._d('budget.reset', {
774
+ step: this.nextSnapshotStep
775
+ });
776
+ }
777
+
778
+ // Utility: check if range s[from..end) has only spaces/tabs.
779
+ onlyTrailingWhitespace(s, from, end) {
780
+ for (let i = from; i < end; i++) {
781
+ const c = s.charCodeAt(i);
782
+ if (c !== 0x20 && c !== 0x09) return false;
783
+ }
784
+ return true;
785
+ }
786
+
787
+ // Update fence (code block) state based on a new chunk; returns open/close info and split point.
788
+ updateFenceHeuristic(chunk) {
789
+ // Combine previous tail buffer with new chunk to scan across boundaries.
790
+ const prev = (this.fenceBuf || '');
791
+ const s = prev + (chunk || '');
792
+ const preLen = prev.length;
793
+ const n = s.length;
794
+ let i = 0;
795
+ let opened = false;
796
+ let closed = false;
797
+ let splitAt = -1;
798
+ let atLineStart = (preLen === 0) ? true : this._reLineEnd.test(prev);
799
+
800
+ // Helper: whether token starts in new chunk or crosses boundary.
801
+ const inNewOrCrosses = (j, k) => (j >= preLen) || (k > preLen);
802
+
803
+ // Scan line by line, looking for fence openers/closers.
804
+ while (i < n) {
805
+ const ch = s[i];
806
+ if (ch === '\r' || ch === '\n') {
807
+ atLineStart = true;
808
+ i++;
809
+ continue;
810
+ }
811
+ if (!atLineStart) {
812
+ i++;
813
+ continue;
814
+ }
815
+ atLineStart = false;
816
+
817
+ // Trim list/quote markers at start so we can detect fences after them.
818
+ let j = i;
819
+ while (j < n) {
820
+ let localSpaces = 0;
821
+ while (j < n && (s[j] === ' ' || s[j] === '\t')) {
822
+ localSpaces += (s[j] === '\t') ? 4 : 1;
823
+ j++;
824
+ if (localSpaces > 3) break;
825
+ }
826
+ if (j < n && s[j] === '>') {
827
+ j++;
828
+ if (j < n && s[j] === ' ') j++;
829
+ continue;
830
+ }
831
+
832
+ let saved = j;
833
+ if (j < n && (s[j] === '-' || s[j] === '*' || s[j] === '+')) {
834
+ let jj = j + 1;
835
+ if (jj < n && s[jj] === ' ') j = jj + 1;
836
+ else j = saved;
837
+ } else {
838
+ let k2 = j;
839
+ let hasDigit = false;
840
+ while (k2 < n && s[k2] >= '0' && s[k2] <= '9') {
841
+ hasDigit = true;
842
+ k2++;
843
+ }
844
+ if (hasDigit && k2 < n && (s[k2] === '.' || s[k2] === ')')) {
845
+ k2++;
846
+ if (k2 < n && s[k2] === ' ') j = k2 + 1;
847
+ else j = saved;
848
+ } else j = saved;
849
+ }
850
+ break;
851
+ }
852
+
853
+ // Respect indentation rules for fenced code (ignore deeply indented).
854
+ let indent = 0;
855
+ while (j < n && (s[j] === ' ' || s[j] === '\t')) {
856
+ indent += (s[j] === '\t') ? 4 : 1;
857
+ j++;
858
+ if (indent > 3) break;
859
+ }
860
+ if (indent > 3) {
861
+ i = j;
862
+ continue;
863
+ }
864
+
865
+ // 1) Custom fences
866
+ if (!this.fenceOpen && this._customFenceSpecs && this._customFenceSpecs.length) {
867
+ for (let ci = 0; ci < this._customFenceSpecs.length; ci++) {
868
+ const spec = this._customFenceSpecs[ci];
869
+ const open = spec && spec.open ? spec.open : '';
870
+ if (!open) continue;
871
+ const k = j + open.length;
872
+ if (k <= n && s.slice(j, k) === open) {
873
+ if (inNewOrCrosses(j, k)) {
874
+ // Open a custom fence.
875
+ this.fenceOpen = true;
876
+ this._fenceCustom = spec;
877
+ opened = true;
878
+ // DEBUG
879
+ this._d('fence.open.custom', {
880
+ open,
881
+ at: j
882
+ });
883
+ i = k;
884
+ continue;
885
+ }
886
+ }
887
+ }
888
+ } else if (this.fenceOpen && this._fenceCustom && this._fenceCustom.close) {
889
+ const close = this._fenceCustom.close;
890
+ const k = j + close.length;
891
+ if (k <= n && s.slice(j, k) === close) {
892
+ // Check that only whitespace follows on this line.
893
+ let eol = k;
894
+ while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
895
+ const onlyWS = this.onlyTrailingWhitespace(s, k, eol);
896
+ if (onlyWS && inNewOrCrosses(j, k)) {
897
+ // Close custom fence and compute split position inside the current chunk.
898
+ this.fenceOpen = false;
899
+ this._fenceCustom = null;
900
+ closed = true;
901
+ const endInS = k;
902
+ const rel = endInS - preLen;
903
+ const splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
904
+ // DEBUG
905
+ this._d('fence.close.custom', {
906
+ close,
907
+ splitAt
908
+ });
909
+ return {
910
+ opened,
911
+ closed,
912
+ splitAt
913
+ };
914
+ }
915
+ }
916
+ }
917
+
918
+ // 2) Standard fences (``` or ~~~)
919
+ if (j < n && (s[j] === '`' || s[j] === '~')) {
920
+ const mark = s[j];
921
+ let k = j;
922
+ while (k < n && s[k] === mark) k++;
923
+ const run = k - j;
924
+
925
+ if (!this.fenceOpen) {
926
+ if (run >= 3) {
927
+ if (inNewOrCrosses(j, k)) {
928
+ // Open standard fence
929
+ this.fenceOpen = true;
930
+ this.fenceMark = mark;
931
+ this.fenceLen = run;
932
+ opened = true;
933
+ // DEBUG
934
+ this._d('fence.open.std', {
935
+ mark,
936
+ run
937
+ });
938
+ i = k;
939
+ continue;
940
+ } else {
941
+ i = k;
942
+ continue;
943
+ }
944
+ }
945
+ } else if (!this._fenceCustom) {
946
+ if (mark === this.fenceMark && run >= this.fenceLen) {
947
+ if (inNewOrCrosses(j, k)) {
948
+ // Only close fence if rest of line is whitespace.
949
+ let eol = k;
950
+ while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
951
+ if (this.onlyTrailingWhitespace(s, k, eol)) {
952
+ this.fenceOpen = false;
953
+ closed = true;
954
+ const endInS = k;
955
+ const rel = endInS - preLen;
956
+ const splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
957
+ // DEBUG
958
+ this._d('fence.close.std', {
959
+ mark,
960
+ run,
961
+ splitAt
962
+ });
963
+ return {
964
+ opened,
965
+ closed,
966
+ splitAt
967
+ };
968
+ }
969
+ } else {
970
+ i = k;
971
+ continue;
972
+ }
973
+ }
974
+ }
975
+ }
976
+
977
+ // Move to next char after we tried all checks at this line start.
978
+ i = j + 1;
979
+ }
980
+
981
+ // Keep a small tail so next chunk can be checked across boundary.
982
+ const MAX_TAIL = 512;
983
+ this.fenceBuf = s.slice(-MAX_TAIL);
984
+ this.fenceTail = s.slice(-3);
985
+ // DEBUG
986
+ if (opened || closed) this._d('fence.state', {
987
+ opened,
988
+ closed,
989
+ fenceTail: this.fenceTail
990
+ });
991
+ return {
992
+ opened,
993
+ closed,
994
+ splitAt: -1
995
+ };
996
+ }
997
+
998
+ // Get or create the DOM root for message snapshots.
999
+ getMsgSnapshotRoot(msg) {
1000
+ if (!msg) return null;
1001
+ let snap = msg.querySelector('.md-snapshot-root');
1002
+ if (!snap) {
1003
+ snap = document.createElement('div');
1004
+ snap.className = 'md-snapshot-root';
1005
+ msg.appendChild(snap);
1006
+ // DEBUG
1007
+ this._d('snapshot.root.create', {});
1008
+ }
1009
+ return snap;
1010
+ }
1011
+
1012
+ // Quick check: does chunk contain a structural boundary worth snapshotting on?
1013
+ hasStructuralBoundary(chunk) {
1014
+ if (!chunk) return false;
1015
+ // New paragraph or common block marker means we may want to render now.
1016
+ return this._reStructBoundary.test(chunk);
1017
+ }
1018
+
1019
+ // Decide whether we should schedule a snapshot for this chunk.
1020
+ shouldSnapshotOnChunk(chunk, chunkHasNL, hasBoundary) {
1021
+ const prof = this.profile();
1022
+ const now = Utils.now();
1023
+ // Avoid snapshots too frequently.
1024
+ if (this.activeCode && this.fenceOpen) return false;
1025
+ if ((now - this.lastSnapshotTs) < prof.minInterval) return false;
1026
+ if (hasBoundary) return true;
1027
+
1028
+ const delta = Math.max(0, this.getStreamLength() - (window.__lastSnapshotLen || 0));
1029
+ if (this.fenceOpen) {
1030
+ // For code, prefer lines-based cadence.
1031
+ if (chunkHasNL && delta >= this.nextSnapshotStep) return true;
1032
+ return false;
1033
+ }
1034
+ // For text, snapshot when enough new content accumulated.
1035
+ if (delta >= this.nextSnapshotStep) return true;
1036
+ return false;
1037
+ }
1038
+
1039
+ // If it's been long enough, schedule a "soft" (soon) snapshot.
1040
+ maybeScheduleSoftSnapshot(msg, chunkHasNL) {
1041
+ const prof = this.profile();
1042
+ if (this.activeCode && this.fenceOpen) return;
1043
+ if (this.fenceOpen && this.codeStream.lines < 1 && !chunkHasNL) return;
1044
+ const now = Utils.now();
1045
+ if ((now - this.lastSnapshotTs) >= prof.softLatency) {
1046
+ this._d('snapshot.soft.schedule', {
1047
+ latency: (now - this.lastSnapshotTs),
1048
+ soft: prof.softLatency
1049
+ });
1050
+ this.scheduleSnapshot(msg);
1051
+ }
1052
+ }
1053
+
1054
+ // Schedule a snapshot render on the next frame (or force immediately).
1055
+ scheduleSnapshot(msg, force = false) {
1056
+ // Keep internal "scheduled" flag in sync with RAF scheduler.
1057
+ if (this.snapshotScheduled && !this.raf.isScheduled('SE:snapshot')) this.snapshotScheduled = false;
1058
+ if (!force) {
1059
+ if (this.snapshotScheduled) {
1060
+ this._d('snapshot.schedule.skip', {
1061
+ reason: 'alreadyScheduled'
1062
+ });
1063
+ return;
1064
+ }
1065
+ if (this.activeCode && this.fenceOpen) {
1066
+ this._d('snapshot.schedule.skip', {
1067
+ reason: 'activeCodeFenceOpen'
1068
+ });
1069
+ return;
1070
+ }
1071
+ } else {
1072
+ if (this.snapshotScheduled && this.raf.isScheduled('SE:snapshot')) {
1073
+ this._d('snapshot.schedule.skip', {
1074
+ reason: 'alreadyScheduled(forceCollide)'
1075
+ });
1076
+ return;
1077
+ }
1078
+ }
1079
+ this.snapshotScheduled = true;
1080
+ this._d('snapshot.schedule', {
1081
+ force,
1082
+ fenceOpen: this.fenceOpen,
1083
+ isStreaming: this.isStreaming
1084
+ });
1085
+ this.raf.schedule('SE:snapshot', () => {
1086
+ this.snapshotScheduled = false;
1087
+ const msg = this.getMsg(false, '');
1088
+ if (msg) this.renderSnapshot(msg);
1089
+ }, 'StreamEngine', 0);
1090
+ }
1091
+
1092
+ // Prepare a code element to have separate "frozen" (highlighted) and "tail" (live) spans.
1093
+ ensureSplitCodeEl(codeEl) {
1094
+ if (!codeEl) return null;
1095
+ let frozen = codeEl.querySelector('.hl-frozen');
1096
+ let tail = codeEl.querySelector('.hl-tail');
1097
+ if (frozen && tail) return {
1098
+ codeEl,
1099
+ frozenEl: frozen,
1100
+ tailEl: tail
1101
+ };
1102
+ // If not split yet, create the structure and move existing text to tail.
1103
+ const text = codeEl.textContent || '';
1104
+ codeEl.innerHTML = '';
1105
+ frozen = document.createElement('span');
1106
+ frozen.className = 'hl-frozen';
1107
+ tail = document.createElement('span');
1108
+ tail.className = 'hl-tail';
1109
+ codeEl.appendChild(frozen);
1110
+ codeEl.appendChild(tail);
1111
+ if (text) tail.textContent = text;
1112
+ // DEBUG
1113
+ this._d('code.ensureSplit', {
1114
+ hadText: !!text,
1115
+ textLen: text.length
1116
+ });
1117
+ return {
1118
+ codeEl,
1119
+ frozenEl: frozen,
1120
+ tailEl: tail
1121
+ };
1122
+ }
1123
+
1124
+ // Inspect the last code block in snapshot and set it as active streaming target.
1125
+ setupActiveCodeFromSnapshot(snap) {
1126
+ // Pick the last <pre><code> since it's most likely the open one.
1127
+ const codes = snap.querySelectorAll('pre code');
1128
+ if (!codes.length) return null;
1129
+ const last = codes[codes.length - 1];
1130
+ // Infer language from class or default to plaintext.
1131
+ const cls = Array.from(last.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
1132
+ const lang = (cls.replace('language-', '') || 'plaintext');
1133
+ const parts = this.ensureSplitCodeEl(last);
1134
+ if (!parts) return null;
1135
+
1136
+ // If we had injected a synthetic EOL for parsing, remove it from the tail text.
1137
+ if (this._lastInjectedEOL && parts.tailEl && parts.tailEl.textContent && parts.tailEl.textContent.endsWith('\n')) {
1138
+ parts.tailEl.textContent = parts.tailEl.textContent.slice(0, -1);
1139
+ this._lastInjectedEOL = false;
1140
+ }
1141
+
1142
+ // Enable auto-follow scrolling for the active code block.
1143
+ const st = this.codeScroll.state(parts.codeEl);
1144
+ st.autoFollow = true;
1145
+ st.userInteracted = false;
1146
+ parts.codeEl.dataset._active_stream = '1';
1147
+ const baseFrozenNL = Utils.countNewlines(parts.frozenEl.textContent || '');
1148
+ const baseTailNL = Utils.countNewlines(parts.tailEl.textContent || '');
1149
+ const ac = {
1150
+ codeEl: parts.codeEl,
1151
+ frozenEl: parts.frozenEl,
1152
+ tailEl: parts.tailEl,
1153
+ lang,
1154
+ frozenLen: parts.frozenEl.textContent.length,
1155
+ lastPromoteTs: 0,
1156
+ lines: 0,
1157
+ tailLines: baseTailNL,
1158
+ linesSincePromote: 0,
1159
+ initialLines: baseFrozenNL + baseTailNL,
1160
+ haltHL: false,
1161
+ plainStream: false
1162
+ };
1163
+ // DEBUG
1164
+ this._d('code.active.set', {
1165
+ lang,
1166
+ frozenLen: ac.frozenLen,
1167
+ tailNL: baseTailNL
1168
+ });
1169
+ return ac;
1170
+ }
1171
+
1172
+ // Reconnect previous active code state to the newly rendered code element after a snapshot.
1173
+ rehydrateActiveCode(oldAC, newAC) {
1174
+ if (!oldAC || !newAC) return;
1175
+ const newFullText = newAC.codeEl.textContent || '';
1176
+
1177
+ // If we switched to plain streaming for performance, rebuild as plain text node tail.
1178
+ if (oldAC.plainStream === true) {
1179
+ const prevText = oldAC.tailEl ? (oldAC.tailEl.textContent || '') : '';
1180
+ let delta = '';
1181
+ if (newFullText && newFullText.startsWith(prevText)) delta = newFullText.slice(prevText.length);
1182
+ else delta = newFullText;
1183
+
1184
+ // Reuse/transfer the tail text node so appends stay cheap.
1185
+ while (newAC.tailEl.firstChild) newAC.tailEl.removeChild(newAC.tailEl.firstChild);
1186
+
1187
+ let tn = null;
1188
+ if (oldAC._tailTextNode && oldAC._tailTextNode.parentNode === oldAC.tailEl && oldAC._tailTextNode.nodeType === Node.TEXT_NODE) {
1189
+ tn = oldAC._tailTextNode;
1190
+ } else if (oldAC.tailEl && oldAC.tailEl.firstChild && oldAC.tailEl.firstChild.nodeType === Node.TEXT_NODE) {
1191
+ tn = oldAC.tailEl.firstChild;
1192
+ } else {
1193
+ tn = document.createTextNode(prevText || '');
1194
+ }
1195
+ newAC.tailEl.appendChild(tn);
1196
+ newAC._tailTextNode = tn;
1197
+
1198
+ if (delta && delta !== prevText) tn.appendData(delta);
1199
+
1200
+ // Carry over counters/flags.
1201
+ newAC.frozenLen = 0;
1202
+ newAC.lang = oldAC.lang;
1203
+ newAC.lines = oldAC.lines;
1204
+ newAC.tailLines = Utils.countNewlines((prevText || '') + (delta && delta !== prevText ? delta : ''));
1205
+ newAC.lastPromoteTs = oldAC.lastPromoteTs;
1206
+ newAC.linesSincePromote = oldAC.linesSincePromote || 0;
1207
+ newAC.initialLines = oldAC.initialLines || 0;
1208
+ newAC.haltHL = !!oldAC.haltHL;
1209
+ newAC.plainStream = true;
1210
+
1211
+ // Null out old references to help GC.
1212
+ try {
1213
+ oldAC.codeEl = null;
1214
+ oldAC.frozenEl = null;
1215
+ oldAC.tailEl = null;
1216
+ } catch (_) {}
1217
+ // DEBUG
1218
+ this._d('code.rehydrate.plain', {
1219
+ deltaLen: delta.length
1220
+ });
1221
+ return;
1222
+ }
1223
+
1224
+ // Default path: frozen length stays, tail is replaced with the remainder.
1225
+ const remainder = newFullText.slice(oldAC.frozenLen);
1226
+
1227
+ if (oldAC.frozenEl) {
1228
+ // Move DOM children from old frozen into new frozen to preserve highlighting.
1229
+ const src = oldAC.frozenEl;
1230
+ const dst = newAC.frozenEl;
1231
+ if (dst && src) {
1232
+ while (src.firstChild) dst.appendChild(src.firstChild);
1233
+ }
1234
+ }
1235
+
1236
+ newAC.tailEl.textContent = remainder;
1237
+
1238
+ // Carry over state so promotion cadence stays smooth.
1239
+ newAC.frozenLen = oldAC.frozenLen;
1240
+ newAC.lang = oldAC.lang;
1241
+ newAC.lines = oldAC.lines;
1242
+ newAC.tailLines = Utils.countNewlines(remainder);
1243
+ newAC.lastPromoteTs = oldAC.lastPromoteTs;
1244
+ newAC.linesSincePromote = oldAC.linesSincePromote || 0;
1245
+ newAC.initialLines = oldAC.initialLines || 0;
1246
+ newAC.haltHL = !!oldAC.haltHL;
1247
+ newAC.plainStream = !!oldAC.plainStream;
1248
+
1249
+ try {
1250
+ oldAC.codeEl = null;
1251
+ oldAC.frozenEl = null;
1252
+ oldAC.tailEl = null;
1253
+ } catch (_) {}
1254
+ // DEBUG
1255
+ this._d('code.rehydrate', {
1256
+ remainderLen: remainder.length,
1257
+ frozenLen: newAC.frozenLen
1258
+ });
1259
+ }
1260
+
1261
+ // Append new text to the active code tail quickly.
1262
+ appendToActiveTail(text) {
1263
+ if (!this.activeCode || !this.activeCode.tailEl || !text) return;
1264
+
1265
+ // Keep a stable text node for cheap appends.
1266
+ let tn = this.activeCode._tailTextNode;
1267
+ if (!tn || tn.parentNode !== this.activeCode.tailEl || tn.nodeType !== Node.TEXT_NODE) {
1268
+ const t = this.activeCode.tailEl.textContent || '';
1269
+ this.activeCode.tailEl.textContent = t;
1270
+ tn = this.activeCode._tailTextNode = this.activeCode.tailEl.firstChild || document.createTextNode('');
1271
+ if (!tn.parentNode) this.activeCode.tailEl.appendChild(tn);
1272
+ }
1273
+
1274
+ tn.appendData(text);
1275
+
1276
+ // Update newline counters used for promotion cadence.
1277
+ const nl = Utils.countNewlines(text);
1278
+ this.activeCode.tailLines += nl;
1279
+ this.activeCode.linesSincePromote += nl;
1280
+
1281
+ // Normalize occasionally to avoid too many text nodes.
1282
+ if (((this.activeCode._tailAppends = (this.activeCode._tailAppends | 0) + 1) % 200) === 0) {
1283
+ this.activeCode.tailEl.normalize();
1284
+ this.activeCode._tailTextNode = this.activeCode.tailEl.firstChild;
1285
+ }
1286
+
1287
+ // DEBUG (only if interesting)
1288
+ if (/[<>]/.test(text)) {
1289
+ this._d('code.tail.append', {
1290
+ len: text.length,
1291
+ nl,
1292
+ head: text.slice(0, 80),
1293
+ tail: text.slice(-80)
1294
+ });
1295
+ }
1296
+
1297
+ // Keep the viewport following the code tail.
1298
+ this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
1299
+ }
1300
+
1301
+ // Apply budgets/limits and switch to plain streaming when too big or too long.
1302
+ enforceHLStopBudget() {
1303
+ if (!this.activeCode) return;
1304
+ // Global kill-switch.
1305
+ if (this.cfg.HL.DISABLE_ALL) {
1306
+ this.activeCode.haltHL = true;
1307
+ this.activeCode.plainStream = true;
1308
+ return;
1309
+ }
1310
+ const stop = (this.cfg.PROFILE_CODE.stopAfterLines | 0);
1311
+ const streamPlainLines = (this.cfg.PROFILE_CODE.streamPlainAfterLines | 0);
1312
+ const streamPlainChars = (this.cfg.PROFILE_CODE.streamPlainAfterChars | 0);
1313
+ const maxFrozenChars = (this.cfg.PROFILE_CODE.maxFrozenChars | 0);
1314
+
1315
+ const totalLines = (this.activeCode.initialLines || 0) + (this.activeCode.lines || 0);
1316
+ const frozenChars = this.activeCode.frozenLen | 0;
1317
+ const tailChars = (this.activeCode.tailEl?.textContent || '').length | 0;
1318
+ const totalStreamedChars = frozenChars + tailChars;
1319
+
1320
+ // If any threshold is exceeded, stop highlighting further and stream as plain.
1321
+ if ((streamPlainLines > 0 && totalLines >= streamPlainLines) ||
1322
+ (streamPlainChars > 0 && totalStreamedChars >= streamPlainChars) ||
1323
+ (maxFrozenChars > 0 && frozenChars >= maxFrozenChars)) {
1324
+ this.activeCode.haltHL = true;
1325
+ this.activeCode.plainStream = true;
1326
+ try {
1327
+ this.activeCode.codeEl.dataset.hlStreamSuspended = '1';
1328
+ } catch (_) {}
1329
+ // DEBUG
1330
+ this._d('code.hl.budget.stop', {
1331
+ totalLines,
1332
+ totalStreamedChars,
1333
+ frozenChars,
1334
+ streamPlainLines,
1335
+ streamPlainChars,
1336
+ maxFrozenChars
1337
+ });
1338
+ return;
1339
+ }
1340
+
1341
+ // Hard stop after N lines if configured.
1342
+ if (stop > 0 && totalLines >= stop) {
1343
+ this.activeCode.haltHL = true;
1344
+ this.activeCode.plainStream = true;
1345
+ try {
1346
+ this.activeCode.codeEl.dataset.hlStreamSuspended = '1';
1347
+ } catch (_) {}
1348
+ // DEBUG
1349
+ this._d('code.hl.budget.hardStop', {
1350
+ totalLines,
1351
+ stop
1352
+ });
1353
+ }
1354
+ }
1355
+
1356
+ // Map common language aliases to canonical highlight.js language ids.
1357
+ _aliasLang(token) {
1358
+ const v = String(token || '').trim().toLowerCase();
1359
+ return this.highlighter.ALIAS[v] || v;
1360
+ }
1361
+
1362
+ // Check if highlight.js knows this language.
1363
+ _isHLJSSupported(lang) {
1364
+ try {
1365
+ return !!(window.hljs && hljs.getLanguage && hljs.getLanguage(lang));
1366
+ } catch (_) {
1367
+ return false;
1368
+ }
1369
+ }
1370
+
1371
+ // Detect language directive from the very first non-empty line (e.g., "language: python").
1372
+ _detectDirectiveLangFromText(text) {
1373
+ if (!text) return null;
1374
+ let s = String(text);
1375
+ // Strip BOM if present.
1376
+ if (s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
1377
+ const lines = s.split(/\r?\n/);
1378
+ let i = 0;
1379
+ // Skip leading blank lines.
1380
+ while (i < lines.length && !lines[i].trim()) i++;
1381
+ if (i >= lines.length) return null;
1382
+ let first = lines[i].trim();
1383
+ // Normalize common directive forms.
1384
+ first = first.replace(/^\s*lang(?:uage)?\s*[:=]\s*/i, '').trim();
1385
+ let token = first.split(/\s+/)[0].replace(/:$/, '');
1386
+ if (!/^[A-Za-z][\w#+\-\.]{0,30}$/.test(token)) return null;
1387
+
1388
+ let cand = this._aliasLang(token);
1389
+ const rest = lines.slice(i + 1).join('\n');
1390
+ if (!rest.trim()) return null;
1391
+
1392
+ // Compute deletion range up to (and including) that directive line.
1393
+ let pos = 0,
1394
+ seen = 0;
1395
+ while (seen < i && pos < s.length) {
1396
+ const nl = s.indexOf('\n', pos);
1397
+ if (nl === -1) return null;
1398
+ pos = nl + 1;
1399
+ seen++;
1400
+ }
1401
+ let end = s.indexOf('\n', pos);
1402
+ if (end === -1) end = s.length;
1403
+ else end = end + 1;
1404
+ // DEBUG
1405
+ this._d('code.lang.directive.detect', {
1406
+ lang: cand,
1407
+ deleteUpto: end
1408
+ });
1409
+ return {
1410
+ lang: cand,
1411
+ deleteUpto: end
1412
+ };
1413
+ }
1414
+
1415
+ // Update language class on code element.
1416
+ _updateCodeLangClass(codeEl, newLang) {
1417
+ try {
1418
+ Array.from(codeEl.classList).forEach(c => {
1419
+ if (c.startsWith('language-')) codeEl.classList.remove(c);
1420
+ });
1421
+ } catch (_) {}
1422
+ try {
1423
+ codeEl.classList.add('language-' + (newLang || 'plaintext'));
1424
+ } catch (_) {}
1425
+ }
1426
+
1427
+ // Update code header label (if present in wrapper).
1428
+ _updateCodeHeaderLabel(codeEl, newLabel, newLangToken) {
1429
+ try {
1430
+ const wrap = codeEl.closest('.code-wrapper');
1431
+ if (!wrap) return;
1432
+ const span = wrap.querySelector('.code-header-lang');
1433
+ if (span) span.textContent = newLabel || (newLangToken || 'code');
1434
+ wrap.setAttribute('data-code-lang', newLangToken || '');
1435
+ } catch (_) {}
1436
+ }
1437
+
1438
+ // If first line contains a "language:" directive, switch the block to that language immediately.
1439
+ maybePromoteLanguageFromDirective() {
1440
+ if (!this.activeCode || !this.activeCode.codeEl) return;
1441
+ if (this.activeCode.lang && this.activeCode.lang !== 'plaintext') return;
1442
+
1443
+ // Combine frozen + tail to inspect early lines.
1444
+ const frozenTxt = this.activeCode.frozenEl ? this.activeCode.frozenEl.textContent : '';
1445
+ const tailTxt = this.activeCode.tailEl ? this.activeCode.tailEl.textContent : '';
1446
+ const combined = frozenTxt + tailTxt;
1447
+ if (!combined) return;
1448
+
1449
+ // Detect and extract language directive.
1450
+ const det = this._detectDirectiveLangFromText(combined);
1451
+ if (!det || !det.lang) return;
1452
+
1453
+ const newLang = det.lang;
1454
+ const newCombined = combined.slice(det.deleteUpto);
1455
+
1456
+ try {
1457
+ // Rebuild split spans with directive removed.
1458
+ const codeEl = this.activeCode.codeEl;
1459
+ codeEl.innerHTML = '';
1460
+ const frozen = document.createElement('span');
1461
+ frozen.className = 'hl-frozen';
1462
+ const tail = document.createElement('span');
1463
+ tail.className = 'hl-tail';
1464
+ tail.textContent = newCombined;
1465
+ codeEl.appendChild(frozen);
1466
+ codeEl.appendChild(tail);
1467
+ this.activeCode.frozenEl = frozen;
1468
+ this.activeCode.tailEl = tail;
1469
+ this.activeCode.frozenLen = 0;
1470
+ this.activeCode.tailLines = Utils.countNewlines(newCombined);
1471
+ this.activeCode.linesSincePromote = 0;
1472
+
1473
+ // Update language label/classes.
1474
+ this.activeCode.lang = newLang;
1475
+ this._updateCodeLangClass(codeEl, newLang);
1476
+ this._updateCodeHeaderLabel(codeEl, newLang, newLang);
1477
+
1478
+ // Trigger immediate promotion so highlighting catches up.
1479
+ this._d('code.lang.directive.promote', {
1480
+ newLang,
1481
+ tailLen: newCombined.length
1482
+ });
1483
+ this.schedulePromoteTail(true);
1484
+ } catch (e) {}
1485
+ }
1486
+
1487
+ // Convert a code text delta to highlighted HTML using hljs (or escape as fallback).
1488
+ highlightDeltaText(lang, text) {
1489
+ if (this.cfg.HL.DISABLE_ALL) return Utils.escapeHtml(text);
1490
+ if (window.hljs && lang && hljs.getLanguage && hljs.getLanguage(lang)) {
1491
+ try {
1492
+ return hljs.highlight(text, {
1493
+ language: lang,
1494
+ ignoreIllegals: true
1495
+ }).value;
1496
+ } catch (_) {
1497
+ return Utils.escapeHtml(text);
1498
+ }
1499
+ }
1500
+ return Utils.escapeHtml(text);
1501
+ }
1502
+
1503
+ // Schedule a background task to promote tail (move some tail to frozen).
1504
+ schedulePromoteTail(force = false) {
1505
+ if (!this.activeCode || !this.activeCode.tailEl) return;
1506
+ if (this.activeCode.plainStream === true) return;
1507
+ if (this._promoteScheduled) return;
1508
+ this._promoteScheduled = true;
1509
+ this._d('code.promote.schedule', {
1510
+ force
1511
+ });
1512
+ this.raf.schedule('SE:promoteTail', () => {
1513
+ this._promoteScheduled = false;
1514
+ this._promoteTailWork(force);
1515
+ }, 'StreamEngine', 1);
1516
+ }
1517
+
1518
+ // Worker: move a safe chunk of tail into frozen (highlighted) area.
1519
+ async _promoteTailWork(force = false) {
1520
+ if (!this.activeCode || !this.activeCode.tailEl) return;
1521
+ if (this.activeCode.plainStream === true) return;
1522
+
1523
+ const now = Utils.now();
1524
+ const prof = this.cfg.PROFILE_CODE;
1525
+ const tailText0 = this.activeCode.tailEl.textContent || '';
1526
+ if (!tailText0) return;
1527
+
1528
+ // Respect cadence: not too often, and only if enough lines/chars arrived unless forced.
1529
+ if (!force) {
1530
+ if ((now - this.activeCode.lastPromoteTs) < prof.promoteMinInterval) return;
1531
+ const enoughLines = (this.activeCode.linesSincePromote || 0) >= (prof.promoteMinLines || 10);
1532
+ const enoughChars = tailText0.length >= prof.minCharsForHL;
1533
+ if (!enoughLines && !enoughChars) return;
1534
+ }
1535
+
1536
+ // Choose a safe cut position: up to last newline to avoid partial lines.
1537
+ const idx = tailText0.lastIndexOf('\n');
1538
+ const usePlain = this.activeCode.haltHL || this.activeCode.plainStream || !this._isHLJSSupported(this.activeCode.lang);
1539
+ let cut = -1;
1540
+
1541
+ if (idx >= 0) cut = idx + 1;
1542
+ else if (usePlain) {
1543
+ // If highlighting is off, we can move big plain chunks even without newline.
1544
+ const PLAIN_PROMOTE_CHARS = this.cfg.PROFILE_CODE.minPlainPromoteChars || 8192;
1545
+ if (tailText0.length >= PLAIN_PROMOTE_CHARS || force) cut = tailText0.length;
1546
+ }
1547
+
1548
+ if (cut <= 0) return;
1549
+ const delta = tailText0.slice(0, cut);
1550
+ if (!delta) return;
1551
+
1552
+ // Enforce budgets before doing heavy work.
1553
+ this.enforceHLStopBudget();
1554
+
1555
+ // Yield to keep UI responsive if we plan to run hljs.
1556
+ if (!usePlain) await this.asyncer.yield();
1557
+
1558
+ // Verify the tail still starts with the expected delta (not changed by new chunks).
1559
+ if (!this.activeCode || !this.activeCode.tailEl) return;
1560
+ const tailNow = this.activeCode.tailEl.textContent || '';
1561
+ if (!tailNow.startsWith(delta)) {
1562
+ // Tail changed; try again later.
1563
+ this._d('code.promote.tailChanged', {
1564
+ expectedLen: delta.length,
1565
+ tailNowLen: tailNow.length
1566
+ });
1567
+ this.schedulePromoteTail(false);
1568
+ return;
1569
+ }
1570
+
1571
+ // Move delta from tail to frozen, highlighting if enabled.
1572
+ if (usePlain) {
1573
+ // PERF: append into a dedicated text node to avoid repeated string copies.
1574
+ let tn = this.activeCode._frozenTextNode;
1575
+ if (!tn || tn.parentNode !== this.activeCode.frozenEl) {
1576
+ tn = document.createTextNode('');
1577
+ this.activeCode.frozenEl.appendChild(tn);
1578
+ this.activeCode._frozenTextNode = tn;
1579
+ }
1580
+ tn.appendData(delta);
1581
+ } else {
1582
+ let html = Utils.escapeHtml(delta);
1583
+ try {
1584
+ html = this.highlightDeltaText(this.activeCode.lang, delta);
1585
+ } catch (_) {
1586
+ html = Utils.escapeHtml(delta);
1587
+ }
1588
+ // Reuse template to parse HTML into nodes without creating extra wrapper elements.
1589
+ if (this._tpl) {
1590
+ this._tpl.innerHTML = html;
1591
+ while (this._tpl.content.firstChild) this.activeCode.frozenEl.appendChild(this._tpl.content.firstChild);
1592
+ } else {
1593
+ this.activeCode.frozenEl.insertAdjacentHTML('beforeend', html);
1594
+ }
1595
+ html = null;
1596
+ }
1597
+
1598
+ // Cut the promoted part from tail and update counters.
1599
+ this.activeCode.tailEl.textContent = tailNow.slice(delta.length);
1600
+ this.activeCode.frozenLen += delta.length;
1601
+ const promotedLines = Utils.countNewlines(delta);
1602
+ this.activeCode.tailLines = Math.max(0, (this.activeCode.tailLines || 0) - promotedLines);
1603
+ this.activeCode.linesSincePromote = Math.max(0, (this.activeCode.linesSincePromote || 0) - promotedLines);
1604
+ this.activeCode.lastPromoteTs = Utils.now();
1605
+
1606
+ // DEBUG
1607
+ this._d('code.promote.done', {
1608
+ plain: usePlain,
1609
+ deltaLen: delta.length,
1610
+ promotedLines,
1611
+ frozenLen: this.activeCode.frozenLen,
1612
+ tailLenNow: (this.activeCode.tailEl.textContent || '').length
1613
+ });
1614
+ }
1615
+
1616
+ // Normalize text for stable fingerprint: unify EOLs, drop single trailing newline, strip BOM.
1617
+ _normTextForFP(s) {
1618
+ if (!s) return '';
1619
+ let t = String(s);
1620
+ if (t.charCodeAt(0) === 0xFEFF) t = t.slice(1);
1621
+ t = t.replace(/\r\n?/g, '\n');
1622
+ if (t.endsWith('\n')) t = t.slice(0, -1);
1623
+ return t;
1624
+ }
1625
+
1626
+ // Lightweight FNV-1a 32-bit hash for short keys.
1627
+ _hash32FNV(str) {
1628
+ let h = 0x811c9dc5 >>> 0;
1629
+ for (let i = 0; i < str.length; i++) {
1630
+ h ^= str.charCodeAt(i);
1631
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
1632
+ }
1633
+ return ('00000000' + h.toString(16)).slice(-8);
1634
+ }
1635
+
1636
+ // Extract canonical language token from code element class.
1637
+ _codeLangFromEl(codeEl) {
1638
+ try {
1639
+ const cls = Array.from(codeEl.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
1640
+ return (cls.replace('language-', '') || 'plaintext');
1641
+ } catch (_) {
1642
+ return 'plaintext';
1643
+ }
1644
+ }
1645
+
1646
+ // Build stable fp key from element (lang | normLen | hash(normText)).
1647
+ _fpKeyFromCodeEl(codeEl) {
1648
+ try {
1649
+ const lang = this._codeLangFromEl(codeEl);
1650
+ const norm = this._normTextForFP(codeEl.textContent || '');
1651
+ return `${lang}|${norm.length}|${this._hash32FNV(norm)}`;
1652
+ } catch (_) {
1653
+ return '';
1654
+ }
1655
+ }
1656
+
1657
+ // Finish the active code block: merge tail+frozen, re-highlight once, and stop auto-follow..
1658
+ finalizeActiveCode() {
1659
+ if (!this.activeCode) return;
1660
+ const ac = this.activeCode;
1661
+ const codeEl = ac.codeEl;
1662
+ if (!codeEl || !codeEl.isConnected) {
1663
+ this.activeCode = null;
1664
+ return;
1665
+ }
1666
+
1667
+ // DEBUG
1668
+ this._d('code.finalize.begin', {
1669
+ lang: ac.lang,
1670
+ frozenLen: ac.frozenLen,
1671
+ tailLen: (ac.tailEl ? (ac.tailEl.textContent || '').length : 0),
1672
+ plainStream: !!ac.plainStream
1673
+ });
1674
+
1675
+ // Preserve current scroll position relative to bottom
1676
+ const fromBottomBefore = Math.max(0, codeEl.scrollHeight - codeEl.clientHeight - codeEl.scrollTop);
1677
+ const wasNearBottom = this.codeScroll.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.NEAR_MARGIN_PX);
1678
+
1679
+ // Gather only what we really need; DO NOT serialize large frozen HTML to string
1680
+ const tailTXT = ac.tailEl ? (ac.tailEl.textContent || '') : '';
1681
+
1682
+ // Decide final rendering mode
1683
+ const canHL = !this.cfg.HL.DISABLE_ALL && !ac.plainStream && this._isHLJSSupported(ac.lang);
1684
+
1685
+ // Build the final code DOM without re-stringifying the whole block
1686
+ // PERF: move existing highlighted nodes instead of reading .innerHTML (huge string)
1687
+ const frag = document.createDocumentFragment();
1688
+
1689
+ // Move all frozen children (already highlighted or plain text node)
1690
+ try {
1691
+ if (ac.frozenEl) {
1692
+ while (ac.frozenEl.firstChild) frag.appendChild(ac.frozenEl.firstChild);
1693
+ }
1694
+ } catch (_) {}
1695
+
1696
+ // Append tail (highlighted if possible)
1697
+ try {
1698
+ if (tailTXT) {
1699
+ if (canHL) {
1700
+ let tailHTML = '';
1701
+ try {
1702
+ tailHTML = this.highlightDeltaText(ac.lang, tailTXT);
1703
+ } catch (_) {
1704
+ tailHTML = Utils.escapeHtml(tailTXT);
1705
+ }
1706
+ if (this._tpl) {
1707
+ this._tpl.innerHTML = tailHTML;
1708
+ while (this._tpl.content.firstChild) frag.appendChild(this._tpl.content.firstChild);
1709
+ } else {
1710
+ const tpl = document.createElement('template');
1711
+ tpl.innerHTML = tailHTML;
1712
+ frag.appendChild(tpl.content);
1713
+ }
1714
+ } else {
1715
+ frag.appendChild(document.createTextNode(tailTXT));
1716
+ }
1717
+ }
1718
+ } catch (_) {}
1719
+
1720
+ // Replace split structure with the final content in minimal DOM writes
1721
+ try {
1722
+ codeEl.textContent = ''; // clear fast without creating a giant temp string
1723
+ codeEl.appendChild(frag);
1724
+ // Ensure hljs base styling is present and mark as already highlighted
1725
+ codeEl.classList.add('hljs');
1726
+ codeEl.setAttribute('data-highlighted', 'yes');
1727
+ // Explicitly clear streaming markers
1728
+ codeEl.dataset._active_stream = '0';
1729
+ } catch (_) {}
1730
+
1731
+ // Sync wrapper metadata to keep reuse stable on next snapshots (fast path)
1732
+ try {
1733
+ const totalChars = (ac.frozenLen | 0) + (tailTXT ? tailTXT.length : 0);
1734
+ const totalLines = (ac.initialLines | 0) + (ac.lines | 0);
1735
+ this._updateCodeWrapperMetaFast(codeEl, totalChars, totalLines, ac.lang);
1736
+ } catch (_) {}
1737
+
1738
+ // Disable auto-follow and restore scroll position relative to bottom
1739
+ const st = this.codeScroll.state(codeEl);
1740
+ st.autoFollow = false;
1741
+ const maxScrollTop = Math.max(0, codeEl.scrollHeight - codeEl.clientHeight);
1742
+ const target = wasNearBottom ? maxScrollTop : Math.max(0, maxScrollTop - fromBottomBefore);
1743
+ try {
1744
+ codeEl.scrollTop = target;
1745
+ } catch (_) {}
1746
+ st.lastScrollTop = codeEl.scrollTop;
1747
+
1748
+ // Mark as just finalized so helper can ensure bottom if needed
1749
+ try {
1750
+ codeEl.dataset.justFinalized = '1';
1751
+ } catch (_) {}
1752
+ this.codeScroll.scheduleScroll(codeEl, false, true);
1753
+
1754
+ // We already produced a final highlighted block when possible – no need to enqueue hljs again.
1755
+ // Keep a suppression flag for math and other post passes consistent with previous behavior.
1756
+ this.suppressPostFinalizePass = true;
1757
+
1758
+ // Drop active state and heavy refs to help GC
1759
+ try {
1760
+ ac._tailTextNode = null;
1761
+ ac._frozenTextNode = null;
1762
+ ac.frozenEl = null;
1763
+ ac.tailEl = null;
1764
+ ac.codeEl = null;
1765
+ } catch (_) {}
1766
+ this.activeCode = null;
1767
+
1768
+ // DEBUG
1769
+ this._d('code.finalize.end', {});
1770
+ }
1771
+
1772
+ // Produce a simple fingerprint of a code block to detect unchanged ones across snapshots.
1773
+ codeFingerprint(codeEl) {
1774
+ const cls = Array.from(codeEl.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
1775
+ const lang = cls.replace('language-', '') || 'plaintext';
1776
+ const t = codeEl.textContent || '';
1777
+ const len = t.length;
1778
+ const head = t.slice(0, 64);
1779
+ const tail = t.slice(-64);
1780
+ return `${lang}|${len}|${head}|${tail}`;
1781
+ }
1782
+
1783
+ // Read precomputed fingerprint from code wrapper attributes if present.
1784
+ // Now validates attributes against the actual code text; falls back to null when stale.
1785
+ codeFingerprintFromWrapper(codeEl) {
1786
+ try {
1787
+ const wrap = codeEl.closest('.code-wrapper');
1788
+ if (!wrap) return null;
1789
+
1790
+ // Prefer stable fp when present (handles small textual diffs across snapshots).
1791
+ const fpStable = wrap.getAttribute('data-fp');
1792
+ if (fpStable) return fpStable;
1793
+
1794
+ // Legacy path with validation against actual text to avoid stale attrs.
1795
+ const cls = Array.from(codeEl.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
1796
+ const lang = (cls.replace('language-', '') || 'plaintext');
1797
+
1798
+ const lenAttr = wrap.getAttribute('data-code-len');
1799
+ const headAttr = wrap.getAttribute('data-code-head') || '';
1800
+ const tailAttr = wrap.getAttribute('data-code-tail') || '';
1801
+ if (!lenAttr) return null;
1802
+
1803
+ const txt = codeEl.textContent || '';
1804
+ const lenNow = txt.length;
1805
+ const lenNum = parseInt(lenAttr, 10);
1806
+ if (!Number.isFinite(lenNum) || lenNum !== lenNow) return null;
1807
+
1808
+ const headNowEsc = Utils.escapeHtml(txt.slice(0, 64));
1809
+ const tailNowEsc = Utils.escapeHtml(txt.slice(-64));
1810
+ if ((headAttr && headAttr !== headNowEsc) || (tailAttr && tailAttr !== tailNowEsc)) {
1811
+ return null;
1812
+ }
1813
+
1814
+ return `${lang}|${lenAttr}|${headAttr}|${tailAttr}`;
1815
+ } catch (_) {
1816
+ return null;
1817
+ }
1818
+ }
1819
+
1820
+ // Reuse old, closed code blocks that did not change between snapshots to avoid flicker/work.
1821
+ // Optimized: rely on wrapper-provided fingerprints/attributes; avoid reading textContent for big nodes.
1822
+ preserveStableClosedCodes(oldSnap, newRoot, skipLastIfStreaming) {
1823
+ try {
1824
+ const oldCodes = oldSnap.querySelectorAll('pre code');
1825
+ if (!oldCodes || !oldCodes.length) return;
1826
+ const newCodesPre = newRoot.querySelectorAll('pre code');
1827
+ if (!newCodesPre || !newCodesPre.length) return;
1828
+ const limit = (this.cfg.STREAM && this.cfg.STREAM.PRESERVE_CODES_MAX) || 200;
1829
+ if (newCodesPre.length > limit || oldCodes.length > limit) return;
1830
+
1831
+ // DEBUG
1832
+ this._d('codes.preserve.scan', {
1833
+ old: oldCodes.length,
1834
+ anew: newCodesPre.length,
1835
+ skipLastIfStreaming
1836
+ });
1837
+
1838
+ // Build maps keyed by full, stable fp key (data-fp) or by lightweight tuple from wrapper attrs.
1839
+ const map = new Map();
1840
+ const push = (key, el) => {
1841
+ if (!key) return;
1842
+ let arr = map.get(key);
1843
+ if (!arr) {
1844
+ arr = [];
1845
+ map.set(key, arr);
1846
+ }
1847
+ arr.push(el);
1848
+ };
1849
+
1850
+ const makeAttrKey = (wrap) => {
1851
+ if (!wrap) return '';
1852
+ // Use only cheap attributes; do not read textContent.
1853
+ const lang = (wrap.getAttribute('data-code-lang') || 'plaintext');
1854
+ const len = (wrap.getAttribute('data-code-len') || '0');
1855
+ const head = (wrap.getAttribute('data-code-head') || '');
1856
+ const tail = (wrap.getAttribute('data-code-tail') || '');
1857
+ return `${lang}|${len}|${head}|${tail}`;
1858
+ };
1859
+
1860
+ for (let idx = 0; idx < oldCodes.length; idx++) {
1861
+ const el = oldCodes[idx];
1862
+ // Skip streaming (split) blocks and the current active block.
1863
+ if (el.querySelector('.hl-frozen')) continue;
1864
+ if (this.activeCode && el === this.activeCode.codeEl) continue;
1865
+ const wrap = el.closest('.code-wrapper');
1866
+ const fpStable = wrap ? wrap.getAttribute('data-fp') : null;
1867
+ if (fpStable) {
1868
+ push(`S|${fpStable}`, el);
1869
+ } else {
1870
+ push(`A|${makeAttrKey(wrap)}`, el);
1871
+ }
1872
+ }
1873
+
1874
+ // For each new code, try to swap with an old, identical one.
1875
+ const end = (skipLastIfStreaming && newCodesPre.length > 0) ? (newCodesPre.length - 1) : newCodesPre.length;
1876
+ for (let i = 0; i < end; i++) {
1877
+ const nc = newCodesPre[i];
1878
+ if (nc.getAttribute('data-highlighted') === 'yes') continue;
1879
+
1880
+ const wrap = nc.closest('.code-wrapper');
1881
+ let swapped = false;
1882
+
1883
+ const fpStableNew = wrap ? wrap.getAttribute('data-fp') : null;
1884
+ if (fpStableNew) {
1885
+ const arr = map.get(`S|${fpStableNew}`);
1886
+ if (arr && arr.length) {
1887
+ const oldEl = arr.pop();
1888
+ if (oldEl && oldEl.isConnected) {
1889
+ try {
1890
+ nc.replaceWith(oldEl);
1891
+ this.codeScroll.attachHandlers(oldEl);
1892
+ if (!oldEl.getAttribute('data-highlighted')) oldEl.setAttribute('data-highlighted', 'yes');
1893
+ const st = this.codeScroll.state(oldEl);
1894
+ st.autoFollow = false;
1895
+ } catch (_) {}
1896
+ swapped = true;
1897
+ }
1898
+ if (!arr.length) map.delete(`S|${fpStableNew}`);
1899
+ }
1900
+ }
1901
+ if (swapped) continue;
1902
+
1903
+ const attrKey = `A|${makeAttrKey(wrap)}`;
1904
+ const arr2 = map.get(attrKey);
1905
+ if (arr2 && arr2.length) {
1906
+ const oldEl = arr2.pop();
1907
+ if (oldEl && oldEl.isConnected) {
1908
+ try {
1909
+ nc.replaceWith(oldEl);
1910
+ this.codeScroll.attachHandlers(oldEl);
1911
+ if (!oldEl.getAttribute('data-highlighted')) oldEl.setAttribute('data-highlighted', 'yes');
1912
+ const st = this.codeScroll.state(oldEl);
1913
+ st.autoFollow = false;
1914
+ } catch (_) {}
1915
+ }
1916
+ if (!arr2.length) map.delete(attrKey);
1917
+ }
1918
+ }
1919
+ } catch (e) {}
1920
+ }
1921
+
1922
+ // Ensure "just finalized" code blocks snap to bottom once scrolling finishes.
1923
+ _ensureSplitContainers(codeEl) {
1924
+ try {
1925
+ const scope = codeEl || document;
1926
+ const nodes = scope.querySelectorAll('pre code[data-just-finalized="1"]');
1927
+ if (!nodes || !nodes.length) return;
1928
+ nodes.forEach((codeEl) => {
1929
+ this.codeScroll.scheduleScroll(codeEl, false, true);
1930
+ // NOTE: use a primitive key; do not capture DOM in scheduler key object
1931
+ const wrap = codeEl.closest('.code-wrapper');
1932
+ const idx = wrap ? (wrap.getAttribute('data-index') || '') : '';
1933
+ const key = `JF:forceBottom#${idx}`;
1934
+ this.raf.schedule(key, () => {
1935
+ this.codeScroll.scrollToBottom(codeEl, false, true);
1936
+ try {
1937
+ codeEl.dataset.justFinalized = '0';
1938
+ } catch (_) {}
1939
+ }, 'CodeScroll', 2);
1940
+ });
1941
+ } catch (_) {}
1942
+ }
1943
+
1944
+ // Similar helper: ensure finalized blocks end up at bottom (variant used after patching).
1945
+ _ensureBottomForJustFinalized(root) {
1946
+ try {
1947
+ const scope = root || document;
1948
+ const nodes = scope.querySelectorAll('pre code[data-just-finalized="1"]');
1949
+ if (!nodes || !nodes.length) return;
1950
+ nodes.forEach((codeEl) => {
1951
+ // NOTE: use a primitive key; do not capture DOM in scheduler key object
1952
+ const wrap = codeEl.closest('.code-wrapper');
1953
+ const idx = wrap ? (wrap.getAttribute('data-index') || '') : '';
1954
+ const key = `JF:ensureBottom#${idx}`;
1955
+ this.codeScroll.scheduleScroll(codeEl, false, true);
1956
+ this.raf.schedule(key, () => {
1957
+ this.codeScroll.scrollToBottom(codeEl, false, true);
1958
+ try {
1959
+ codeEl.dataset.justFinalized = '0';
1960
+ } catch (_) {}
1961
+ }, 'CodeScroll', 2);
1962
+ });
1963
+ } catch (_) {}
1964
+ }
1965
+
1966
+ // Kick the engine when tab becomes visible again or layout changed.
1967
+ kickVisibility() {
1968
+ const msg = this.getMsg(false, '');
1969
+ if (!msg) return;
1970
+ // If code is open but activeCode got lost, force a snapshot to recover.
1971
+ if (this.codeStream.open && !this.activeCode) {
1972
+ this._d('kick.visibility', {
1973
+ reason: 'codeStreamOpenNoActive'
1974
+ });
1975
+ this.scheduleSnapshot(msg, true);
1976
+ return;
1977
+ }
1978
+ // If we have unseen data in buffer, render it now.
1979
+ const needSnap = (this.getStreamLength() !== (window.__lastSnapshotLen || 0));
1980
+ if (needSnap) {
1981
+ this._d('kick.visibility', {
1982
+ reason: 'bufferDelta'
1983
+ });
1984
+ this.scheduleSnapshot(msg, true);
1985
+ }
1986
+ // If code is active, keep promoting/highlighting.
1987
+ if (this.activeCode && this.activeCode.codeEl) {
1988
+ this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
1989
+ this.schedulePromoteTail(true);
1990
+ }
1991
+ }
1992
+
1993
+ // Stabilize the language header label across snapshots to avoid flicker.
1994
+ stabilizeHeaderLabel(prevAC, newAC) {
1995
+ try {
1996
+ if (!newAC || !newAC.codeEl || !newAC.codeEl.isConnected) return;
1997
+
1998
+ const wrap = newAC.codeEl.closest('.code-wrapper');
1999
+ if (!wrap) return;
2000
+
2001
+ const span = wrap.querySelector('.code-header-lang');
2002
+ const curLabel = (span && span.textContent ? span.textContent.trim() : '').toLowerCase();
2003
+
2004
+ // If labeled "output", keep it as-is.
2005
+ if (curLabel === 'output') return;
2006
+
2007
+ const tokNow = (wrap.getAttribute('data-code-lang') || '').trim().toLowerCase();
2008
+ const sticky = (wrap.getAttribute('data-lang-sticky') || '').trim().toLowerCase();
2009
+ const prev = (prevAC && prevAC.lang && prevAC.lang !== 'plaintext') ? prevAC.lang.toLowerCase() : '';
2010
+
2011
+ const valid = (t) => !!t && t !== 'plaintext' && this._isHLJSSupported(t);
2012
+
2013
+ // Prefer current explicit token, else previous language, else sticky fallback.
2014
+ let finalTok = '';
2015
+ if (valid(tokNow)) finalTok = tokNow;
2016
+ else if (valid(prev)) finalTok = prev;
2017
+ else if (valid(sticky)) finalTok = sticky;
2018
+
2019
+ if (finalTok) {
2020
+ this._updateCodeLangClass(newAC.codeEl, finalTok);
2021
+ this._updateCodeHeaderLabel(newAC.codeEl, finalTok, finalTok);
2022
+ try {
2023
+ wrap.setAttribute('data-code-lang', finalTok);
2024
+ } catch (_) {}
2025
+ try {
2026
+ wrap.setAttribute('data-lang-sticky', finalTok);
2027
+ } catch (_) {}
2028
+ newAC.lang = finalTok;
2029
+ // DEBUG
2030
+ this._d('code.header.stabilize', {
2031
+ finalTok
2032
+ });
2033
+ } else {
2034
+ // Avoid super short labels like "c" if we can't validate them.
2035
+ if (span && curLabel && curLabel.length < 3) span.textContent = 'code';
2036
+ }
2037
+ } catch (_) {}
2038
+ }
2039
+
2040
+ // Patch the snapshot root with minimal DOM changes (preserve stable nodes).
2041
+ _patchSnapshotRoot(snap, frag) {
2042
+ try {
2043
+ // Snapshot current and new children (live NodeLists; avoid creating big arrays).
2044
+ const oldKids = snap.childNodes;
2045
+ const newKids = frag.childNodes;
2046
+ const aLen = oldKids.length;
2047
+ const bLen = newKids.length;
2048
+
2049
+ // Fast path: nothing there yet.
2050
+ if (aLen === 0) {
2051
+ snap.appendChild(frag);
2052
+ // DEBUG
2053
+ this._d('snapshot.patch.first', {
2054
+ newCount: bLen
2055
+ });
2056
+ return;
2057
+ }
2058
+
2059
+ // Compare up to MAX_CMP items from both ends to find common prefix/suffix.
2060
+ const MAX_CMP = 6;
2061
+ const eq = (a, b) => {
2062
+ try {
2063
+ if (!a || !b) return false;
2064
+ if (a.nodeType !== b.nodeType) return false;
2065
+ if (a.nodeType === 3 || a.nodeType === 8) return a.nodeValue === b.nodeValue;
2066
+ if (a.nodeType === 1) {
2067
+ const ae = a,
2068
+ be = b;
2069
+ if (ae.tagName !== be.tagName) return false;
2070
+ const acls = ae.className || '';
2071
+ if (acls !== (be.className || '')) return false;
2072
+ return ae.isEqualNode(be);
2073
+ }
2074
+ return false;
2075
+ } catch (_) {
2076
+ return false;
2077
+ }
2078
+ };
2079
+
2080
+ let i = 0,
2081
+ j = 0;
2082
+ const iMax = Math.min(aLen, bLen, MAX_CMP);
2083
+ while (i < iMax && eq(oldKids[i], newKids[i])) i++;
2084
+ const jMax = Math.min(aLen - i, bLen - i, MAX_CMP);
2085
+ while (j < jMax && eq(oldKids[aLen - 1 - j], newKids[bLen - 1 - j])) j++;
2086
+
2087
+ // Remove the changed middle from old DOM.
2088
+ const removeStart = i;
2089
+ const removeEnd = aLen - j;
2090
+ for (let k = removeStart; k < removeEnd; k++) {
2091
+ const node = snap.childNodes[removeStart]; // always remove at the same index
2092
+ if (node) {
2093
+ try {
2094
+ snap.removeChild(node);
2095
+ } catch (_) {}
2096
+ }
2097
+ }
2098
+
2099
+ // Insert the new middle chunk.
2100
+ const insStart = i,
2101
+ insEnd = bLen - j;
2102
+ if (insStart < insEnd) {
2103
+ const mid = document.createDocumentFragment();
2104
+ // Note: newKids is live; always grab at insStart index.
2105
+ for (let k = insStart; k < insEnd; k++) {
2106
+ if (newKids[insStart]) mid.appendChild(newKids[insStart]);
2107
+ }
2108
+ const ref = (i < snap.childNodes.length) ? snap.childNodes[i] : null;
2109
+ if (ref) snap.insertBefore(mid, ref);
2110
+ else snap.appendChild(mid);
2111
+ }
2112
+
2113
+ // DEBUG
2114
+ this._d('snapshot.patch', {
2115
+ oldCount: aLen,
2116
+ newCount: bLen,
2117
+ removed: (removeEnd - removeStart),
2118
+ inserted: (bLen - j - i)
2119
+ });
2120
+
2121
+ } catch (_) {
2122
+ // Fallback: replace everything.
2123
+ try {
2124
+ snap.replaceChildren(frag);
2125
+ this._d('snapshot.patch.replaceAll', {});
2126
+ } catch (__) {}
2127
+ }
2128
+ }
2129
+
2130
+ // Quick MD detectors
2131
+
2132
+ // Check if a chunk likely contains inline or block markdown markers.
2133
+ _chunkHasMarkdown(s) {
2134
+ try {
2135
+ return this._mdQuickRe.test(String(s || ''));
2136
+ } catch (_) {
2137
+ return false;
2138
+ }
2139
+ }
2140
+
2141
+ // Ask custom markup if there are any stream open tokens in the chunk.
2142
+ _chunkHasCustomOpeners(s) {
2143
+ try {
2144
+ const CM = this.renderer && this.renderer.customMarkup;
2145
+ if (!CM || typeof CM.hasAnyStreamOpenToken !== 'function') return false;
2146
+ return CM.hasAnyStreamOpenToken(String(s || ''));
2147
+ } catch (_) {
2148
+ return false;
2149
+ }
2150
+ }
2151
+
2152
+ // Render a snapshot of the current buffer: either plain streaming or full markdown.
2153
+ renderSnapshot(msg) {
2154
+ const streaming = !!this.isStreaming;
2155
+ const snap = this.getMsgSnapshotRoot(msg);
2156
+ if (!snap) return;
2157
+
2158
+ // If nothing changed and no open code, just update timestamps and return.
2159
+ const prevLen = (window.__lastSnapshotLen || 0);
2160
+ const curLen = this.getStreamLength();
2161
+ if (!this.fenceOpen && !this.activeCode && curLen === prevLen) {
2162
+ this.lastSnapshotTs = Utils.now();
2163
+ return;
2164
+ }
2165
+
2166
+ // Decide plain vs full-MD path before materializing big strings.
2167
+ const forceFull = !!this.plain.forceFullMDOnce;
2168
+ // IMPORTANT: use plain path only after threshold and only when enabled
2169
+ const streamingPlain = streaming && !this.fenceOpen && !forceFull && this.plain.enabled;
2170
+
2171
+ // DEBUG
2172
+ this._d('snapshot.begin', {
2173
+ streaming,
2174
+ fenceOpen: this.fenceOpen,
2175
+ streamingPlain,
2176
+ forceFull,
2177
+ prevLen,
2178
+ curLen
2179
+ });
2180
+
2181
+ if (streamingPlain) {
2182
+ // Fast path: compute delta without materializing the full buffer.
2183
+ const delta = this.getDeltaSince(prevLen);
2184
+ this._plainAppendDelta(snap, delta);
2185
+
2186
+ // Bookkeeping for pacing and next snapshot.
2187
+ window.__lastSnapshotLen = curLen;
2188
+ this.lastSnapshotTs = Utils.now();
2189
+
2190
+ const prof = this.profile();
2191
+ if (prof.adaptiveStep) {
2192
+ const maxStep = this.cfg.STREAM.SNAPSHOT_MAX_STEP || 8000;
2193
+ this.nextSnapshotStep = Math.min(Math.ceil(this.nextSnapshotStep * prof.growth), maxStep);
2194
+ } else {
2195
+ this.nextSnapshotStep = prof.base;
2196
+ }
2197
+
2198
+ this.scrollMgr.scheduleScroll(true);
2199
+ this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
2200
+ this.scrollMgr.scheduleScrollFabUpdate();
2201
+
2202
+ this._d('snapshot.end.plain', {
2203
+ nextStep: this.nextSnapshotStep
2204
+ });
2205
+ return;
2206
+ }
2207
+
2208
+ // Non-plain (full MD streaming or final)
2209
+ if (forceFull) this.plain.forceFullMDOnce = false;
2210
+
2211
+ // Materialize buffer for rendering (zero-copy: see getStreamText()).
2212
+ let allText = this.getStreamText();
2213
+
2214
+ // When switching away from plain streaming, drop any pending carry (full snapshot re-renders everything).
2215
+ this.plain._carry = '';
2216
+
2217
+ // If a code fence is open, but buffer ends without EOL, append synthetic EOL so parser sees the line.
2218
+ const needSyntheticEOL = (this.fenceOpen && !/[\r\n]$/.test(allText));
2219
+ this._lastInjectedEOL = !!needSyntheticEOL;
2220
+ let src = needSyntheticEOL ? (allText + '\n') : allText;
2221
+
2222
+ // DEBUG (only if interesting)
2223
+ if (/[<>]/.test(src)) this._d('snapshot.full.src', {
2224
+ len: src.length,
2225
+ head: src.slice(0, 120),
2226
+ tail: src.slice(-120),
2227
+ injectedEOL: needSyntheticEOL
2228
+ });
2229
+
2230
+ // Produce a DOM fragment from renderer (stream or final flavor).
2231
+ let frag = null;
2232
+ if (streaming) frag = this.renderer.renderStreamingSnapshotFragment(src);
2233
+ else frag = this.renderer.renderFinalSnapshotFragment(src);
2234
+
2235
+ // Let custom markup post-process the fragment if enabled.
2236
+ try {
2237
+ if (this.renderer && this.renderer.customMarkup && this.renderer.customMarkup.hasStreamRules()) {
2238
+ const MDinline = this.renderer.MD_STREAM || this.renderer.MD || null;
2239
+ this.renderer.customMarkup.applyStream(frag, MDinline);
2240
+ }
2241
+ } catch (_) {}
2242
+
2243
+ // Try to reuse stable code blocks to reduce flicker/work.
2244
+ this.preserveStableClosedCodes(snap, frag, this.fenceOpen === true);
2245
+ // Minimal DOM patch to update only changed middle section.
2246
+ this._patchSnapshotRoot(snap, frag);
2247
+
2248
+ // Micro highlight: highlight one small visible code block immediately to avoid a plain-text flash.
2249
+ try {
2250
+ if (this.highlighter && typeof this.highlighter.microHighlightNow === 'function') {
2251
+ this.highlighter.microHighlightNow(snap, {
2252
+ maxCount: 1,
2253
+ budgetMs: 4
2254
+ }, this.activeCode);
2255
+ }
2256
+ } catch (_) {}
2257
+
2258
+ // Restore any collapsed code UI and ensure finalized blocks are scrolled properly.
2259
+ this.renderer.restoreCollapsedCode(snap);
2260
+ this._ensureBottomForJustFinalized(snap);
2261
+
2262
+ // If code fence is open, re-create active streaming code target.
2263
+ const prevAC = this.activeCode;
2264
+ if (this.fenceOpen) {
2265
+ const newAC = this.setupActiveCodeFromSnapshot(snap);
2266
+ if (prevAC && newAC) this.rehydrateActiveCode(prevAC, newAC);
2267
+ // Run stabilization even for the first snapshot (prevAC may be null).
2268
+ this.stabilizeHeaderLabel(prevAC || null, newAC || null);
2269
+ this.activeCode = newAC || null;
2270
+ } else {
2271
+ this.activeCode = null;
2272
+ }
2273
+
2274
+ // Initialize scroll handlers for code blocks when outside of code streaming.
2275
+ if (!this.fenceOpen) {
2276
+ this.codeScroll.initScrollableBlocks(snap);
2277
+ }
2278
+
2279
+ // Observe new code blocks for highlighting; defer the last one while streaming.
2280
+ this.highlighter.observeNewCode(
2281
+ snap, {
2282
+ deferLastIfStreaming: true,
2283
+ minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
2284
+ minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
2285
+ },
2286
+ this.activeCode
2287
+ );
2288
+
2289
+ // Also watch code inside message boxes that may appear.
2290
+ this.highlighter.observeMsgBoxes(snap, (box) => {
2291
+ this.highlighter.observeNewCode(
2292
+ box, {
2293
+ deferLastIfStreaming: true,
2294
+ minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
2295
+ minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
2296
+ },
2297
+ this.activeCode
2298
+ );
2299
+ this.codeScroll.initScrollableBlocks(box);
2300
+ });
2301
+
2302
+ // Schedule math rendering depending on math mode.
2303
+ const mm = getMathMode();
2304
+ if (!this.suppressPostFinalizePass) {
2305
+ if (mm === 'idle') this.math.schedule(snap);
2306
+ else if (mm === 'always') this.math.schedule(snap, 0, true);
2307
+ }
2308
+
2309
+ // If we are streaming code, attach scroll handlers and keep following.
2310
+ if (this.fenceOpen && this.activeCode && this.activeCode.codeEl) {
2311
+ this.codeScroll.attachHandlers(this.activeCode.codeEl);
2312
+ this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
2313
+ } else if (!this.fenceOpen) {
2314
+ this.codeScroll.initScrollableBlocks(snap);
2315
+ }
2316
+
2317
+ // Update counters and adaptive step.
2318
+ window.__lastSnapshotLen = this.getStreamLength();
2319
+ this.lastSnapshotTs = Utils.now();
2320
+
2321
+ const prof = this.profile();
2322
+ if (prof.adaptiveStep) {
2323
+ const maxStep = this.cfg.STREAM.SNAPSHOT_MAX_STEP || 8000;
2324
+ this.nextSnapshotStep = Math.min(Math.ceil(this.nextSnapshotStep * prof.growth), maxStep);
2325
+ } else {
2326
+ this.nextSnapshotStep = prof.base;
2327
+ }
2328
+
2329
+ // Keep the viewport and FAB updated.
2330
+ this.scrollMgr.scheduleScroll(true);
2331
+ this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
2332
+ this.scrollMgr.scheduleScrollFabUpdate();
2333
+
2334
+ // Clear one-time suppression flag if set.
2335
+ if (this.suppressPostFinalizePass) this.suppressPostFinalizePass = false;
2336
+
2337
+ // NOTE: drop local big references ASAP
2338
+ frag = null;
2339
+ src = null;
2340
+ allText = null;
2341
+
2342
+ // DEBUG
2343
+ this._d('snapshot.end.full', {
2344
+ nextStep: this.nextSnapshotStep,
2345
+ fenceOpen: this.fenceOpen,
2346
+ hasActiveCode: !!this.activeCode
2347
+ });
2348
+ }
2349
+
2350
+ // Keep wrapper metadata (len/head/tail/nl/lang/fp) in sync with actual code text.
2351
+ _updateCodeWrapperMeta(codeEl) {
2352
+ try {
2353
+ const wrap = codeEl.closest('.code-wrapper');
2354
+ if (!wrap) return;
2355
+ const txt = codeEl.textContent || '';
2356
+ wrap.setAttribute('data-code-len', String(txt.length));
2357
+ wrap.setAttribute('data-code-head', Utils.escapeHtml(txt.slice(0, 64)));
2358
+ wrap.setAttribute('data-code-tail', Utils.escapeHtml(txt.slice(-64)));
2359
+ wrap.setAttribute('data-code-nl', String(Utils.countNewlines(txt)));
2360
+
2361
+ const lang = this._codeLangFromEl(codeEl);
2362
+ wrap.setAttribute('data-code-lang', lang);
2363
+
2364
+ const norm = this._normTextForFP(txt);
2365
+ const fp = `${lang}|${norm.length}|${this._hash32FNV(norm)}`;
2366
+ wrap.setAttribute('data-fp', fp);
2367
+ } catch (_) {}
2368
+ }
2369
+
2370
+ // Fast metadata update without reading full textContent (avoid huge string copies).
2371
+ _updateCodeWrapperMetaFast(codeEl, len, nl, langTok) {
2372
+ try {
2373
+ const wrap = codeEl.closest('.code-wrapper');
2374
+ if (!wrap) return;
2375
+ if (Number.isFinite(len)) wrap.setAttribute('data-code-len', String(len));
2376
+ if (Number.isFinite(nl)) wrap.setAttribute('data-code-nl', String(nl));
2377
+ if (langTok) {
2378
+ wrap.setAttribute('data-code-lang', String(langTok));
2379
+ this._updateCodeLangClass(codeEl, langTok);
2380
+ }
2381
+ // Do not recompute data-fp or head/tail here to avoid serializing the whole code.
2382
+ // Existing attributes remain valid enough for reuse + scanning.
2383
+ } catch (_) {}
2384
+ }
2385
+
2386
+ // Get or create the message element used for streaming output.
2387
+ getMsg(create, name_header) {
2388
+ return this.dom.getStreamMsg(create, name_header);
2389
+ }
2390
+
2391
+ // Start a new stream: clear output, reset state, and scroll to bottom.
2392
+ beginStream(chunk = false) {
2393
+ this.isStreaming = true;
2394
+ // DEBUG
2395
+ this._d('stream.begin', {
2396
+ chunk
2397
+ });
2398
+ // Hide loading spinner if this call corresponds to first chunk.
2399
+ if (chunk) {
2400
+ try {
2401
+ runtime.loading.hide();
2402
+ } catch (_) {}
2403
+ }
2404
+ this.scrollMgr.userInteracted = false;
2405
+ this.dom.clearOutput();
2406
+ this.reset();
2407
+ this.scrollMgr.forceScrollToBottomImmediate();
2408
+ this.scrollMgr.scheduleScroll();
2409
+ }
2410
+
2411
+ // Finish the stream: render final snapshot, finalize code, flush highlighting, and clean up.
2412
+ endStream() {
2413
+ this.isStreaming = false;
2414
+ const msg = this.getMsg(false, '');
2415
+ if (msg) this.renderSnapshot(msg);
2416
+
2417
+ // Cancel any scheduled tasks related to streaming.
2418
+ this.snapshotScheduled = false;
2419
+ try {
2420
+ this.raf.cancel('SE:snapshot');
2421
+ } catch (_) {}
2422
+ try {
2423
+ this.raf.cancelGroup('StreamEngine');
2424
+ } catch (_) {}
2425
+ try {
2426
+ this.raf.cancelGroup('CodeScroll');
2427
+ } catch (_) {}
2428
+ try {
2429
+ this.raf.cancelGroup('ScrollMgr');
2430
+ } catch (_) {}
2431
+
2432
+ this.snapshotRAF = 0;
2433
+
2434
+ // If there was an active code block, finalize it now.
2435
+ const hadActive = !!this.activeCode;
2436
+ if (this.activeCode) this.finalizeActiveCode();
2437
+
2438
+ // If not, flush any remaining highlight queue and render math once.
2439
+ if (!hadActive) {
2440
+ if (this.highlighter.hlQueue && this.highlighter.hlQueue.length) {
2441
+ this.highlighter.flush(this.activeCode);
2442
+ }
2443
+ const snap = msg ? this.getMsgSnapshotRoot(msg) : null;
2444
+ if (snap) this.math.renderAsync(snap);
2445
+ }
2446
+
2447
+ // Reset buffers and flags.
2448
+ this._clearStreamBuffer();
2449
+
2450
+ this.fenceOpen = false;
2451
+ this.codeStream.open = false;
2452
+ this.activeCode = null;
2453
+ this.lastSnapshotTs = Utils.now();
2454
+ this.suppressPostFinalizePass = false;
2455
+
2456
+ // Reset plain state to default for next sessions.
2457
+ this._plainReset();
2458
+
2459
+ // DEBUG
2460
+ this._d('stream.end', {
2461
+ hadActive
2462
+ });
2463
+ }
2464
+
2465
+ // If custom markup has openers in the chunk (especially at start), trigger early snapshot.
2466
+ _maybeEagerSnapshotForCustomOpeners(msg, chunkStr) {
2467
+ try {
2468
+ const CM = this.renderer && this.renderer.customMarkup;
2469
+ if (!CM || !CM.hasStreamRules()) return;
2470
+ if (this.fenceOpen || this.codeStream.open) return;
2471
+
2472
+ // For the very first snapshot, check if the buffered head already starts with an opener.
2473
+ const isFirstSnapshot = ((window.__lastSnapshotLen || 0) === 0);
2474
+
2475
+ if (isFirstSnapshot) {
2476
+ let head;
2477
+ try {
2478
+ head = this.getStreamText();
2479
+ } catch (_) {
2480
+ head = String(chunkStr || '');
2481
+ }
2482
+ if (CM.hasStreamOpenerAtStart(head)) {
2483
+ this._d('snapshot.eager.custom', {
2484
+ reason: 'headHasOpener'
2485
+ });
2486
+ this.scheduleSnapshot(msg, true);
2487
+ return;
2488
+ }
2489
+ }
2490
+
2491
+ // Otherwise, check current chunk for any open token.
2492
+ const rules = (CM.getRules() || []).filter(r => r && r.stream && typeof r.open === 'string');
2493
+ if (rules.length && CM.hasAnyOpenToken(String(chunkStr || ''), rules)) {
2494
+ this._d('snapshot.eager.custom', {
2495
+ reason: 'chunkHasOpener'
2496
+ });
2497
+ this.scheduleSnapshot(msg);
2498
+ }
2499
+ } catch (_) {}
2500
+ }
2501
+
2502
+ // Main entry: accept a chunk for a given "name_header" and update the view incrementally.
2503
+ applyStream(name_header, chunk, alreadyBuffered = false) {
2504
+ // If there is no active code and fences are closed, defuse any stray active blocks in DOM.
2505
+ if (!this.activeCode && !this.fenceOpen) {
2506
+ try {
2507
+ if (document.querySelector('pre code[data-_active_stream="1"]')) this.defuseOrphanActiveBlocks();
2508
+ } catch (_) {}
2509
+ }
2510
+ // Re-sync scheduled flag with RAF state.
2511
+ if (this.snapshotScheduled && !this.raf.isScheduled('SE:snapshot')) this.snapshotScheduled = false;
2512
+
2513
+ const msg = this.getMsg(true, name_header);
2514
+ if (!msg || !chunk) return;
2515
+ const s = String(chunk);
2516
+
2517
+ // DEBUG (only if interesting)
2518
+ if (/[<>]/.test(s)) {
2519
+ this._d('apply.chunk', {
2520
+ len: s.length,
2521
+ nl: Utils.countNewlines(s),
2522
+ head: s.slice(0, 120),
2523
+ tail: s.slice(-120)
2524
+ });
2525
+ }
2526
+
2527
+ // Buffer the chunk unless caller says it's already buffered (recursive tail call case).
2528
+ if (!alreadyBuffered) this._appendChunk(s);
2529
+
2530
+ // Update fence state based on the new text.
2531
+ const change = this.updateFenceHeuristic(s);
2532
+ const nlCount = Utils.countNewlines(s);
2533
+ const chunkHasNL = nlCount > 0;
2534
+
2535
+ // If no fence opened and we are outside code, check if custom openers request early snapshot.
2536
+ if (!change.opened && !this.fenceOpen) {
2537
+ this._maybeEagerSnapshotForCustomOpeners(msg, s);
2538
+ }
2539
+
2540
+ // Plain vs full-MD decision management (non-code)
2541
+ if (!this.fenceOpen && !this.codeStream.open) {
2542
+ const mdPresent = this._chunkHasMarkdown(s) || this._chunkHasCustomOpeners(s) || change.opened;
2543
+ const thr = this._plainThreshold();
2544
+
2545
+ if (mdPresent) {
2546
+ // Markdown seen → reset counters and exit plain mode if active.
2547
+ if (this.plain.noMdNL !== 0) {
2548
+ this._d('apply.plain.resetOnMD', { noMdNL: this.plain.noMdNL });
2549
+ }
2550
+ this.plain.noMdNL = 0;
2551
+
2552
+ if (this.plain.enabled) {
2553
+ // Leave plain mode and force one full snapshot to "re-sync" elegant rendering.
2554
+ this.plain.enabled = false;
2555
+ this.plain.suppressInline = false;
2556
+ this.plain.forceFullMDOnce = true;
2557
+ this._d('apply.plain.disableOnMD', {});
2558
+ this.scheduleSnapshot(msg, true);
2559
+ }
2560
+ } else if (chunkHasNL) {
2561
+ // No markdown in this chunk and we got newlines → count "plain lines"
2562
+ this.plain.noMdNL += nlCount;
2563
+
2564
+ if (!this.plain.enabled && this.plain.noMdNL >= thr) {
2565
+ // Threshold reached → enter fully plain-text mode
2566
+ this.plain.enabled = true;
2567
+ this.plain.suppressInline = true; // fully plain-text (no inline markdown upgrades)
2568
+ this._d('apply.plain.enable', { noMdNL: this.plain.noMdNL, thr });
2569
+ // Switch to plain path soon
2570
+ this.scheduleSnapshot(msg);
2571
+ }
2572
+ }
2573
+ }
2574
+
2575
+ // Track if we just materialized the first code-open snapshot synchronously.
2576
+ let didImmediateOpenSnap = false;
2577
+
2578
+ // If a fence opened in this chunk, mark code stream active and try to snapshot soon.
2579
+ if (change.opened) {
2580
+ this.codeStream.open = true;
2581
+ this.codeStream.lines = 0;
2582
+ this.codeStream.chars = 0;
2583
+ this.resetBudget();
2584
+ this._d('code.open', {});
2585
+ this.scheduleSnapshot(msg);
2586
+
2587
+ // Special case: if the message is empty and we just opened, render immediately once.
2588
+ if (!this._firstCodeOpenSnapDone && !this.activeCode && ((window.__lastSnapshotLen || 0) === 0)) {
2589
+ try {
2590
+ this.renderSnapshot(msg);
2591
+ try {
2592
+ this.raf.cancel('SE:snapshot');
2593
+ } catch (_) {}
2594
+ this.snapshotScheduled = false;
2595
+ this._firstCodeOpenSnapDone = true;
2596
+ didImmediateOpenSnap = true;
2597
+ this._d('code.open.immediateSnap', {});
2598
+ } catch (_) {}
2599
+ }
2600
+ }
2601
+
2602
+ // If we are inside a code block stream, feed text into the active code tail or wait for a snapshot.
2603
+ if (this.codeStream.open) {
2604
+ this.codeStream.lines += nlCount;
2605
+ this.codeStream.chars += s.length;
2606
+
2607
+ if (this.activeCode && this.activeCode.codeEl && this.activeCode.codeEl.isConnected) {
2608
+ // Split current chunk if it also contains the closing fence.
2609
+ let partForCode = s;
2610
+ let remainder = '';
2611
+
2612
+ if (didImmediateOpenSnap) partForCode = '';
2613
+ else if (change.closed && change.splitAt >= 0 && change.splitAt <= s.length) {
2614
+ partForCode = s.slice(0, change.splitAt);
2615
+ remainder = s.slice(change.splitAt);
2616
+ }
2617
+
2618
+ // Append code text to the tail and update counters/promotions.
2619
+ if (partForCode) {
2620
+ this.appendToActiveTail(partForCode);
2621
+ this.activeCode.lines += Utils.countNewlines(partForCode);
2622
+
2623
+ this.maybePromoteLanguageFromDirective();
2624
+ this.enforceHLStopBudget();
2625
+
2626
+ const tailLenNow = (this.activeCode.tailEl.textContent || '').length;
2627
+ const hasNL = partForCode.indexOf('\n') >= 0;
2628
+
2629
+ // Schedule promotion when we have a newline or enough chars.
2630
+ if (!this.activeCode.plainStream) {
2631
+ const HL_MIN = this.cfg.PROFILE_CODE.minCharsForHL;
2632
+ if (hasNL || tailLenNow >= HL_MIN) this.schedulePromoteTail(false);
2633
+ }
2634
+ }
2635
+ // Keep viewport and FAB updated while streaming code.
2636
+ this.scrollMgr.scrollFabUpdateScheduled = false;
2637
+ this.scrollMgr.scheduleScroll(true);
2638
+ this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
2639
+ this.scrollMgr.scheduleScrollFabUpdate();
2640
+
2641
+ // If this chunk closed the fence, finalize code and process any remainder as normal text.
2642
+ if (change.closed) {
2643
+ this._d('code.close', {
2644
+ remainderLen: remainder.length
2645
+ });
2646
+ this.finalizeActiveCode();
2647
+ this.codeStream.open = false;
2648
+ this.resetBudget();
2649
+ // Force immediate full snapshot to avoid any transient plain rendering
2650
+ this.plain.forceFullMDOnce = true;
2651
+ this.scheduleSnapshot(msg, true);
2652
+ if (remainder && remainder.length) {
2653
+ this.applyStream(name_header, remainder, true);
2654
+ }
2655
+ }
2656
+ return;
2657
+ } else {
2658
+ // If code just opened but we have not created activeCode yet, force a snapshot once enough content arrives.
2659
+ if (!this.activeCode && (this.codeStream.lines >= 2 || this.codeStream.chars >= 80)) {
2660
+ this._d('code.awaitActive.forceSnap', {
2661
+ lines: this.codeStream.lines,
2662
+ chars: this.codeStream.chars
2663
+ });
2664
+ this.scheduleSnapshot(msg, true);
2665
+ return;
2666
+ }
2667
+ // If code closed without an active code element (rare), schedule a snapshot to reflect closure.
2668
+ // Outside code streaming
2669
+ if (change.closed) {
2670
+ this.codeStream.open = false;
2671
+ this.resetBudget();
2672
+ this._d('code.closed.outside', {});
2673
+ this.plain.forceFullMDOnce = true;
2674
+ this.scheduleSnapshot(msg, true);
2675
+ } else {
2676
+ const boundary = this.hasStructuralBoundary(s);
2677
+ if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) {
2678
+ this._d('snapshot.decide', {
2679
+ reason: 'boundary/step'
2680
+ });
2681
+ this.scheduleSnapshot(msg);
2682
+ } else {
2683
+ this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
2684
+ }
2685
+ }
2686
+ return;
2687
+ }
2688
+ }
2689
+
2690
+ // Outside code streaming: consider snapshotting or soft scheduling based on boundaries/size.
2691
+ if (change.closed) {
2692
+ this.codeStream.open = false;
2693
+ this.resetBudget();
2694
+ this._d('code.closed.outside', {});
2695
+ this.scheduleSnapshot(msg);
2696
+ } else {
2697
+ const boundary = this.hasStructuralBoundary(s);
2698
+ if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) {
2699
+ this._d('snapshot.decide', {
2700
+ reason: 'boundary/step'
2701
+ });
2702
+ this.scheduleSnapshot(msg);
2703
+ } else {
2704
+ this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
2705
+ }
2706
+ }
2707
+ }
2708
+ }