pygpt-net 2.6.45__py3-none-any.whl → 2.6.47__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 (77) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/__init__.py +1 -3
  4. pygpt_net/controller/audio/audio.py +2 -0
  5. pygpt_net/controller/chat/text.py +2 -1
  6. pygpt_net/controller/debug/debug.py +11 -9
  7. pygpt_net/controller/dialogs/debug.py +40 -29
  8. pygpt_net/controller/notepad/notepad.py +0 -2
  9. pygpt_net/controller/theme/theme.py +5 -5
  10. pygpt_net/controller/ui/tabs.py +40 -2
  11. pygpt_net/core/debug/agent.py +19 -14
  12. pygpt_net/core/debug/assistants.py +22 -24
  13. pygpt_net/core/debug/attachments.py +11 -7
  14. pygpt_net/core/debug/config.py +22 -23
  15. pygpt_net/core/debug/context.py +63 -63
  16. pygpt_net/core/debug/db.py +1 -4
  17. pygpt_net/core/debug/events.py +14 -11
  18. pygpt_net/core/debug/indexes.py +41 -76
  19. pygpt_net/core/debug/kernel.py +11 -8
  20. pygpt_net/core/debug/models.py +20 -15
  21. pygpt_net/core/debug/plugins.py +9 -6
  22. pygpt_net/core/debug/presets.py +16 -11
  23. pygpt_net/core/debug/tabs.py +28 -22
  24. pygpt_net/core/debug/ui.py +25 -22
  25. pygpt_net/core/render/web/renderer.py +5 -2
  26. pygpt_net/core/tabs/tab.py +16 -3
  27. pygpt_net/data/config/config.json +3 -3
  28. pygpt_net/data/config/models.json +3 -3
  29. pygpt_net/data/config/settings.json +15 -17
  30. pygpt_net/data/css/style.dark.css +6 -0
  31. pygpt_net/data/css/web-blocks.css +4 -0
  32. pygpt_net/data/css/web-blocks.light.css +1 -1
  33. pygpt_net/data/css/web-chatgpt.css +4 -0
  34. pygpt_net/data/css/web-chatgpt.light.css +1 -1
  35. pygpt_net/data/css/web-chatgpt_wide.css +4 -0
  36. pygpt_net/data/css/web-chatgpt_wide.light.css +1 -1
  37. pygpt_net/data/js/app.js +1804 -1688
  38. pygpt_net/data/locale/locale.de.ini +1 -1
  39. pygpt_net/data/locale/locale.en.ini +1 -1
  40. pygpt_net/data/locale/locale.es.ini +1 -1
  41. pygpt_net/data/locale/locale.fr.ini +1 -1
  42. pygpt_net/data/locale/locale.it.ini +1 -1
  43. pygpt_net/data/locale/locale.pl.ini +2 -2
  44. pygpt_net/data/locale/locale.uk.ini +1 -1
  45. pygpt_net/data/locale/locale.zh.ini +1 -1
  46. pygpt_net/item/model.py +4 -1
  47. pygpt_net/js_rc.py +14303 -14540
  48. pygpt_net/provider/api/anthropic/__init__.py +3 -1
  49. pygpt_net/provider/api/anthropic/tools.py +1 -1
  50. pygpt_net/provider/api/google/__init__.py +7 -1
  51. pygpt_net/provider/api/x_ai/__init__.py +5 -1
  52. pygpt_net/provider/core/config/patch.py +14 -1
  53. pygpt_net/provider/llms/anthropic.py +37 -5
  54. pygpt_net/provider/llms/azure_openai.py +3 -1
  55. pygpt_net/provider/llms/base.py +13 -1
  56. pygpt_net/provider/llms/deepseek_api.py +13 -3
  57. pygpt_net/provider/llms/google.py +14 -1
  58. pygpt_net/provider/llms/hugging_face_api.py +105 -24
  59. pygpt_net/provider/llms/hugging_face_embedding.py +88 -0
  60. pygpt_net/provider/llms/hugging_face_router.py +28 -16
  61. pygpt_net/provider/llms/local.py +2 -0
  62. pygpt_net/provider/llms/mistral.py +60 -3
  63. pygpt_net/provider/llms/open_router.py +4 -2
  64. pygpt_net/provider/llms/openai.py +4 -1
  65. pygpt_net/provider/llms/perplexity.py +66 -5
  66. pygpt_net/provider/llms/utils.py +39 -0
  67. pygpt_net/provider/llms/voyage.py +50 -0
  68. pygpt_net/provider/llms/x_ai.py +70 -10
  69. pygpt_net/ui/widget/lists/db.py +1 -0
  70. pygpt_net/ui/widget/lists/debug.py +1 -0
  71. pygpt_net/ui/widget/tabs/body.py +23 -4
  72. pygpt_net/ui/widget/textarea/notepad.py +0 -4
  73. {pygpt_net-2.6.45.dist-info → pygpt_net-2.6.47.dist-info}/METADATA +16 -4
  74. {pygpt_net-2.6.45.dist-info → pygpt_net-2.6.47.dist-info}/RECORD +77 -74
  75. {pygpt_net-2.6.45.dist-info → pygpt_net-2.6.47.dist-info}/LICENSE +0 -0
  76. {pygpt_net-2.6.45.dist-info → pygpt_net-2.6.47.dist-info}/WHEEL +0 -0
  77. {pygpt_net-2.6.45.dist-info → pygpt_net-2.6.47.dist-info}/entry_points.txt +0 -0
pygpt_net/data/js/app.js CHANGED
@@ -461,147 +461,151 @@
461
461
  // ==========================================================================
462
462
 
463
463
  class Config {
464
- constructor() {
465
- // Process identifier (passed from host).
466
- this.PID = Utils.g('PID', 0);
467
-
468
- // UI: scroll behavior and busy/zoom signalling thresholds (milliseconds / pixels).
469
- this.UI = {
470
- AUTO_FOLLOW_REENABLE_PX: Utils.g('AUTO_FOLLOW_REENABLE_PX', 8), // Enable auto-follow when near bottom
471
- SCROLL_NEAR_MARGIN_PX: Utils.g('SCROLL_NEAR_MARGIN_PX', 450), // How close to bottom is considered "near"
472
- INTERACTION_BUSY_MS: Utils.g('UI_INTERACTION_BUSY_MS', 140), // Minimum busy time for interactions
473
- ZOOM_BUSY_MS: Utils.g('UI_ZOOM_BUSY_MS', 300) // Minimum busy time for zoom
474
- };
464
+ constructor() {
465
+ // Process identifier (passed from host).
466
+ this.PID = Utils.g('PID', 0);
467
+
468
+ // UI: scroll behavior and busy/zoom signalling thresholds (milliseconds / pixels).
469
+ this.UI = {
470
+ AUTO_FOLLOW_REENABLE_PX: Utils.g('AUTO_FOLLOW_REENABLE_PX', 8),
471
+ SCROLL_NEAR_MARGIN_PX: Utils.g('SCROLL_NEAR_MARGIN_PX', 450),
472
+ INTERACTION_BUSY_MS: Utils.g('UI_INTERACTION_BUSY_MS', 140),
473
+ ZOOM_BUSY_MS: Utils.g('UI_ZOOM_BUSY_MS', 300)
474
+ };
475
475
 
476
- // FAB (floating action button) visibility and debounce.
477
- this.FAB = {
478
- SHOW_DOWN_THRESHOLD_PX: Utils.g('SHOW_DOWN_THRESHOLD_PX', 0), // Show "down" arrow when scroll distance is large
479
- TOGGLE_DEBOUNCE_MS: Utils.g('FAB_TOGGLE_DEBOUNCE_MS', 100) // Debounce for icon/text toggles
480
- };
476
+ // FAB (floating action button) visibility and debounce.
477
+ this.FAB = {
478
+ SHOW_DOWN_THRESHOLD_PX: Utils.g('SHOW_DOWN_THRESHOLD_PX', 0),
479
+ TOGGLE_DEBOUNCE_MS: Utils.g('FAB_TOGGLE_DEBOUNCE_MS', 100)
480
+ };
481
481
 
482
- // Highlighting controls and per-frame budget.
483
- this.HL = {
484
- PER_FRAME: Utils.g('HL_PER_FRAME', 2), // How many code blocks to highlight per frame
485
- DISABLE_ALL: Utils.g('DISABLE_SYNTAX_HIGHLIGHT', false) // Global off switch for hljs
486
- };
482
+ // Highlighting controls and per-frame budget.
483
+ this.HL = {
484
+ PER_FRAME: Utils.g('HL_PER_FRAME', 2),
485
+ DISABLE_ALL: Utils.g('DISABLE_SYNTAX_HIGHLIGHT', false)
486
+ };
487
487
 
488
- // Intersection-like margins (we do our own scan, but these guide budgets).
489
- this.OBSERVER = {
490
- CODE_ROOT_MARGIN: Utils.g('CODE_ROOT_MARGIN', '1000px 0px 1000px 0px'),
491
- BOX_ROOT_MARGIN: Utils.g('BOX_ROOT_MARGIN', '1500px 0px 1500px 0px'),
492
- CODE_THRESHOLD: [0, 0.001], BOX_THRESHOLD: 0
493
- };
488
+ // Intersection-like margins (we do our own scan, but these guide budgets).
489
+ this.OBSERVER = {
490
+ CODE_ROOT_MARGIN: Utils.g('CODE_ROOT_MARGIN', '1000px 0px 1000px 0px'),
491
+ BOX_ROOT_MARGIN: Utils.g('BOX_ROOT_MARGIN', '1500px 0px 1500px 0px'),
492
+ CODE_THRESHOLD: [0, 0.001], BOX_THRESHOLD: 0
493
+ };
494
494
 
495
- // Viewport scan preload distance (in pixels).
496
- this.SCAN = { PRELOAD_PX: Utils.g('SCAN_PRELOAD_PX', 1000) };
495
+ // Viewport scan preload distance (in pixels).
496
+ this.SCAN = { PRELOAD_PX: Utils.g('SCAN_PRELOAD_PX', 1000) };
497
497
 
498
- // Code scroll behavior (auto-follow re-enable margin and near-bottom margin).
499
- this.CODE_SCROLL = {
500
- AUTO_FOLLOW_REENABLE_PX: Utils.g('CODE_AUTO_FOLLOW_REENABLE_PX', 8),
501
- NEAR_MARGIN_PX: Utils.g('CODE_SCROLL_NEAR_MARGIN_PX', 48)
502
- };
498
+ // Code scroll behavior (auto-follow re-enable margin and near-bottom margin).
499
+ this.CODE_SCROLL = {
500
+ AUTO_FOLLOW_REENABLE_PX: Utils.g('CODE_AUTO_FOLLOW_REENABLE_PX', 8),
501
+ NEAR_MARGIN_PX: Utils.g('CODE_SCROLL_NEAR_MARGIN_PX', 48)
502
+ };
503
503
 
504
- // Stream (snapshot) budgets and queue limits.
505
- this.STREAM = {
506
- MAX_PER_FRAME: Utils.g('STREAM_MAX_PER_FRAME', 8), // How many chunks to process per frame
507
- EMERGENCY_COALESCE_LEN: Utils.g('STREAM_EMERGENCY_COALESCE_LEN', 300), // If queue grows beyond, coalesce
508
- COALESCE_MODE: Utils.g('STREAM_COALESCE_MODE', 'fixed'), // fixed | adaptive
509
- SNAPSHOT_MAX_STEP: Utils.g('STREAM_SNAPSHOT_MAX_STEP', 8000), // Upper bound for adaptive step
510
- // Bounds for queue to prevent memory growth on bursty streams
511
- QUEUE_MAX_ITEMS: Utils.g('STREAM_QUEUE_MAX_ITEMS', 1200),
512
- PRESERVE_CODES_MAX: Utils.g('STREAM_PRESERVE_CODES_MAX', 120) // Max code blocks to attempt reuse
513
- };
504
+ // Stream (snapshot) budgets and queue limits.
505
+ this.STREAM = {
506
+ MAX_PER_FRAME: Utils.g('STREAM_MAX_PER_FRAME', 8),
507
+ EMERGENCY_COALESCE_LEN: Utils.g('STREAM_EMERGENCY_COALESCE_LEN', 300),
508
+ COALESCE_MODE: Utils.g('STREAM_COALESCE_MODE', 'fixed'),
509
+ SNAPSHOT_MAX_STEP: Utils.g('STREAM_SNAPSHOT_MAX_STEP', 8000),
510
+ QUEUE_MAX_ITEMS: Utils.g('STREAM_QUEUE_MAX_ITEMS', 1200),
511
+ PRESERVE_CODES_MAX: Utils.g('STREAM_PRESERVE_CODES_MAX', 120)
512
+ };
514
513
 
515
- // Math (KaTeX) idle batching and per-batch hint.
516
- this.MATH = {
517
- IDLE_TIMEOUT_MS: Utils.g('MATH_IDLE_TIMEOUT_MS', 800),
518
- BATCH_HINT: Utils.g('MATH_BATCH_HINT', 24)
519
- };
514
+ // Math (KaTeX) idle batching and per-batch hint.
515
+ this.MATH = {
516
+ IDLE_TIMEOUT_MS: Utils.g('MATH_IDLE_TIMEOUT_MS', 800),
517
+ BATCH_HINT: Utils.g('MATH_BATCH_HINT', 24)
518
+ };
520
519
 
521
- // Icon URLs (provided by host app).
522
- this.ICONS = {
523
- EXPAND: Utils.g('ICON_EXPAND', ''), COLLAPSE: Utils.g('ICON_COLLAPSE', ''),
524
- CODE_MENU: Utils.g('ICON_CODE_MENU', ''), CODE_COPY: Utils.g('ICON_CODE_COPY', ''),
525
- CODE_RUN: Utils.g('ICON_CODE_RUN', ''), CODE_PREVIEW: Utils.g('ICON_CODE_PREVIEW', '')
526
- };
520
+ // Icon URLs (provided by host app).
521
+ this.ICONS = {
522
+ EXPAND: Utils.g('ICON_EXPAND', ''), COLLAPSE: Utils.g('ICON_COLLAPSE', ''),
523
+ CODE_MENU: Utils.g('ICON_CODE_MENU', ''), CODE_COPY: Utils.g('ICON_CODE_COPY', ''),
524
+ CODE_RUN: Utils.g('ICON_CODE_RUN', ''), CODE_PREVIEW: Utils.g('ICON_CODE_PREVIEW', '')
525
+ };
527
526
 
528
- // Localized UI strings.
529
- this.LOCALE = {
530
- PREVIEW: Utils.g('LOCALE_PREVIEW', 'Preview'),
531
- RUN: Utils.g('LOCALE_RUN', 'Run'),
532
- COLLAPSE: Utils.g('LOCALE_COLLAPSE', 'Collapse'),
533
- EXPAND: Utils.g('LOCALE_EXPAND', 'Expand'),
534
- COPY: Utils.g('LOCALE_COPY', 'Copy'),
535
- COPIED: Utils.g('LOCALE_COPIED', 'Copied')
536
- };
527
+ // Localized UI strings.
528
+ this.LOCALE = {
529
+ PREVIEW: Utils.g('LOCALE_PREVIEW', 'Preview'),
530
+ RUN: Utils.g('LOCALE_RUN', 'Run'),
531
+ COLLAPSE: Utils.g('LOCALE_COLLAPSE', 'Collapse'),
532
+ EXPAND: Utils.g('LOCALE_EXPAND', 'Expand'),
533
+ COPY: Utils.g('LOCALE_COPY', 'Copy'),
534
+ COPIED: Utils.g('LOCALE_COPIED', 'Copied')
535
+ };
537
536
 
538
- // Code block styling theme (hljs theme key or custom).
539
- this.CODE_STYLE = Utils.g('CODE_SYNTAX_STYLE', 'default');
537
+ // Code block styling theme (hljs theme key or custom).
538
+ this.CODE_STYLE = Utils.g('CODE_SYNTAX_STYLE', 'default');
540
539
 
541
- // Adaptive snapshot profile for plain text.
542
- this.PROFILE_TEXT = {
543
- base: Utils.g('PROFILE_TEXT_BASE', 4), // Base step (chars) between snapshots
544
- growth: Utils.g('PROFILE_TEXT_GROWTH', 1.28), // Growth factor for adaptive
545
- minInterval: Utils.g('PROFILE_TEXT_MIN_INTERVAL', 4), // Minimum time between snapshots (ms)
546
- softLatency: Utils.g('PROFILE_TEXT_SOFT_LATENCY', 60), // If idle for this long, force a snapshot
547
- adaptiveStep: Utils.g('PROFILE_TEXT_ADAPTIVE_STEP', false)
548
- };
540
+ // Adaptive snapshot profile for plain text.
541
+ this.PROFILE_TEXT = {
542
+ base: Utils.g('PROFILE_TEXT_BASE', 4),
543
+ growth: Utils.g('PROFILE_TEXT_GROWTH', 1.28),
544
+ minInterval: Utils.g('PROFILE_TEXT_MIN_INTERVAL', 4),
545
+ softLatency: Utils.g('PROFILE_TEXT_SOFT_LATENCY', 60),
546
+ adaptiveStep: Utils.g('PROFILE_TEXT_ADAPTIVE_STEP', false)
547
+ };
549
548
 
550
- // Adaptive snapshot profile for code (line-aware).
551
- this.PROFILE_CODE = {
552
- base: 2048, // Fewer snapshots during fence-open warm-up (reduce transient fragments)
553
- growth: 2.6, // Ramp step quickly if adaptive is enabled
554
- minInterval: 500, // Minimum time between snapshots (ms) to avoid churn
555
- softLatency: 1200, // Force snapshot only after a noticeable idle (ms)
556
- minLinesForHL: Utils.g('PROFILE_CODE_HL_N_LINE', 25),
557
- minCharsForHL: Utils.g('PROFILE_CODE_HL_N_CHARS', 5000),
558
- promoteMinInterval: 300, promoteMaxLatency: 800, promoteMinLines: Utils.g('PROFILE_CODE_HL_N_LINE', 25),
559
- adaptiveStep: Utils.g('PROFILE_CODE_ADAPTIVE_STEP', true),
560
- // Hard switches to plain streaming (no incremental hljs, minimal DOM churn)
561
- stopAfterLines: Utils.g('PROFILE_CODE_STOP_HL_AFTER_LINES', 300), // Stop incremental hljs after this many lines
562
- streamPlainAfterLines: 0, // Belt-and-suspenders: enforce plain mode soon after
563
- streamPlainAfterChars: 0, // Also guard huge single-line code (chars cap)
564
- maxFrozenChars: 32000, // If promotions slipped through, cap before spans grow too large
565
- finalHighlightMaxLines: Utils.g('PROFILE_CODE_FINAL_HL_MAX_LINES', 1500),
566
- finalHighlightMaxChars: Utils.g('PROFILE_CODE_FINAL_HL_MAX_CHARS', 350000)
567
- };
549
+ // Adaptive snapshot profile for code (line-aware).
550
+ this.PROFILE_CODE = {
551
+ base: 2048,
552
+ growth: 2.6,
553
+ minInterval: 500,
554
+ softLatency: 1200,
555
+ minLinesForHL: Utils.g('PROFILE_CODE_HL_N_LINE', 25),
556
+ minCharsForHL: Utils.g('PROFILE_CODE_HL_N_CHARS', 5000),
557
+ promoteMinInterval: 300, promoteMaxLatency: 800, promoteMinLines: Utils.g('PROFILE_CODE_HL_N_LINE', 25),
558
+ adaptiveStep: Utils.g('PROFILE_CODE_ADAPTIVE_STEP', true),
559
+ stopAfterLines: Utils.g('PROFILE_CODE_STOP_HL_AFTER_LINES', 300),
560
+ streamPlainAfterLines: 0,
561
+ streamPlainAfterChars: 0,
562
+ maxFrozenChars: 32000,
563
+ finalHighlightMaxLines: Utils.g('PROFILE_CODE_FINAL_HL_MAX_LINES', 1500),
564
+ finalHighlightMaxChars: Utils.g('PROFILE_CODE_FINAL_HL_MAX_CHARS', 350000)
565
+ };
568
566
 
569
- // Debounce for heavy resets (ms).
570
- this.RESET = {
571
- HEAVY_DEBOUNCE_MS: Utils.g('RESET_HEAVY_DEBOUNCE_MS', 24)
572
- };
567
+ // Debounce for heavy resets (ms).
568
+ this.RESET = {
569
+ HEAVY_DEBOUNCE_MS: Utils.g('RESET_HEAVY_DEBOUNCE_MS', 24)
570
+ };
573
571
 
574
- // Logging caps (used by Logger).
575
- this.LOG = {
576
- MAX_QUEUE: Utils.g('LOG_MAX_QUEUE', 400),
577
- MAX_BYTES: Utils.g('LOG_MAX_BYTES', 256 * 1024),
578
- BATCH_MAX: Utils.g('LOG_BATCH_MAX', 64),
579
- RATE_LIMIT_PER_SEC: Utils.g('LOG_RATE_LIMIT_PER_SEC', 0)
580
- };
572
+ // Logging caps (used by Logger).
573
+ this.LOG = {
574
+ MAX_QUEUE: Utils.g('LOG_MAX_QUEUE', 400),
575
+ MAX_BYTES: Utils.g('LOG_MAX_BYTES', 256 * 1024),
576
+ BATCH_MAX: Utils.g('LOG_BATCH_MAX', 64),
577
+ RATE_LIMIT_PER_SEC: Utils.g('LOG_RATE_LIMIT_PER_SEC', 0)
578
+ };
581
579
 
582
- // Async tuning for background work.
583
- this.ASYNC = {
584
- SLICE_MS: Utils.g('ASYNC_SLICE_MS', 12),
585
- MIN_YIELD_MS: Utils.g('ASYNC_MIN_YIELD_MS', 0),
586
- MD_NODES_PER_SLICE: Utils.g('ASYNC_MD_NODES_PER_SLICE', 12)
587
- };
580
+ // Async tuning for background work.
581
+ this.ASYNC = {
582
+ SLICE_MS: Utils.g('ASYNC_SLICE_MS', 12),
583
+ MIN_YIELD_MS: Utils.g('ASYNC_MIN_YIELD_MS', 0),
584
+ MD_NODES_PER_SLICE: Utils.g('ASYNC_MD_NODES_PER_SLICE', 12)
585
+ };
588
586
 
589
- // RAF pump tuning (budget per frame).
590
- this.RAF = {
591
- FLUSH_BUDGET_MS: Utils.g('RAF_FLUSH_BUDGET_MS', 7),
592
- MAX_TASKS_PER_FLUSH: Utils.g('RAF_MAX_TASKS_PER_FLUSH', 120)
593
- };
587
+ // RAF pump tuning (budget per frame).
588
+ this.RAF = {
589
+ FLUSH_BUDGET_MS: Utils.g('RAF_FLUSH_BUDGET_MS', 7),
590
+ MAX_TASKS_PER_FLUSH: Utils.g('RAF_MAX_TASKS_PER_FLUSH', 120)
591
+ };
594
592
 
595
- // Markdown tuning – allow/disallow indented code blocks.
596
- this.MD = {
597
- ALLOW_INDENTED_CODE: Utils.g('MD_ALLOW_INDENTED_CODE', false)
598
- };
593
+ // Markdown tuning – allow/disallow indented code blocks.
594
+ this.MD = {
595
+ ALLOW_INDENTED_CODE: Utils.g('MD_ALLOW_INDENTED_CODE', false)
596
+ };
599
597
 
600
- // Custom markup rules for simple tags in text.
601
- this.CUSTOM_MARKUP_RULES = Utils.g('CUSTOM_MARKUP_RULES', [
598
+ // Custom markup rules for simple tags in text.
599
+ // NOTE: added nl2br + allowBr for think rules; rest unchanged.
600
+ this.CUSTOM_MARKUP_RULES = Utils.g('CUSTOM_MARKUP_RULES', [
602
601
  { name: 'cmd', open: '[!cmd]', close: '[/!cmd]', tag: 'div', className: 'cmd', innerMode: 'text' },
603
- { name: 'think_md', open: '[!think]', close: '[/!think]', tag: 'think', className: '', innerMode: 'text' },
604
- { name: 'think_html', open: '<think>', close: '</think>', tag: 'think', className: '', innerMode: 'text', stream: true },
602
+
603
+ // Think (Markdown-style) convert newlines to <br>, allow real <br> tokens; safe-escape everything else.
604
+ { name: 'think_md', open: '[!think]', close: '[/!think]', tag: 'think', className: '', innerMode: 'text', nl2br: true, allowBr: true },
605
+
606
+ // Think (HTML-style, streaming-friendly)
607
+ { name: 'think_html', open: '<think>', close: '</think>', tag: 'think', className: '', innerMode: 'text', stream: true, nl2br: true, allowBr: true },
608
+
605
609
  { name: 'tool', open: '<tool>', close: '</tool>', tag: 'div', className: 'cmd', innerMode: 'text', stream: true },
606
610
 
607
611
  // Streams+final: convert [!exec]... into fenced python code BEFORE markdown-it
@@ -612,8 +616,8 @@
612
616
  { name: 'exec_html', open: '<execute>', close: '</execute>', innerMode: 'text', stream: true,
613
617
  openReplace: '```python\n', closeReplace: '\n```', phase: 'source' }
614
618
  ]);
619
+ }
615
620
  }
616
- }
617
621
 
618
622
  // ==========================================================================
619
623
  // 1) DOM references
@@ -1206,643 +1210,793 @@
1206
1210
  this.hlQueueSet.clear(); this.hlQueue.length = 0;
1207
1211
  }
1208
1212
  }
1213
+ // ==========================================================================
1214
+ // 4) Custom Markup Processor
1215
+ // ==========================================================================
1216
+
1217
+ class CustomMarkup {
1218
+ constructor(cfg, logger) {
1219
+ this.cfg = cfg || { CUSTOM_MARKUP_RULES: [] };
1220
+ this.logger = logger || new Logger(cfg);
1221
+ this.__compiled = null;
1222
+ this.__hasStreamRules = false; // Fast flag to skip stream work if not needed
1223
+ }
1224
+ _d(line, ctx) { try { this.logger.debug('CM', line, ctx); } catch (_) {} }
1225
+
1226
+ // Decode HTML entities once (safe)
1227
+ decodeEntitiesOnce(s) {
1228
+ if (!s || s.indexOf('&') === -1) return String(s || '');
1229
+ const ta = CustomMarkup._decTA || (CustomMarkup._decTA = document.createElement('textarea'));
1230
+ ta.innerHTML = s;
1231
+ return ta.value;
1232
+ }
1233
+
1234
+ // Escape helpers
1235
+ _escHtml(s) {
1236
+ try { return Utils.escapeHtml(s); } catch (_) {
1237
+ return String(s || '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
1238
+ }
1239
+ }
1240
+ _escapeHtmlAllowBr(text, { convertNewlines = true } = {}) {
1241
+ const PLACEHOLDER = '\u0001__BR__\u0001';
1242
+ let s = String(text || '');
1243
+ s = s.replace(/<br\s*\/?>/gi, PLACEHOLDER);
1244
+ s = this._escHtml(s);
1245
+ if (convertNewlines) s = s.replace(/\r\n|\r|\n/g, '<br>');
1246
+ s = s.replace(new RegExp(PLACEHOLDER, 'g'), '<br>');
1247
+ return s;
1248
+ }
1249
+
1250
+ hasAnyOpenToken(text, rules) {
1251
+ if (!text || !rules || !rules.length) return false;
1252
+ for (let i = 0; i < rules.length; i++) {
1253
+ const r = rules[i];
1254
+ if (!r || !r.open) continue;
1255
+ if (text.indexOf(r.open) !== -1) return true;
1256
+ }
1257
+ return false;
1258
+ }
1259
+
1260
+ _materializeInnerHTML(rule, text, MD) {
1261
+ let payload = String(text || '');
1262
+ const wantsBr = !!(rule && (rule.nl2br || rule.allowBr));
1263
+
1264
+ if (rule && rule.decodeEntities && payload && payload.indexOf('&') !== -1) {
1265
+ try { payload = this.decodeEntitiesOnce(payload); } catch (_) { /* keep original */ }
1266
+ }
1267
+
1268
+ if (wantsBr) {
1269
+ try { return this._escapeHtmlAllowBr(payload, { convertNewlines: !!rule.nl2br }); }
1270
+ catch (_) { return this._escHtml(payload); }
1271
+ }
1272
+
1273
+ if (rule && rule.innerMode === 'markdown-inline' && MD && typeof MD.renderInline === 'function') {
1274
+ try { return MD.renderInline(payload); } catch (_) { return this._escHtml(payload); }
1275
+ }
1276
+ return this._escHtml(payload);
1277
+ }
1278
+
1279
+ _fragmentFromHTML(html, ctxNode) {
1280
+ let frag = null;
1281
+ try {
1282
+ const range = document.createRange();
1283
+ const ctx = (ctxNode && ctxNode.parentNode) ? ctxNode.parentNode : (document.body || document.documentElement);
1284
+ range.selectNode(ctx);
1285
+ frag = range.createContextualFragment(String(html || ''));
1286
+ return frag;
1287
+ } catch (_) {
1288
+ const tmp = document.createElement('div');
1289
+ tmp.innerHTML = String(html || '');
1290
+ frag = document.createDocumentFragment();
1291
+ while (tmp.firstChild) frag.appendChild(tmp.firstChild);
1292
+ return frag;
1293
+ }
1294
+ }
1295
+ _replaceElementWithHTML(el, html) {
1296
+ if (!el || !el.parentNode) return;
1297
+ const parent = el.parentNode;
1298
+ const frag = this._fragmentFromHTML(html, el);
1299
+ try {
1300
+ parent.insertBefore(frag, el);
1301
+ parent.removeChild(el);
1302
+ } catch (_) {
1303
+ const tmp = document.createElement('span');
1304
+ tmp.innerHTML = String(html || '');
1305
+ while (tmp.firstChild) parent.insertBefore(tmp.firstChild, el);
1306
+ parent.removeChild(el);
1307
+ }
1308
+ }
1309
+
1310
+ compile(rules) {
1311
+ const src = Array.isArray(rules) ? rules : (window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES || []);
1312
+ const compiled = [];
1313
+ let hasStream = false;
1314
+
1315
+ for (const r of src) {
1316
+ if (!r || typeof r.open !== 'string' || typeof r.close !== 'string') continue;
1317
+
1318
+ const tag = (r.tag || 'span').toLowerCase();
1319
+ const className = (r.className || r.class || '').trim();
1320
+ const innerMode = (r.innerMode === 'markdown-inline' || r.innerMode === 'text') ? r.innerMode : 'text';
1321
+
1322
+ const stream = !!(r.stream === true);
1323
+ const openReplace = String((r.openReplace != null ? r.openReplace : (r.openReplace || '')) || '');
1324
+ const closeReplace = String((r.closeReplace != null ? r.closeReplace : (r.closeReplace || '')) || '');
1325
+
1326
+ const decodeEntities = (typeof r.decodeEntities === 'boolean')
1327
+ ? r.decodeEntities
1328
+ : ((r.name || '').toLowerCase() === 'cmd' || className === 'cmd');
1329
+
1330
+ let phaseRaw = (typeof r.phase === 'string') ? r.phase.toLowerCase() : '';
1331
+ if (phaseRaw !== 'source' && phaseRaw !== 'html' && phaseRaw !== 'both') phaseRaw = '';
1332
+ const looksLikeFence = (openReplace.indexOf('```') !== -1) || (closeReplace.indexOf('```') !== -1);
1333
+ const phase = phaseRaw || (looksLikeFence ? 'source' : 'html');
1334
+
1335
+ const re = new RegExp(Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close), 'g');
1336
+ const reFull = new RegExp('^' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '$');
1337
+ const reFullTrim = new RegExp('^\\s*' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '\\s*$');
1338
+
1339
+ const nl2br = !!r.nl2br;
1340
+ const allowBr = !!r.allowBr;
1341
+
1342
+ const item = {
1343
+ name: r.name || tag,
1344
+ tag, className, innerMode,
1345
+ open: r.open, close: r.close,
1346
+ decodeEntities,
1347
+ re, reFull, reFullTrim,
1348
+ stream,
1349
+ openReplace, closeReplace,
1350
+ phase,
1351
+ isSourceFence: looksLikeFence,
1352
+ nl2br, allowBr
1353
+ };
1354
+ compiled.push(item);
1355
+ if (stream) hasStream = true;
1356
+ this._d('COMPILE_RULE', { name: item.name, phase: item.phase, stream: item.stream, nl2br: item.nl2br, allowBr: item.allowBr });
1357
+ }
1358
+
1359
+ if (compiled.length === 0) {
1360
+ const open = '[!cmd]', close = '[/!cmd]';
1361
+ const item = {
1362
+ name: 'cmd', tag: 'p', className: 'cmd', innerMode: 'text', open, close,
1363
+ decodeEntities: true,
1364
+ re: new RegExp(Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close), 'g'),
1365
+ reFull: new RegExp('^' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '$'),
1366
+ reFullTrim: new RegExp('^\\s*' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '\\s*$'),
1367
+ stream: false,
1368
+ openReplace: '', closeReplace: '',
1369
+ phase: 'html', isSourceFence: false,
1370
+ nl2br: false, allowBr: false
1371
+ };
1372
+ compiled.push(item);
1373
+ this._d('COMPILE_RULE_FALLBACK', { name: item.name });
1374
+ }
1375
+
1376
+ this.__hasStreamRules = hasStream;
1377
+ return compiled;
1378
+ }
1379
+
1380
+ transformSource(src, opts) {
1381
+ let s = String(src || '');
1382
+ this.ensureCompiled();
1383
+ const rules = this.__compiled;
1384
+ if (!rules || !rules.length) return s;
1385
+
1386
+ const candidates = [];
1387
+ for (let i = 0; i < rules.length; i++) {
1388
+ const r = rules[i];
1389
+ if (!r) continue;
1390
+ if ((r.phase === 'source' || r.phase === 'both') && (r.openReplace || r.closeReplace)) candidates.push(r);
1391
+ }
1392
+ if (!candidates.length) return s;
1393
+
1394
+ const fences = this._findFenceRanges(s);
1395
+ if (!fences.length) {
1396
+ return this._applySourceReplacementsInChunk(s, s, 0, candidates);
1397
+ }
1398
+
1399
+ let out = '';
1400
+ let last = 0;
1401
+ for (let k = 0; k < fences.length; k++) {
1402
+ const [a, b] = fences[k];
1403
+ if (a > last) {
1404
+ const chunk = s.slice(last, a);
1405
+ out += this._applySourceReplacementsInChunk(s, chunk, last, candidates);
1406
+ }
1407
+ out += s.slice(a, b);
1408
+ last = b;
1409
+ }
1410
+ if (last < s.length) {
1411
+ const tail = s.slice(last);
1412
+ out += this._applySourceReplacementsInChunk(s, tail, last, candidates);
1413
+ }
1414
+ return out;
1415
+ }
1416
+
1417
+ getSourceFenceSpecs() {
1418
+ this.ensureCompiled();
1419
+ const rules = this.__compiled || [];
1420
+ const out = [];
1421
+ for (let i = 0; i < rules.length; i++) {
1422
+ const r = rules[i];
1423
+ if (!r || !r.isSourceFence) continue;
1424
+ if (r.phase !== 'source' && r.phase !== 'both') continue;
1425
+ out.push({ open: r.open, close: r.close });
1426
+ }
1427
+ return out;
1428
+ }
1429
+
1430
+ ensureCompiled() {
1431
+ if (!this.__compiled) {
1432
+ this.__compiled = this.compile(window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES);
1433
+ this._d('ENSURE_COMPILED', { count: this.__compiled.length, hasStream: this.__hasStreamRules });
1434
+ }
1435
+ return this.__compiled;
1436
+ }
1437
+
1438
+ setRules(rules) {
1439
+ this.__compiled = this.compile(rules);
1440
+ window.CUSTOM_MARKUP_RULES = Array.isArray(rules) ? rules.slice() : (this.cfg.CUSTOM_MARKUP_RULES || []).slice();
1441
+ this._d('SET_RULES', { count: this.__compiled.length, hasStream: this.__hasStreamRules });
1442
+ }
1443
+ getRules() {
1444
+ const list = (window.CUSTOM_MARKUP_RULES ? window.CUSTOM_MARKUP_RULES.slice()
1445
+ : (this.cfg.CUSTOM_MARKUP_RULES || []).slice());
1446
+ this._d('GET_RULES', { count: list.length });
1447
+ return list;
1448
+ }
1449
+
1450
+ hasStreamRules() {
1451
+ this.ensureCompiled();
1452
+ return !!this.__hasStreamRules;
1453
+ }
1454
+
1455
+ hasStreamOpenerAtStart(text) {
1456
+ if (!text) return false;
1457
+ this.ensureCompiled();
1458
+ const rules = (this.__compiled || []).filter(r => !!r.stream);
1459
+ if (!rules.length) return false;
1460
+ const t = String(text).trimStart();
1461
+ for (let i = 0; i < rules.length; i++) {
1462
+ const r = rules[i];
1463
+ if (!r || !r.open) continue;
1464
+ if (t.startsWith(r.open)) return true;
1465
+ }
1466
+ return false;
1467
+ }
1468
+
1469
+ isInsideForbiddenContext(node) {
1470
+ const p = node.parentElement; if (!p) return true;
1471
+ return !!p.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper, ul, ol, li, dl, dt, dd');
1472
+ }
1473
+ isInsideForbiddenElement(el) {
1474
+ if (!el) return true;
1475
+ return !!el.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper, ul, ol, li, dl, dt, dd');
1476
+ }
1477
+
1478
+ findNextMatch(text, from, rules) {
1479
+ let best = null;
1480
+ for (const rule of rules) {
1481
+ rule.re.lastIndex = from;
1482
+ const m = rule.re.exec(text);
1483
+ if (m) {
1484
+ const start = m.index, end = rule.re.lastIndex;
1485
+ if (!best || start < best.start) best = { rule, start, end, inner: m[1] || '' };
1486
+ }
1487
+ }
1488
+ return best;
1489
+ }
1490
+ findFullMatch(text, rules) {
1491
+ for (const rule of rules) {
1492
+ if (rule.reFull) {
1493
+ const m = rule.reFull.exec(text);
1494
+ if (m) return { rule, inner: m[1] || '' };
1495
+ } else {
1496
+ rule.re.lastIndex = 0;
1497
+ const m = rule.re.exec(text);
1498
+ if (m && m.index === 0 && (rule.re.lastIndex === text.length)) {
1499
+ const m2 = rule.re.exec(text);
1500
+ if (!m2) return { rule: rule, inner: m[1] || '' };
1501
+ }
1502
+ }
1503
+ }
1504
+ return null;
1505
+ }
1506
+
1507
+ setInnerByMode(el, mode, text, MD, decodeEntities = false, rule = null) {
1508
+ let payload = String(text || '');
1509
+ const wantsBr = !!(rule && (rule.nl2br || rule.allowBr));
1510
+
1511
+ if (decodeEntities && payload && payload.indexOf('&') !== -1) {
1512
+ try { payload = this.decodeEntitiesOnce(payload); } catch (_) {}
1513
+ }
1514
+
1515
+ if (wantsBr) {
1516
+ el.innerHTML = this._escapeHtmlAllowBr(payload, { convertNewlines: !!(rule && rule.nl2br) });
1517
+ return;
1518
+ }
1519
+
1520
+ if (mode === 'markdown-inline' && typeof window.markdownit !== 'undefined') {
1521
+ try {
1522
+ if (MD && typeof MD.renderInline === 'function') { el.innerHTML = MD.renderInline(payload); return; }
1523
+ const tempMD = window.markdownit({ html: false, linkify: true, breaks: true, highlight: () => '' });
1524
+ el.innerHTML = tempMD.renderInline(payload); return;
1525
+ } catch (_) {}
1526
+ }
1527
+ el.textContent = payload;
1528
+ }
1529
+
1530
+ _tryReplaceFullParagraph(el, rules, MD) {
1531
+ if (!el || el.tagName !== 'P') return false;
1532
+ if (this.isInsideForbiddenElement(el)) {
1533
+ this._d('P_SKIP_FORBIDDEN', { tag: el.tagName });
1534
+ return false;
1535
+ }
1536
+ const t = el.textContent || '';
1537
+ if (!this.hasAnyOpenToken(t, rules)) return false;
1538
+
1539
+ for (const rule of rules) {
1540
+ if (!rule) continue;
1541
+ const m = rule.reFullTrim ? rule.reFullTrim.exec(t) : null;
1542
+ if (!m) continue;
1543
+
1544
+ const innerText = m[1] || '';
1545
+ if (rule.phase !== 'html' && rule.phase !== 'both') continue;
1546
+
1547
+ if (rule.openReplace || rule.closeReplace) {
1548
+ const innerHTML = this._materializeInnerHTML(rule, innerText, MD);
1549
+ const html = String(rule.openReplace || '') + innerHTML + String(rule.closeReplace || '');
1550
+ this._replaceElementWithHTML(el, html);
1551
+ this._d('P_REPLACED_AS_HTML', { rule: rule.name });
1552
+ return true;
1553
+ }
1554
+
1555
+ const outTag = (rule.tag && typeof rule.tag === 'string') ? rule.tag.toLowerCase() : 'span';
1556
+ const out = document.createElement(outTag === 'p' ? 'p' : outTag);
1557
+ if (rule.className) out.className = rule.className;
1558
+ out.setAttribute('data-cm', rule.name);
1559
+ this.setInnerByMode(out, rule.innerMode, innerText, MD, !!rule.decodeEntities, rule);
1560
+
1561
+ try { el.replaceWith(out); } catch (_) {
1562
+ const par = el.parentNode; if (par) par.replaceChild(out, el);
1563
+ }
1564
+ this._d('P_REPLACED', { rule: rule.name, asTag: outTag });
1565
+ return true;
1566
+ }
1567
+ this._d('P_NO_FULL_MATCH', { preview: this.logger.pv(t, 160) });
1568
+ return false;
1569
+ }
1570
+
1571
+ applyRules(root, MD, rules) {
1572
+ if (!root || !rules || !rules.length) return;
1573
+
1574
+ const scope = (root.nodeType === 1 || root.nodeType === 11) ? root : document;
1575
+
1576
+ // Phase 1: tolerant <p> replacements
1577
+ try {
1578
+ const paragraphs = (typeof scope.querySelectorAll === 'function') ? scope.querySelectorAll('p') : [];
1579
+ this._d('P_TOLERANT_SCAN_START', { count: paragraphs.length });
1580
+
1581
+ if (paragraphs && paragraphs.length) {
1582
+ for (let i = 0; i < paragraphs.length; i++) {
1583
+ const p = paragraphs[i];
1584
+ if (p && p.getAttribute && p.getAttribute('data-cm')) continue;
1585
+ const tc = p && (p.textContent || '');
1586
+ if (!tc || !this.hasAnyOpenToken(tc, rules)) continue;
1587
+ if (this.isInsideForbiddenElement(p)) continue;
1588
+ this._tryReplaceFullParagraph(p, rules, MD);
1589
+ }
1590
+ }
1591
+ } catch (e) {
1592
+ this._d('P_TOLERANT_SCAN_ERR', String(e));
1593
+ }
1594
+
1595
+ // Phase 2: per-text-node inline replacements
1596
+ const self = this;
1597
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1598
+ acceptNode: (node) => {
1599
+ const val = node && node.nodeValue ? node.nodeValue : '';
1600
+ if (!val || !self.hasAnyOpenToken(val, rules)) return NodeFilter.FILTER_SKIP;
1601
+ if (self.isInsideForbiddenContext(node)) return NodeFilter.FILTER_REJECT;
1602
+ return NodeFilter.FILTER_ACCEPT;
1603
+ }
1604
+ });
1605
+
1606
+ let node;
1607
+ while ((node = walker.nextNode())) {
1608
+ const text = node.nodeValue;
1609
+ if (!text || !this.hasAnyOpenToken(text, rules)) continue;
1610
+ const parent = node.parentElement;
1611
+
1612
+ if (parent && parent.tagName === 'P' && parent.childNodes.length === 1) {
1613
+ const fm = this.findFullMatch(text, rules);
1614
+ if (fm) {
1615
+ if ((fm.rule.phase === 'html' || fm.rule.phase === 'both') && (fm.rule.openReplace || fm.rule.closeReplace)) {
1616
+ const innerHTML = this._materializeInnerHTML(fm.rule, fm.inner, MD);
1617
+ const html = String(fm.rule.openReplace || '') + innerHTML + String(fm.rule.closeReplace || '');
1618
+ this._replaceElementWithHTML(parent, html);
1619
+ this._d('WALKER_FULL_REPLACE_HTML', { rule: fm.rule.name, preview: this.logger.pv(text, 160) });
1620
+ continue;
1621
+ }
1622
+ if (fm.rule.tag === 'p') {
1623
+ const out = document.createElement('p');
1624
+ if (fm.rule.className) out.className = fm.rule.className;
1625
+ out.setAttribute('data-cm', fm.rule.name);
1626
+ this.setInnerByMode(out, fm.rule.innerMode, fm.inner, MD, !!fm.rule.decodeEntities, fm.rule);
1627
+ try { parent.replaceWith(out); } catch (_) {
1628
+ const par = parent.parentNode; if (par) par.replaceChild(out, parent);
1629
+ }
1630
+ this._d('WALKER_FULL_REPLACE', { rule: fm.rule.name, preview: this.logger.pv(text, 160) });
1631
+ continue;
1632
+ }
1633
+ }
1634
+ }
1209
1635
 
1210
- // ==========================================================================
1211
- // 4) Custom Markup Processor
1212
- // ==========================================================================
1213
-
1214
- class CustomMarkup {
1215
- constructor(cfg, logger) {
1216
- this.cfg = cfg || { CUSTOM_MARKUP_RULES: [] };
1217
- this.logger = logger || new Logger(cfg);
1218
- this.__compiled = null;
1219
- this.__hasStreamRules = false; // Fast flag to skip stream work if not needed
1220
- }
1221
- _d(line, ctx) { try { this.logger.debug('CM', line, ctx); } catch (_) {} }
1222
-
1223
- // Decode HTML entities once (safe)
1224
- // This addresses cases when linkify/full markdown path leaves literal "&quot;" etc. in text nodes.
1225
- // We decode only for rules that explicitly opt-in (see compile()) to avoid changing semantics globally.
1226
- decodeEntitiesOnce(s) {
1227
- if (!s || s.indexOf('&') === -1) return String(s || '');
1228
- const ta = CustomMarkup._decTA || (CustomMarkup._decTA = document.createElement('textarea'));
1229
- ta.innerHTML = s;
1230
- return ta.value;
1231
- }
1232
-
1233
- // Small helper: escape text to safe HTML (shared Utils or fallback)
1234
- _escHtml(s) {
1235
- try { return Utils.escapeHtml(s); } catch (_) {
1236
- return String(s || '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
1237
- }
1238
- }
1239
-
1240
- // quick check if any rule's open token is present in text (used to skip expensive work early)
1241
- hasAnyOpenToken(text, rules) {
1242
- if (!text || !rules || !rules.length) return false;
1243
- for (let i = 0; i < rules.length; i++) {
1244
- const r = rules[i];
1245
- if (!r || !r.open) continue;
1246
- if (text.indexOf(r.open) !== -1) return true;
1247
- }
1248
- return false;
1249
- }
1250
-
1251
- // Build inner HTML from text according to rule's mode (markdown-inline | text) with optional entity decode.
1252
- _materializeInnerHTML(rule, text, MD) {
1253
- let payload = String(text || '');
1254
- if (rule && rule.decodeEntities && payload && payload.indexOf('&') !== -1) {
1255
- try { payload = this.decodeEntitiesOnce(payload); } catch (_) { /* keep original */ }
1256
- }
1257
- if (rule && rule.innerMode === 'markdown-inline' && MD && typeof MD.renderInline === 'function') {
1258
- try { return MD.renderInline(payload); } catch (_) { return this._escHtml(payload); }
1259
- }
1260
- return this._escHtml(payload);
1261
- }
1262
-
1263
- // Make a DOM Fragment from HTML string (robust across contexts).
1264
- _fragmentFromHTML(html, ctxNode) {
1265
- let frag = null;
1266
- try {
1267
- const range = document.createRange();
1268
- const ctx = (ctxNode && ctxNode.parentNode) ? ctxNode.parentNode : (document.body || document.documentElement);
1269
- range.selectNode(ctx);
1270
- frag = range.createContextualFragment(String(html || ''));
1271
- return frag;
1272
- } catch (_) {
1273
- const tmp = document.createElement('div');
1274
- tmp.innerHTML = String(html || '');
1275
- frag = document.createDocumentFragment();
1276
- while (tmp.firstChild) frag.appendChild(tmp.firstChild);
1277
- return frag;
1278
- }
1279
- }
1280
-
1281
- // Replace one element in DOM with HTML string (keeps siblings intact).
1282
- _replaceElementWithHTML(el, html) {
1283
- if (!el || !el.parentNode) return;
1284
- const parent = el.parentNode;
1285
- const frag = this._fragmentFromHTML(html, el);
1286
- try {
1287
- // Insert new nodes before the old element, then remove the old element (widely supported).
1288
- parent.insertBefore(frag, el);
1289
- parent.removeChild(el);
1290
- } catch (_) {
1291
- // Conservative fallback: wrap in a span if direct fragment insertion failed for some reason.
1292
- const tmp = document.createElement('span');
1293
- tmp.innerHTML = String(html || '');
1294
- while (tmp.firstChild) parent.insertBefore(tmp.firstChild, el);
1295
- parent.removeChild(el);
1296
- }
1297
- }
1298
-
1299
- // Compile rules once; also precompile strict and whitespace-tolerant "full match" regexes.
1300
- compile(rules) {
1301
- const src = Array.isArray(rules) ? rules : (window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES || []);
1302
- const compiled = [];
1303
- let hasStream = false;
1304
-
1305
- for (const r of src) {
1306
- if (!r || typeof r.open !== 'string' || typeof r.close !== 'string') continue;
1307
-
1308
- const tag = (r.tag || 'span').toLowerCase();
1309
- const className = (r.className || r.class || '').trim();
1310
- const innerMode = (r.innerMode === 'markdown-inline' || r.innerMode === 'text') ? r.innerMode : 'text';
1311
-
1312
- const stream = !!(r.stream === true);
1313
- const openReplace = String((r.openReplace != null ? r.openReplace : (r.openReplace || '')) || '');
1314
- const closeReplace = String((r.closeReplace != null ? r.closeReplace : (r.closeReplace || '')) || '');
1315
-
1316
- // Back-compat: decode entities default true for cmd-like
1317
- const decodeEntities = (typeof r.decodeEntities === 'boolean')
1318
- ? r.decodeEntities
1319
- : ((r.name || '').toLowerCase() === 'cmd' || className === 'cmd');
1320
-
1321
- // Optional application phase (where replacement should happen)
1322
- // - 'source' => before markdown-it
1323
- // - 'html' => after markdown-it (DOM fragment)
1324
- // - 'both'
1325
- let phaseRaw = (typeof r.phase === 'string') ? r.phase.toLowerCase() : '';
1326
- if (phaseRaw !== 'source' && phaseRaw !== 'html' && phaseRaw !== 'both') phaseRaw = '';
1327
- // Heuristic: if replacement contains fenced code backticks, default to 'source'
1328
- const looksLikeFence = (openReplace.indexOf('```') !== -1) || (closeReplace.indexOf('```') !== -1);
1329
- const phase = phaseRaw || (looksLikeFence ? 'source' : 'html');
1330
-
1331
- const re = new RegExp(Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close), 'g');
1332
- const reFull = new RegExp('^' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '$');
1333
- const reFullTrim = new RegExp('^\\s*' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '\\s*$');
1334
-
1335
- const item = {
1336
- name: r.name || tag,
1337
- tag, className, innerMode,
1338
- open: r.open, close: r.close,
1339
- decodeEntities,
1340
- re, reFull, reFullTrim,
1341
- stream,
1342
- openReplace, closeReplace,
1343
- phase, // NEW: where this rule should be applied
1344
- isSourceFence: looksLikeFence // NEW: hints StreamEngine to treat as custom fence
1345
- };
1346
- compiled.push(item);
1347
- if (stream) hasStream = true;
1348
- this._d('COMPILE_RULE', { name: item.name, phase: item.phase, stream: item.stream });
1349
- }
1350
-
1351
- if (compiled.length === 0) {
1352
- const open = '[!cmd]', close = '[/!cmd]';
1353
- const item = {
1354
- name: 'cmd', tag: 'p', className: 'cmd', innerMode: 'text', open, close,
1355
- decodeEntities: true,
1356
- re: new RegExp(Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close), 'g'),
1357
- reFull: new RegExp('^' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '$'),
1358
- reFullTrim: new RegExp('^\\s*' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '\\s*$'),
1359
- stream: false,
1360
- openReplace: '', closeReplace: '',
1361
- phase: 'html', isSourceFence: false
1362
- };
1363
- compiled.push(item);
1364
- this._d('COMPILE_RULE_FALLBACK', { name: item.name });
1365
- }
1366
-
1367
- this.__hasStreamRules = hasStream;
1368
- return compiled;
1369
- }
1370
-
1371
- // pre-markdown source transformer – applies only rules for 'source'/'both' with replacements
1372
- // IMPORTANT CHANGE:
1373
- // - Skips replacements inside fenced code blocks (``` / ~~~).
1374
- // - Applies only when the rule opener is at top-level of the line (no list markers/blockquote).
1375
- transformSource(src, opts) {
1376
- let s = String(src || '');
1377
- this.ensureCompiled();
1378
- const rules = this.__compiled;
1379
- if (!rules || !rules.length) return s;
1380
-
1381
- // Pick only source-phase rules with explicit replacements
1382
- const candidates = [];
1383
- for (let i = 0; i < rules.length; i++) {
1384
- const r = rules[i];
1385
- if (!r) continue;
1386
- if ((r.phase === 'source' || r.phase === 'both') && (r.openReplace || r.closeReplace)) candidates.push(r);
1387
- }
1388
- if (!candidates.length) return s;
1389
-
1390
- // Compute fenced-code ranges once to exclude them from replacements (production-safe).
1391
- const fences = this._findFenceRanges(s);
1392
- if (!fences.length) {
1393
- // No code fences in source; apply top-level guarded replacements globally.
1394
- return this._applySourceReplacementsInChunk(s, s, 0, candidates);
1395
- }
1396
-
1397
- // Apply replacements only in segments outside fenced code.
1398
- let out = '';
1399
- let last = 0;
1400
- for (let k = 0; k < fences.length; k++) {
1401
- const [a, b] = fences[k];
1402
- if (a > last) {
1403
- const chunk = s.slice(last, a);
1404
- out += this._applySourceReplacementsInChunk(s, chunk, last, candidates);
1405
- }
1406
- out += s.slice(a, b); // pass fenced code verbatim
1407
- last = b;
1408
- }
1409
- if (last < s.length) {
1410
- const tail = s.slice(last);
1411
- out += this._applySourceReplacementsInChunk(s, tail, last, candidates);
1412
- }
1413
- return out;
1414
- }
1415
-
1416
- // expose custom fence specs (to StreamEngine)
1417
- getSourceFenceSpecs() {
1418
- this.ensureCompiled();
1419
- const rules = this.__compiled || [];
1420
- const out = [];
1421
- for (let i = 0; i < rules.length; i++) {
1422
- const r = rules[i];
1423
- if (!r || !r.isSourceFence) continue;
1424
- // Only expose when they actually look like fences in source phase
1425
- if (r.phase !== 'source' && r.phase !== 'both') continue;
1426
- out.push({ open: r.open, close: r.close });
1427
- }
1428
- return out;
1429
- }
1430
-
1431
- // Ensure rules are compiled and cached.
1432
- ensureCompiled() {
1433
- if (!this.__compiled) {
1434
- this.__compiled = this.compile(window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES);
1435
- this._d('ENSURE_COMPILED', { count: this.__compiled.length, hasStream: this.__hasStreamRules });
1436
- }
1437
- return this.__compiled;
1438
- }
1439
-
1440
- // Replace rules set (also exposes rules on window).
1441
- setRules(rules) {
1442
- this.__compiled = this.compile(rules);
1443
- window.CUSTOM_MARKUP_RULES = Array.isArray(rules) ? rules.slice() : (this.cfg.CUSTOM_MARKUP_RULES || []).slice();
1444
- this._d('SET_RULES', { count: this.__compiled.length, hasStream: this.__hasStreamRules });
1445
- }
1446
-
1447
- // Return current rules as array.
1448
- getRules() {
1449
- const list = (window.CUSTOM_MARKUP_RULES ? window.CUSTOM_MARKUP_RULES.slice()
1450
- : (this.cfg.CUSTOM_MARKUP_RULES || []).slice());
1451
- this._d('GET_RULES', { count: list.length });
1452
- return list;
1453
- }
1454
-
1455
- // Fast switch: do we have any rules that want streaming parsing?
1456
- hasStreamRules() {
1457
- this.ensureCompiled();
1458
- return !!this.__hasStreamRules;
1459
- }
1460
-
1461
- // Context guards
1462
- isInsideForbiddenContext(node) {
1463
- const p = node.parentElement; if (!p) return true;
1464
- // IMPORTANT: exclude code/math/hljs/wrappers AND list contexts (ul/ol/li/dl/dt/dd)
1465
- return !!p.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper, ul, ol, li, dl, dt, dd');
1466
- }
1467
- isInsideForbiddenElement(el) {
1468
- if (!el) return true;
1469
- // IMPORTANT: exclude code/math/hljs/wrappers AND list contexts (ul/ol/li/dl/dt/dd)
1470
- return !!el.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper, ul, ol, li, dl, dt, dd');
1471
- }
1472
-
1473
- // Global finder on a single text blob (original per-text-node logic).
1474
- findNextMatch(text, from, rules) {
1475
- let best = null;
1476
- for (const rule of rules) {
1477
- rule.re.lastIndex = from;
1478
- const m = rule.re.exec(text);
1479
- if (m) {
1480
- const start = m.index, end = rule.re.lastIndex;
1481
- if (!best || start < best.start) best = { rule, start, end, inner: m[1] || '' };
1482
- }
1483
- }
1484
- return best;
1485
- }
1486
-
1487
- // Strict full match of a pure text node (legacy path).
1488
- findFullMatch(text, rules) {
1489
- for (const rule of rules) {
1490
- if (rule.reFull) {
1491
- const m = rule.reFull.exec(text);
1492
- if (m) return { rule, inner: m[1] || '' };
1493
- } else {
1494
- rule.re.lastIndex = 0;
1495
- const m = rule.re.exec(text);
1496
- if (m && m.index === 0 && (rule.re.lastIndex === text.length)) {
1497
- const m2 = rule.re.exec(text);
1498
- if (!m2) return { rule, inner: m[1] || '' };
1499
- }
1500
- }
1501
- }
1502
- return null;
1503
- }
1504
-
1505
- // Set inner content according to the rule's mode, with optional entity decode (element mode).
1506
- setInnerByMode(el, mode, text, MD, decodeEntities = false) {
1507
- let payload = String(text || '');
1508
- if (decodeEntities && payload && payload.indexOf('&') !== -1) {
1509
- try { payload = this.decodeEntitiesOnce(payload); } catch (_) {}
1510
- }
1511
-
1512
- if (mode === 'markdown-inline' && typeof window.markdownit !== 'undefined') {
1513
- try {
1514
- if (MD && typeof MD.renderInline === 'function') { el.innerHTML = MD.renderInline(payload); return; }
1515
- const tempMD = window.markdownit({ html: false, linkify: true, breaks: true, highlight: () => '' });
1516
- el.innerHTML = tempMD.renderInline(payload); return;
1517
- } catch (_) {}
1518
- }
1519
- el.textContent = payload;
1520
- }
1521
-
1522
- // Try to replace an entire <p> that is a full custom markup match.
1523
- _tryReplaceFullParagraph(el, rules, MD) {
1524
- if (!el || el.tagName !== 'P') return false;
1525
- if (this.isInsideForbiddenElement(el)) {
1526
- this._d('P_SKIP_FORBIDDEN', { tag: el.tagName });
1527
- return false;
1528
- }
1529
- const t = el.textContent || '';
1530
- if (!this.hasAnyOpenToken(t, rules)) return false;
1531
-
1532
- for (const rule of rules) {
1533
- if (!rule) continue;
1534
- const m = rule.reFullTrim ? rule.reFullTrim.exec(t) : null;
1535
- if (!m) continue;
1536
-
1537
- const innerText = m[1] || '';
1538
-
1539
- if (rule.phase !== 'html' && rule.phase !== 'both') continue; // element materialization is html-phase only
1540
-
1541
- if (rule.openReplace || rule.closeReplace) {
1542
- const innerHTML = this._materializeInnerHTML(rule, innerText, MD);
1543
- const html = String(rule.openReplace || '') + innerHTML + String(rule.closeReplace || '');
1544
- this._replaceElementWithHTML(el, html);
1545
- this._d('P_REPLACED_AS_HTML', { rule: rule.name });
1546
- return true;
1547
- }
1548
-
1549
- const outTag = (rule.tag && typeof rule.tag === 'string') ? rule.tag.toLowerCase() : 'span';
1550
- const out = document.createElement(outTag === 'p' ? 'p' : outTag);
1551
- if (rule.className) out.className = rule.className;
1552
- out.setAttribute('data-cm', rule.name);
1553
- this.setInnerByMode(out, rule.innerMode, innerText, MD, !!rule.decodeEntities);
1554
-
1555
- try { el.replaceWith(out); } catch (_) {
1556
- const par = el.parentNode; if (par) par.replaceChild(out, el);
1557
- }
1558
- this._d('P_REPLACED', { rule: rule.name, asTag: outTag });
1559
- return true;
1560
- }
1561
- this._d('P_NO_FULL_MATCH', { preview: this.logger.pv(t, 160) });
1562
- return false;
1563
- }
1564
-
1565
- // Core implementation shared by static and streaming passes.
1566
- applyRules(root, MD, rules) {
1567
- if (!root || !rules || !rules.length) return;
1568
-
1569
- const scope = (root.nodeType === 1 || root.nodeType === 11) ? root : document;
1570
-
1571
- // Phase 1: tolerant <p> replacements
1572
- try {
1573
- const paragraphs = (typeof scope.querySelectorAll === 'function') ? scope.querySelectorAll('p') : [];
1574
- this._d('P_TOLERANT_SCAN_START', { count: paragraphs.length });
1575
-
1576
- if (paragraphs && paragraphs.length) {
1577
- for (let i = 0; i < paragraphs.length; i++) {
1578
- const p = paragraphs[i];
1579
- if (p && p.getAttribute && p.getAttribute('data-cm')) continue;
1580
- const tc = p && (p.textContent || '');
1581
- if (!tc || !this.hasAnyOpenToken(tc, rules)) continue;
1582
- // Skip paragraphs inside forbidden contexts (includes lists now)
1583
- if (this.isInsideForbiddenElement(p)) continue;
1584
- this._tryReplaceFullParagraph(p, rules, MD);
1585
- }
1586
- }
1587
- } catch (e) {
1588
- this._d('P_TOLERANT_SCAN_ERR', String(e));
1589
- }
1590
-
1591
- // Phase 2: legacy per-text-node pass for partial inline cases.
1592
- const self = this;
1593
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1594
- acceptNode: (node) => {
1595
- const val = node && node.nodeValue ? node.nodeValue : '';
1596
- if (!val || !self.hasAnyOpenToken(val, rules)) return NodeFilter.FILTER_SKIP;
1597
- if (self.isInsideForbiddenContext(node)) return NodeFilter.FILTER_REJECT;
1598
- return NodeFilter.FILTER_ACCEPT;
1599
- }
1600
- });
1601
-
1602
- let node;
1603
- while ((node = walker.nextNode())) {
1604
- const text = node.nodeValue;
1605
- if (!text || !this.hasAnyOpenToken(text, rules)) continue; // quick skip
1606
- const parent = node.parentElement;
1607
-
1608
- // Entire text node equals one full match and parent is <p>.
1609
- if (parent && parent.tagName === 'P' && parent.childNodes.length === 1) {
1610
- const fm = this.findFullMatch(text, rules);
1611
- if (fm) {
1612
- // If explicit HTML replacements are provided, swap <p> for exact HTML (only for html/both phase).
1613
- if ((fm.rule.phase === 'html' || fm.rule.phase === 'both') && (fm.rule.openReplace || fm.rule.closeReplace)) {
1614
- const innerHTML = this._materializeInnerHTML(fm.rule, fm.inner, MD);
1615
- const html = String(fm.rule.openReplace || '') + innerHTML + String(fm.rule.closeReplace || '');
1616
- this._replaceElementWithHTML(parent, html);
1617
- this._d('WALKER_FULL_REPLACE_HTML', { rule: fm.rule.name, preview: this.logger.pv(text, 160) });
1618
- continue;
1619
- }
1620
-
1621
- // Backward-compatible: only replace as <p> when rule tag is 'p'
1622
- if (fm.rule.tag === 'p') {
1623
- const out = document.createElement('p');
1624
- if (fm.rule.className) out.className = fm.rule.className;
1625
- out.setAttribute('data-cm', fm.rule.name);
1626
- this.setInnerByMode(out, fm.rule.innerMode, fm.inner, MD, !!fm.rule.decodeEntities);
1627
- try { parent.replaceWith(out); } catch (_) {
1628
- const par = parent.parentNode; if (par) par.replaceChild(out, parent);
1629
- }
1630
- this._d('WALKER_FULL_REPLACE', { rule: fm.rule.name, preview: this.logger.pv(text, 160) });
1631
- continue;
1632
- }
1633
- }
1634
- }
1635
-
1636
- // General inline replacement inside the text node (span-like or HTML-replace).
1637
- let i = 0;
1638
- let didReplace = false;
1639
- const frag = document.createDocumentFragment();
1640
-
1641
- while (i < text.length) {
1642
- const m = this.findNextMatch(text, i, rules);
1643
- if (!m) break;
1644
-
1645
- if (m.start > i) {
1646
- frag.appendChild(document.createTextNode(text.slice(i, m.start)));
1647
- }
1648
-
1649
- // If HTML replacements are provided, build exact HTML around processed inner – only for html/both phase.
1650
- if ((m.rule.openReplace || m.rule.closeReplace) && (m.rule.phase === 'html' || m.rule.phase === 'both')) {
1651
- const innerHTML = this._materializeInnerHTML(m.rule, m.inner, MD);
1652
- const html = String(m.rule.openReplace || '') + innerHTML + String(m.rule.closeReplace || '');
1653
- const part = this._fragmentFromHTML(html, node);
1654
- frag.appendChild(part);
1655
- this._d('WALKER_INLINE_MATCH_HTML', { rule: m.rule.name, start: m.start, end: m.end });
1656
- i = m.end; didReplace = true; continue;
1657
- }
1658
-
1659
- // If rule is not html-phase, do NOT inject open/close replacements here (source-only rules are handled pre-md).
1660
- if (m.rule.openReplace || m.rule.closeReplace) {
1661
- // Source-only replacement met in DOM pass – keep original text verbatim for this match.
1662
- frag.appendChild(document.createTextNode(text.slice(m.start, m.end)));
1663
- this._d('WALKER_INLINE_SKIP_SOURCE_PHASE_HTML', { rule: m.rule.name, start: m.start, end: m.end });
1664
- i = m.end; didReplace = true; continue;
1665
- }
1666
-
1667
- // Element-based inline replacement (original behavior).
1668
- const tag = (m.rule.tag === 'p') ? 'span' : m.rule.tag;
1669
- const el = document.createElement(tag);
1670
- if (m.rule.className) el.className = m.rule.className;
1671
- el.setAttribute('data-cm', m.rule.name);
1672
- this.setInnerByMode(el, m.rule.innerMode, m.inner, MD, !!m.rule.decodeEntities);
1673
- frag.appendChild(el);
1674
- this._d('WALKER_INLINE_MATCH', { rule: m.rule.name, start: m.start, end: m.end });
1675
-
1676
- i = m.end;
1677
- didReplace = true;
1678
- }
1679
-
1680
- if (!didReplace) continue;
1681
-
1682
- if (i < text.length) {
1683
- frag.appendChild(document.createTextNode(text.slice(i)));
1684
- }
1685
-
1686
- const parentNode = node.parentNode;
1687
- if (parentNode) {
1688
- parentNode.replaceChild(frag, node);
1689
- this._d('WALKER_INLINE_DONE', { preview: this.logger.pv(text, 120) });
1690
- }
1691
- }
1692
- }
1693
-
1694
- // Public API: apply custom markup for full (static) paths – unchanged behavior.
1695
- apply(root, MD) {
1696
- this.ensureCompiled();
1697
- this.applyRules(root, MD, this.__compiled);
1698
- }
1699
-
1700
- // Public API: apply only stream-enabled rules (used in snapshots).
1701
- applyStream(root, MD) {
1702
- this.ensureCompiled();
1703
- if (!this.__hasStreamRules) return;
1704
- const rules = this.__compiled.filter(r => !!r.stream);
1705
- if (!rules.length) return;
1706
- this.applyRules(root, MD, rules);
1707
- }
1708
-
1709
- // -----------------------------
1710
- // INTERNAL HELPERS (NEW)
1711
- // -----------------------------
1712
-
1713
- // Scan source and return ranges [start, end) of fenced code blocks (``` or ~~~).
1714
- // Matches Markdown fences at line-start with up to 3 spaces/tabs indentation.
1715
- _findFenceRanges(s) {
1716
- const ranges = [];
1717
- const n = s.length;
1718
- let i = 0;
1719
- let inFence = false;
1720
- let fenceMark = '';
1721
- let fenceLen = 0;
1722
- let startLineStart = 0;
1723
-
1724
- while (i < n) {
1725
- const lineStart = i;
1726
- // Find line end and newline length
1727
- let j = lineStart;
1728
- while (j < n && s.charCodeAt(j) !== 10 && s.charCodeAt(j) !== 13) j++;
1729
- const lineEnd = j;
1730
- let nl = 0;
1731
- if (j < n) {
1732
- if (s.charCodeAt(j) === 13 && j + 1 < n && s.charCodeAt(j + 1) === 10) nl = 2;
1733
- else nl = 1;
1734
- }
1735
-
1736
- // Compute indentation up to 3 "spaces" (tabs count as 1 here – safe heuristic)
1737
- let k = lineStart;
1738
- let indent = 0;
1739
- while (k < lineEnd) {
1740
- const c = s.charCodeAt(k);
1741
- if (c === 32 /* space */) { indent++; if (indent > 3) break; k++; }
1742
- else if (c === 9 /* tab */) { indent++; if (indent > 3) break; k++; }
1743
- else break;
1744
- }
1745
-
1746
- if (!inFence) {
1747
- if (indent <= 3 && k < lineEnd) {
1748
- const ch = s.charCodeAt(k);
1749
- if (ch === 0x60 /* ` */ || ch === 0x7E /* ~ */) {
1750
- const mark = String.fromCharCode(ch);
1751
- let m = k;
1752
- while (m < lineEnd && s.charCodeAt(m) === ch) m++;
1753
- const run = m - k;
1754
- if (run >= 3) {
1755
- inFence = true;
1756
- fenceMark = mark;
1757
- fenceLen = run;
1758
- startLineStart = lineStart;
1759
- }
1760
- }
1761
- }
1762
- } else {
1763
- if (indent <= 3 && k < lineEnd && s.charCodeAt(k) === fenceMark.charCodeAt(0)) {
1764
- let m = k;
1765
- while (m < lineEnd && s.charCodeAt(m) === fenceMark.charCodeAt(0)) m++;
1766
- const run = m - k;
1767
- if (run >= fenceLen) {
1768
- // Only whitespace is allowed after closing fence on the same line
1769
- let onlyWS = true;
1770
- for (let t = m; t < lineEnd; t++) {
1771
- const cc = s.charCodeAt(t);
1772
- if (cc !== 32 && cc !== 9) { onlyWS = false; break; }
1773
- }
1774
- if (onlyWS) {
1775
- const endIdx = lineEnd + nl; // include trailing newline if present
1776
- ranges.push([startLineStart, endIdx]);
1777
- inFence = false; fenceMark = ''; fenceLen = 0; startLineStart = 0;
1778
- }
1779
- }
1780
- }
1781
- }
1782
- i = lineEnd + nl;
1783
- }
1784
-
1785
- // If EOF while still in fence, mark until end of string.
1786
- if (inFence) ranges.push([startLineStart, n]);
1787
- return ranges;
1788
- }
1789
-
1790
- // Check if match starts at "top-level" of a line:
1791
- // - up to 3 leading spaces/tabs allowed
1792
- // - not a list item marker ("- ", "+ ", "* ", "1. ", "1) ") and not a blockquote ("> ")
1793
- // - nothing else precedes the token on the same line
1794
- _isTopLevelLineInSource(s, absIdx) {
1795
- let ls = absIdx;
1796
- while (ls > 0) {
1797
- const ch = s.charCodeAt(ls - 1);
1798
- if (ch === 10 /* \n */ || ch === 13 /* \r */) break;
1799
- ls--;
1800
- }
1801
- const prefix = s.slice(ls, absIdx);
1802
-
1803
- // Strip up to 3 leading "spaces" (tabs treated as 1 – acceptable heuristic)
1804
- let i = 0, indent = 0;
1805
- while (i < prefix.length) {
1806
- const c = prefix.charCodeAt(i);
1807
- if (c === 32) { indent++; if (indent > 3) break; i++; }
1808
- else if (c === 9) { indent++; if (indent > 3) break; i++; }
1809
- else break;
1810
- }
1811
- if (indent > 3) return false;
1812
- const rest = prefix.slice(i);
1813
-
1814
- // Reject lists/blockquote
1815
- if (/^>\s?/.test(rest)) return false;
1816
- if (/^[-+*]\s/.test(rest)) return false;
1817
- if (/^\d+[.)]\s/.test(rest)) return false;
1818
-
1819
- // If any other non-whitespace text precedes the token on this line – not top-level
1820
- if (rest.trim().length > 0) return false;
1821
-
1822
- return true;
1823
- }
1824
-
1825
- // Apply source-phase replacements to one outside-of-fence chunk with top-level guard.
1826
- _applySourceReplacementsInChunk(full, chunk, baseOffset, rules) {
1827
- let t = chunk;
1828
- for (let i = 0; i < rules.length; i++) {
1829
- const r = rules[i];
1830
- if (!r || !(r.openReplace || r.closeReplace)) continue;
1831
- try {
1832
- r.re.lastIndex = 0;
1833
- t = t.replace(r.re, (match, inner, offset /*, ...rest*/) => {
1834
- const abs = baseOffset + (offset | 0);
1835
- // Only apply when opener is at top-level on that line (not in lists/blockquote)
1836
- if (!this._isTopLevelLineInSource(full, abs)) return match;
1837
- const open = r.openReplace || '';
1838
- const close = r.closeReplace || '';
1839
- return open + (inner || '') + close;
1840
- });
1841
- } catch (_) { /* keep chunk as is on any error */ }
1842
- }
1843
- return t;
1844
- }
1845
- }
1636
+ let i = 0;
1637
+ let didReplace = false;
1638
+ const frag = document.createDocumentFragment();
1639
+
1640
+ while (i < text.length) {
1641
+ const m = this.findNextMatch(text, i, rules);
1642
+ if (!m) break;
1643
+
1644
+ if (m.start > i) {
1645
+ frag.appendChild(document.createTextNode(text.slice(i, m.start)));
1646
+ }
1647
+
1648
+ if ((m.rule.openReplace || m.rule.closeReplace) && (m.rule.phase === 'html' || m.rule.phase === 'both')) {
1649
+ const innerHTML = this._materializeInnerHTML(m.rule, m.inner, MD);
1650
+ const html = String(m.rule.openReplace || '') + innerHTML + String(m.rule.closeReplace || '');
1651
+ const part = this._fragmentFromHTML(html, node);
1652
+ frag.appendChild(part);
1653
+ this._d('WALKER_INLINE_MATCH_HTML', { rule: m.rule.name, start: m.start, end: m.end });
1654
+ i = m.end; didReplace = true; continue;
1655
+ }
1656
+
1657
+ if (m.rule.openReplace || m.rule.closeReplace) {
1658
+ frag.appendChild(document.createTextNode(text.slice(m.start, m.end)));
1659
+ this._d('WALKER_INLINE_SKIP_SOURCE_PHASE_HTML', { rule: m.rule.name, start: m.start, end: m.end });
1660
+ i = m.end; didReplace = true; continue;
1661
+ }
1662
+
1663
+ const tag = (m.rule.tag === 'p') ? 'span' : m.rule.tag;
1664
+ const el = document.createElement(tag);
1665
+ if (m.rule.className) el.className = m.rule.className;
1666
+ el.setAttribute('data-cm', m.rule.name);
1667
+ this.setInnerByMode(el, m.rule.innerMode, m.inner, MD, !!m.rule.decodeEntities, m.rule);
1668
+ frag.appendChild(el);
1669
+ this._d('WALKER_INLINE_MATCH', { rule: m.rule.name, start: m.start, end: m.end });
1670
+
1671
+ i = m.end;
1672
+ didReplace = true;
1673
+ }
1674
+
1675
+ if (!didReplace) continue;
1676
+ if (i < text.length) frag.appendChild(document.createTextNode(text.slice(i)));
1677
+
1678
+ const parentNode = node.parentNode;
1679
+ if (parentNode) {
1680
+ parentNode.replaceChild(frag, node);
1681
+ this._d('WALKER_INLINE_DONE', { preview: this.logger.pv(text, 120) });
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ // Public API: full pass (static)
1687
+ apply(root, MD) {
1688
+ this.ensureCompiled();
1689
+ this.applyRules(root, MD, this.__compiled);
1690
+ }
1691
+
1692
+ // Public API: stream pass (stream-enabled rules only)
1693
+ applyStream(root, MD) {
1694
+ this.ensureCompiled();
1695
+ if (!this.__hasStreamRules) return;
1696
+ const rules = this.__compiled.filter(r => !!r.stream);
1697
+ if (!rules.length) return;
1698
+
1699
+ // 1) Normal html-phase replacements where both tokens are within one node/paragraph
1700
+ this.applyRules(root, MD, rules);
1701
+
1702
+ // 2) If only opener is present, start pending block and wrap the remainder of the snapshot
1703
+ try { this.applyStreamPartialOpeners(root, MD, rules); } catch (_) {}
1704
+
1705
+ // 3) If a pending block exists and we can see a closer now – finalize immediately
1706
+ try { this.applyStreamFinalizeClosers(root, rules); } catch (_) {}
1707
+ }
1708
+
1709
+ // Streaming: begin pending wrapper when opener is unmatched in the same text node.
1710
+ applyStreamPartialOpeners(root, MD, rulesAll) {
1711
+ if (!root) return;
1712
+
1713
+ const rules = (rulesAll || []).filter(r =>
1714
+ (r && (r.phase === 'html' || r.phase === 'both') && !(r.openReplace || r.closeReplace) && r.open && r.close)
1715
+ );
1716
+ if (!rules.length) return;
1717
+
1718
+ const scope = (root.nodeType === 1 || root.nodeType === 11) ? root : document;
1719
+ const self = this;
1720
+
1721
+ const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT, {
1722
+ acceptNode(node) {
1723
+ const val = node && node.nodeValue ? node.nodeValue : '';
1724
+ if (!val || !self.hasAnyOpenToken(val, rules)) return NodeFilter.FILTER_SKIP;
1725
+ if (self.isInsideForbiddenContext(node)) return NodeFilter.FILTER_REJECT;
1726
+ return NodeFilter.FILTER_ACCEPT;
1727
+ }
1728
+ });
1729
+
1730
+ let node;
1731
+ while ((node = walker.nextNode())) {
1732
+ const text = node.nodeValue || '';
1733
+ if (!text) continue;
1734
+
1735
+ let best = null; // { rule, start }
1736
+ for (let i = 0; i < rules.length; i++) {
1737
+ const r = rules[i];
1738
+ if (!r || !r.open || !r.close) continue;
1739
+
1740
+ const idx = text.lastIndexOf(r.open);
1741
+ if (idx === -1) continue;
1742
+
1743
+ const after = text.indexOf(r.close, idx + r.open.length);
1744
+ if (after !== -1) continue;
1745
+
1746
+ if (!best || idx > best.start) best = { rule: r, start: idx };
1747
+ }
1748
+
1749
+ if (!best) continue;
1750
+
1751
+ const r = best.rule;
1752
+ const start = best.start;
1753
+ const openLen = r.open.length;
1754
+ const prefixText = text.slice(0, start);
1755
+ const fromOffset = start + openLen;
1756
+
1757
+ try {
1758
+ const range = document.createRange();
1759
+ range.setStart(node, Math.min(fromOffset, node.nodeValue.length));
1760
+
1761
+ // end to the end of scope
1762
+ let endNode = root;
1763
+ try {
1764
+ endNode = (root.nodeType === 11 || root.nodeType === 1) ? root : node.parentNode;
1765
+ while (endNode && endNode.lastChild) endNode = endNode.lastChild;
1766
+ } catch (_) {}
1767
+ if (endNode && endNode.nodeType === 3) range.setEnd(endNode, endNode.nodeValue.length);
1768
+ else if (endNode) range.setEndAfter(endNode);
1769
+ else range.setEndAfter(node);
1770
+
1771
+ const remainder = range.extractContents();
1772
+
1773
+ const outTag = (r.tag && typeof r.tag === 'string') ? r.tag.toLowerCase() : 'span';
1774
+ const hostTag = (outTag === 'p') ? 'span' : outTag;
1775
+ const el = document.createElement(hostTag);
1776
+ if (r.className) el.className = r.className;
1777
+ el.setAttribute('data-cm', r.name);
1778
+ el.setAttribute('data-cm-pending', '1');
1779
+
1780
+ el.appendChild(remainder);
1781
+ range.insertNode(el);
1782
+ range.detach();
1783
+
1784
+ try { node.nodeValue = prefixText; } catch (_) {}
1785
+ this._d('STREAM_PENDING_OPEN_WRAP_WHOLE', { rule: r.name, open: r.open, preview: this.logger.pv(text, 160) });
1786
+
1787
+ return; // wrap-to-end done; exit to avoid scanning mutated subtree
1788
+ } catch (err) {
1789
+ // Fallback: wrap only tail of current node
1790
+ try {
1791
+ const tail = text.slice(start + r.open.length);
1792
+ const frag = document.createDocumentFragment();
1793
+
1794
+ if (prefixText) frag.appendChild(document.createTextNode(prefixText));
1795
+
1796
+ const el = document.createElement((r.tag === 'p') ? 'span' : r.tag);
1797
+ if (r.className) el.className = r.className;
1798
+ el.setAttribute('data-cm', r.name);
1799
+ el.setAttribute('data-cm-pending', '1');
1800
+
1801
+ this.setInnerByMode(el, r.innerMode, tail, MD, !!r.decodeEntities, r);
1802
+ frag.appendChild(el);
1803
+
1804
+ node.parentNode.replaceChild(frag, node);
1805
+ this._d('STREAM_PENDING_OPEN_WRAP_FALLBACK', { rule: r.name, err: String(err) });
1806
+ return;
1807
+ } catch (_) { /* worst-case: keep node as-is */ }
1808
+ }
1809
+ }
1810
+ }
1811
+
1812
+ // NEW: streaming close finalization if a pending wrapper contains a closing token,
1813
+ // remove the token and move the tail outside the wrapper, then clear pending flag.
1814
+ applyStreamFinalizeClosers(root, rulesAll) {
1815
+ if (!root) return;
1816
+
1817
+ const scope = (root.nodeType === 1 || root.nodeType === 11) ? root : document;
1818
+ const pending = scope.querySelectorAll('[data-cm][data-cm-pending="1"]');
1819
+ if (!pending || !pending.length) return;
1820
+
1821
+ const rulesByName = new Map();
1822
+ (rulesAll || []).forEach(r => { if (r && r.name) rulesByName.set(r.name, r); });
1823
+
1824
+ for (let i = 0; i < pending.length; i++) {
1825
+ const el = pending[i];
1826
+ const name = el.getAttribute('data-cm') || '';
1827
+ const rule = rulesByName.get(name);
1828
+ if (!rule || !rule.close) continue;
1829
+
1830
+ // Look for the first text node inside 'el' that contains the closing token
1831
+ const self = this;
1832
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
1833
+ acceptNode(node) {
1834
+ const val = node && node.nodeValue ? node.nodeValue : '';
1835
+ if (!val || val.indexOf(rule.close) === -1) return NodeFilter.FILTER_SKIP;
1836
+ if (self.isInsideForbiddenContext(node)) return NodeFilter.FILTER_REJECT;
1837
+ return NodeFilter.FILTER_ACCEPT;
1838
+ }
1839
+ });
1840
+
1841
+ let nodeWithClose = null;
1842
+ let idxInNode = -1;
1843
+ let tn;
1844
+ while ((tn = walker.nextNode())) {
1845
+ const idx = tn.nodeValue.indexOf(rule.close);
1846
+ if (idx !== -1) { nodeWithClose = tn; idxInNode = idx; break; }
1847
+ }
1848
+ if (!nodeWithClose) continue; // still pending; no closer yet
1849
+
1850
+ try {
1851
+ const tokenLen = rule.close.length;
1852
+
1853
+ // Range for content AFTER the closing token -> will be moved outside
1854
+ const afterRange = document.createRange();
1855
+ afterRange.setStart(nodeWithClose, idxInNode + tokenLen);
1856
+ // End at end of wrapper
1857
+ let endNode = el;
1858
+ while (endNode && endNode.lastChild) endNode = endNode.lastChild;
1859
+ if (endNode && endNode.nodeType === 3) afterRange.setEnd(endNode, endNode.nodeValue.length);
1860
+ else afterRange.setEndAfter(el.lastChild || el);
1861
+
1862
+ const tail = afterRange.extractContents();
1863
+ afterRange.detach();
1864
+
1865
+ // Delete the closing token itself
1866
+ const tok = document.createRange();
1867
+ tok.setStart(nodeWithClose, idxInNode);
1868
+ tok.setEnd(nodeWithClose, idxInNode + tokenLen);
1869
+ tok.deleteContents();
1870
+ tok.detach();
1871
+
1872
+ // Move tail right after the wrapper (preserve DOM order)
1873
+ if (el.parentNode && tail && tail.childNodes.length) {
1874
+ el.parentNode.insertBefore(tail, el.nextSibling);
1875
+ }
1876
+
1877
+ // Finalize the wrapper
1878
+ el.removeAttribute('data-cm-pending');
1879
+ this._d('STREAM_PENDING_CLOSE_FINALIZED', { rule: rule.name });
1880
+
1881
+ } catch (err) {
1882
+ this._d('STREAM_PENDING_CLOSE_ERR', { rule: rule.name, err: String(err) });
1883
+ // If anything goes wrong, keep the block pending (safer for next snapshot).
1884
+ }
1885
+ }
1886
+ }
1887
+
1888
+ // Source fence scan (unchanged)
1889
+ _findFenceRanges(s) {
1890
+ const ranges = [];
1891
+ const n = s.length;
1892
+ let i = 0;
1893
+ let inFence = false;
1894
+ let fenceMark = '';
1895
+ let fenceLen = 0;
1896
+ let startLineStart = 0;
1897
+
1898
+ while (i < n) {
1899
+ const lineStart = i;
1900
+ let j = lineStart;
1901
+ while (j < n && s.charCodeAt(j) !== 10 && s.charCodeAt(j) !== 13) j++;
1902
+ const lineEnd = j;
1903
+ let nl = 0;
1904
+ if (j < n) {
1905
+ if (s.charCodeAt(j) === 13 && j + 1 < n && s.charCodeAt(j + 1) === 10) nl = 2;
1906
+ else nl = 1;
1907
+ }
1908
+
1909
+ let k = lineStart;
1910
+ let indent = 0;
1911
+ while (k < lineEnd) {
1912
+ const c = s.charCodeAt(k);
1913
+ if (c === 32) { indent++; if (indent > 3) break; k++; }
1914
+ else if (c === 9) { indent++; if (indent > 3) break; k++; }
1915
+ else break;
1916
+ }
1917
+
1918
+ if (!inFence) {
1919
+ if (indent <= 3 && k < lineEnd) {
1920
+ const ch = s.charCodeAt(k);
1921
+ if (ch === 0x60 || ch === 0x7E) {
1922
+ const mark = String.fromCharCode(ch);
1923
+ let m = k;
1924
+ while (m < lineEnd && s.charCodeAt(m) === ch) m++;
1925
+ const run = m - k;
1926
+ if (run >= 3) { inFence = true; fenceMark = mark; fenceLen = run; startLineStart = lineStart; }
1927
+ }
1928
+ }
1929
+ } else {
1930
+ if (indent <= 3 && k < lineEnd && s.charCodeAt(k) === fenceMark.charCodeAt(0)) {
1931
+ let m = k;
1932
+ while (m < lineEnd && s.charCodeAt(m) === fenceMark.charCodeAt(0)) m++;
1933
+ const run = m - k;
1934
+ if (run >= fenceLen) {
1935
+ let onlyWS = true;
1936
+ for (let t = m; t < lineEnd; t++) {
1937
+ const cc = s.charCodeAt(t);
1938
+ if (cc !== 32 && cc !== 9) { onlyWS = false; break; }
1939
+ }
1940
+ if (onlyWS) {
1941
+ const endIdx = lineEnd + nl;
1942
+ ranges.push([startLineStart, endIdx]);
1943
+ inFence = false; fenceMark = ''; fenceLen = 0; startLineStart = 0;
1944
+ }
1945
+ }
1946
+ }
1947
+ }
1948
+ i = lineEnd + nl;
1949
+ }
1950
+ if (inFence) ranges.push([startLineStart, n]);
1951
+ return ranges;
1952
+ }
1953
+
1954
+ _isTopLevelLineInSource(s, absIdx) {
1955
+ let ls = absIdx;
1956
+ while (ls > 0) {
1957
+ const ch = s.charCodeAt(ls - 1);
1958
+ if (ch === 10 || ch === 13) break;
1959
+ ls--;
1960
+ }
1961
+ const prefix = s.slice(ls, absIdx);
1962
+
1963
+ let i = 0, indent = 0;
1964
+ while (i < prefix.length) {
1965
+ const c = prefix.charCodeAt(i);
1966
+ if (c === 32) { indent++; if (indent > 3) break; i++; }
1967
+ else if (c === 9) { indent++; if (indent > 3) break; i++; }
1968
+ else break;
1969
+ }
1970
+ if (indent > 3) return false;
1971
+ const rest = prefix.slice(i);
1972
+
1973
+ if (/^>\s?/.test(rest)) return false;
1974
+ if (/^[-+*]\s/.test(rest)) return false;
1975
+ if (/^\d+[.)]\s/.test(rest)) return false;
1976
+
1977
+ if (rest.trim().length > 0) return false;
1978
+ return true;
1979
+ }
1980
+
1981
+ _applySourceReplacementsInChunk(full, chunk, baseOffset, rules) {
1982
+ let t = chunk;
1983
+ for (let i = 0; i < rules.length; i++) {
1984
+ const r = rules[i];
1985
+ if (!r || !(r.openReplace || r.closeReplace)) continue;
1986
+ try {
1987
+ r.re.lastIndex = 0;
1988
+ t = t.replace(r.re, (match, inner, offset) => {
1989
+ const abs = baseOffset + (offset | 0);
1990
+ if (!this._isTopLevelLineInSource(full, abs)) return match;
1991
+ const open = r.openReplace || '';
1992
+ const close = r.closeReplace || '';
1993
+ return open + (inner || '') + close;
1994
+ });
1995
+ } catch (_) {}
1996
+ }
1997
+ return t;
1998
+ }
1999
+ }
1846
2000
 
1847
2001
  // ==========================================================================
1848
2002
  // 5) Markdown runtime (markdown-it + code wrapper + math placeholders)
@@ -3892,1072 +4046,1034 @@
3892
4046
  // ==========================================================================
3893
4047
 
3894
4048
  class StreamEngine {
3895
- constructor(cfg, dom, renderer, math, highlighter, codeScroll, scrollMgr, raf, asyncer, logger) {
3896
- this.cfg = cfg; this.dom = dom; this.renderer = renderer; this.math = math;
3897
- this.highlighter = highlighter; this.codeScroll = codeScroll; this.scrollMgr = scrollMgr; this.raf = raf;
3898
- this.asyncer = asyncer;
3899
- this.logger = logger || new Logger(cfg);
3900
-
3901
- // Streaming buffer (rope-like) – avoids O(n^2) string concatenation when many small chunks arrive.
3902
- // streamBuf holds the already materialized prefix; _sbParts keeps recent tail parts; _sbLen tracks their length.
3903
- this.streamBuf = ''; // materialized prefix (string used by render)
3904
- this._sbParts = []; // pending string chunks (array) not yet joined
3905
- this._sbLen = 0; // length of pending chunks
4049
+ constructor(cfg, dom, renderer, math, highlighter, codeScroll, scrollMgr, raf, asyncer, logger) {
4050
+ this.cfg = cfg; this.dom = dom; this.renderer = renderer; this.math = math;
4051
+ this.highlighter = highlighter; this.codeScroll = codeScroll; this.scrollMgr = scrollMgr; this.raf = raf;
4052
+ this.asyncer = asyncer;
4053
+ this.logger = logger || new Logger(cfg);
3906
4054
 
3907
- this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
3908
- this.fenceTail = ''; this.fenceBuf = '';
3909
- this.lastSnapshotTs = 0; this.nextSnapshotStep = cfg.PROFILE_TEXT.base;
3910
- this.snapshotScheduled = false; this.snapshotRAF = 0;
4055
+ // Streaming buffer (rope-like) avoids O(n^2) string concatenation when many small chunks arrive.
4056
+ // streamBuf holds the already materialized prefix; _sbParts keeps recent tail parts; _sbLen tracks their length.
4057
+ this.streamBuf = ''; // materialized prefix (string used by render)
4058
+ this._sbParts = []; // pending string chunks (array) not yet joined
4059
+ this._sbLen = 0; // length of pending chunks
3911
4060
 
3912
- this.codeStream = { open: false, lines: 0, chars: 0 };
3913
- this.activeCode = null;
4061
+ this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
4062
+ this.fenceTail = ''; this.fenceBuf = '';
4063
+ this.lastSnapshotTs = 0; this.nextSnapshotStep = cfg.PROFILE_TEXT.base;
4064
+ this.snapshotScheduled = false; this.snapshotRAF = 0;
3914
4065
 
3915
- this.suppressPostFinalizePass = false;
4066
+ this.codeStream = { open: false, lines: 0, chars: 0 };
4067
+ this.activeCode = null;
3916
4068
 
3917
- this._promoteScheduled = false;
4069
+ this.suppressPostFinalizePass = false;
3918
4070
 
3919
- // Guard to ensure first fence-open is materialized immediately when stream starts with code.
3920
- this._firstCodeOpenSnapDone = false;
4071
+ this._promoteScheduled = false;
3921
4072
 
3922
- // Streaming mode flag controls reduced rendering (no linkify etc.) on hot path.
3923
- this.isStreaming = false;
4073
+ // Guard to ensure first fence-open is materialized immediately when stream starts with code.
4074
+ this._firstCodeOpenSnapDone = false;
3924
4075
 
3925
- // Tracks whether renderSnapshot injected a one-off synthetic EOL for parsing an open fence
3926
- // (used to strip it from the initial streaming tail to avoid "#\n foo" on first line).
3927
- this._lastInjectedEOL = false;
4076
+ // Streaming mode flag controls reduced rendering (no linkify etc.) on hot path.
4077
+ this.isStreaming = false;
3928
4078
 
3929
- this._customFenceSpecs = []; // [{ open, close }, ...]
3930
- this._fenceCustom = null; // currently active custom fence spec or null
3931
- }
3932
- _d(tag, data) { this.logger.debug('STREAM', tag, data); }
4079
+ // Tracks whether renderSnapshot injected a one-off synthetic EOL for parsing an open fence
4080
+ // (used to strip it from the initial streaming tail to avoid "#\n foo" on first line).
4081
+ this._lastInjectedEOL = false;
3933
4082
 
3934
- setCustomFenceSpecs(specs) {
3935
- this._customFenceSpecs = Array.isArray(specs) ? specs.slice() : [];
3936
- }
4083
+ this._customFenceSpecs = []; // [{ open, close }, ...]
4084
+ this._fenceCustom = null; // currently active custom fence spec or null
4085
+ }
4086
+ _d(tag, data) { this.logger.debug('STREAM', tag, data); }
3937
4087
 
3938
- // --- Rope buffer helpers (internal) ---
4088
+ setCustomFenceSpecs(specs) {
4089
+ this._customFenceSpecs = Array.isArray(specs) ? specs.slice() : [];
4090
+ }
3939
4091
 
3940
- // Append a chunk into the rope without immediately touching the large string.
3941
- _appendChunk(s) {
3942
- if (!s) return;
3943
- this._sbParts.push(s);
3944
- this._sbLen += s.length;
3945
- }
3946
- // Current logical length of the stream text (materialized prefix + pending tail).
3947
- getStreamLength() {
3948
- return (this.streamBuf.length + this._sbLen);
3949
- }
3950
- // Materialize the rope into a single string for rendering (cheap if nothing pending).
3951
- getStreamText() {
3952
- if (this._sbLen > 0) {
3953
- // Join pending parts into the materialized prefix and clear the tail.
3954
- // Single-part fast path avoids a temporary array join.
3955
- this.streamBuf += (this._sbParts.length === 1 ? this._sbParts[0] : this._sbParts.join(''));
3956
- this._sbParts.length = 0;
3957
- this._sbLen = 0;
3958
- }
3959
- return this.streamBuf;
3960
- }
3961
- // Reset the rope to an empty state.
3962
- _clearStreamBuffer() {
3963
- this.streamBuf = '';
4092
+ // --- Rope buffer helpers (internal) ---
4093
+ _appendChunk(s) {
4094
+ if (!s) return;
4095
+ this._sbParts.push(s);
4096
+ this._sbLen += s.length;
4097
+ }
4098
+ getStreamLength() {
4099
+ return (this.streamBuf.length + this._sbLen);
4100
+ }
4101
+ getStreamText() {
4102
+ if (this._sbLen > 0) {
4103
+ // Join pending parts into the materialized prefix and clear the tail.
4104
+ // Single-part fast path avoids a temporary array join.
4105
+ this.streamBuf += (this._sbParts.length === 1 ? this._sbParts[0] : this._sbParts.join(''));
3964
4106
  this._sbParts.length = 0;
3965
4107
  this._sbLen = 0;
3966
4108
  }
4109
+ return this.streamBuf;
4110
+ }
4111
+ _clearStreamBuffer() {
4112
+ this.streamBuf = '';
4113
+ this._sbParts.length = 0;
4114
+ this._sbLen = 0;
4115
+ }
3967
4116
 
3968
- // Reset all streaming state and counters.
3969
- reset() {
3970
- this._clearStreamBuffer();
3971
- this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
3972
- this.fenceTail = ''; this.fenceBuf = '';
3973
- this.lastSnapshotTs = 0; this.nextSnapshotStep = this.profile().base;
3974
- this.snapshotScheduled = false; this.snapshotRAF = 0;
3975
- this.codeStream = { open: false, lines: 0, chars: 0 };
3976
- this.activeCode = null; this.suppressPostFinalizePass = false;
3977
- this._promoteScheduled = false;
3978
- this._firstCodeOpenSnapDone = false;
3979
-
3980
- // Clear any previous synthetic EOL marker.
3981
- this._lastInjectedEOL = false;
3982
- this._fenceCustom = null;
3983
-
3984
- this._d('RESET', { });
3985
- }
3986
- // Turn active streaming code block into plain text (safety on abort).
3987
- defuseActiveToPlain() {
3988
- if (!this.activeCode || !this.activeCode.codeEl || !this.activeCode.codeEl.isConnected) return;
3989
- const codeEl = this.activeCode.codeEl;
3990
- const fullText = (this.activeCode.frozenEl?.textContent || '') + (this.activeCode.tailEl?.textContent || '');
3991
- try {
3992
- codeEl.textContent = fullText;
4117
+ // Reset all streaming state and counters.
4118
+ reset() {
4119
+ this._clearStreamBuffer();
4120
+ this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
4121
+ this.fenceTail = ''; this.fenceBuf = '';
4122
+ this.lastSnapshotTs = 0; this.nextSnapshotStep = this.profile().base;
4123
+ this.snapshotScheduled = false; this.snapshotRAF = 0;
4124
+ this.codeStream = { open: false, lines: 0, chars: 0 };
4125
+ this.activeCode = null; this.suppressPostFinalizePass = false;
4126
+ this._promoteScheduled = false;
4127
+ this._firstCodeOpenSnapDone = false;
4128
+
4129
+ // Clear any previous synthetic EOL marker.
4130
+ this._lastInjectedEOL = false;
4131
+ this._fenceCustom = null;
4132
+
4133
+ this._d('RESET', { });
4134
+ }
4135
+ defuseActiveToPlain() {
4136
+ if (!this.activeCode || !this.activeCode.codeEl || !this.activeCode.codeEl.isConnected) return;
4137
+ const codeEl = this.activeCode.codeEl;
4138
+ const fullText = (this.activeCode.frozenEl?.textContent || '') + (this.activeCode.tailEl?.textContent || '');
4139
+ try {
4140
+ codeEl.textContent = fullText;
4141
+ codeEl.removeAttribute('data-highlighted');
4142
+ codeEl.classList.remove('hljs');
4143
+ codeEl.dataset._active_stream = '0';
4144
+ const st = this.codeScroll.state(codeEl); st.autoFollow = false;
4145
+ } catch (_) {}
4146
+ this._d('DEFUSE_ACTIVE_TO_PLAIN', { len: fullText.length });
4147
+ this.activeCode = null;
4148
+ }
4149
+ defuseOrphanActiveBlocks(root) {
4150
+ try {
4151
+ const scope = root || document;
4152
+ const nodes = scope.querySelectorAll('pre code[data-_active_stream="1"]');
4153
+ let n = 0;
4154
+ nodes.forEach(codeEl => {
4155
+ if (!codeEl.isConnected) return;
4156
+ let text = '';
4157
+ const frozen = codeEl.querySelector('.hl-frozen');
4158
+ const tail = codeEl.querySelector('.hl-tail');
4159
+ if (frozen || tail) text = (frozen?.textContent || '') + (tail?.textContent || '');
4160
+ else text = codeEl.textContent || '';
4161
+ codeEl.textContent = text;
3993
4162
  codeEl.removeAttribute('data-highlighted');
3994
4163
  codeEl.classList.remove('hljs');
3995
4164
  codeEl.dataset._active_stream = '0';
3996
- const st = this.codeScroll.state(codeEl); st.autoFollow = false;
3997
- } catch (_) {}
3998
- this._d('DEFUSE_ACTIVE_TO_PLAIN', { len: fullText.length });
3999
- this.activeCode = null;
4000
- }
4001
- // If there are orphan streaming code blocks in DOM, finalize them as plain text.
4002
- defuseOrphanActiveBlocks(root) {
4003
- try {
4004
- const scope = root || document;
4005
- const nodes = scope.querySelectorAll('pre code[data-_active_stream="1"]');
4006
- let n = 0;
4007
- nodes.forEach(codeEl => {
4008
- if (!codeEl.isConnected) return;
4009
- let text = '';
4010
- const frozen = codeEl.querySelector('.hl-frozen');
4011
- const tail = codeEl.querySelector('.hl-tail');
4012
- if (frozen || tail) text = (frozen?.textContent || '') + (tail?.textContent || '');
4013
- else text = codeEl.textContent || '';
4014
- codeEl.textContent = text;
4015
- codeEl.removeAttribute('data-highlighted');
4016
- codeEl.classList.remove('hljs');
4017
- codeEl.dataset._active_stream = '0';
4018
- try { this.codeScroll.attachHandlers(codeEl); } catch (_) {}
4019
- n++;
4020
- });
4021
- if (n) this._d('DEFUSE_ORPHAN_ACTIVE_BLOCKS', { count: n });
4022
- } catch (e) { this._d('DEFUSE_ORPHAN_ACTIVE_ERR', String(e)); }
4023
- }
4024
- // Abort streaming and clear state with options.
4025
- abortAndReset(opts) {
4026
- const o = Object.assign({
4027
- finalizeActive: true,
4028
- clearBuffer: true,
4029
- clearMsg: false,
4030
- defuseOrphans: true,
4031
- reason: '',
4032
- suppressLog: false
4033
- }, (opts || {}));
4165
+ try { this.codeScroll.attachHandlers(codeEl); } catch (_) {}
4166
+ n++;
4167
+ });
4168
+ if (n) this._d('DEFUSE_ORPHAN_ACTIVE_BLOCKS', { count: n });
4169
+ } catch (e) { this._d('DEFUSE_ORPHAN_ACTIVE_ERR', String(e)); }
4170
+ }
4171
+ abortAndReset(opts) {
4172
+ const o = Object.assign({
4173
+ finalizeActive: true,
4174
+ clearBuffer: true,
4175
+ clearMsg: false,
4176
+ defuseOrphans: true,
4177
+ reason: '',
4178
+ suppressLog: false
4179
+ }, (opts || {}));
4034
4180
 
4035
- try { this.raf.cancelGroup('StreamEngine'); } catch (_) {}
4036
- try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4037
- this.snapshotScheduled = false; this.snapshotRAF = 0;
4181
+ try { this.raf.cancelGroup('StreamEngine'); } catch (_) {}
4182
+ try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4183
+ this.snapshotScheduled = false; this.snapshotRAF = 0;
4038
4184
 
4039
- const hadActive = !!this.activeCode;
4040
- try {
4041
- if (this.activeCode) {
4042
- if (o.finalizeActive === true) this.finalizeActiveCode();
4043
- else this.defuseActiveToPlain();
4044
- }
4045
- } catch (e) {
4046
- this._d('ABORT_FINALIZE_ERR', String(e));
4185
+ const hadActive = !!this.activeCode;
4186
+ try {
4187
+ if (this.activeCode) {
4188
+ if (o.finalizeActive === true) this.finalizeActiveCode();
4189
+ else this.defuseActiveToPlain();
4047
4190
  }
4191
+ } catch (e) {
4192
+ this._d('ABORT_FINALIZE_ERR', String(e));
4193
+ }
4048
4194
 
4049
- if (o.defuseOrphans) {
4050
- try { this.defuseOrphanActiveBlocks(); }
4051
- catch (e) { this._d('ABORT_DEFUSE_ORPHANS_ERR', String(e)); }
4052
- }
4195
+ if (o.defuseOrphans) {
4196
+ try { this.defuseOrphanActiveBlocks(); }
4197
+ catch (e) { this._d('ABORT_DEFUSE_ORPHANS_ERR', String(e)); }
4198
+ }
4053
4199
 
4054
- if (o.clearBuffer) {
4055
- this._clearStreamBuffer();
4056
- this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
4057
- this.fenceTail = ''; this.fenceBuf = '';
4058
- this.codeStream.open = false; this.codeStream.lines = 0; this.codeStream.chars = 0;
4059
- window.__lastSnapshotLen = 0;
4060
- }
4061
- if (o.clearMsg === true) {
4062
- try { this.dom.resetEphemeral(); } catch (_) {}
4063
- }
4064
- if (!o.suppressLog) this._d('ABORT_AND_RESET', { hadActive, ...o });
4200
+ if (o.clearBuffer) {
4201
+ this._clearStreamBuffer();
4202
+ this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
4203
+ this.fenceTail = ''; this.fenceBuf = '';
4204
+ this.codeStream.open = false; this.codeStream.lines = 0; this.codeStream.chars = 0;
4205
+ window.__lastSnapshotLen = 0;
4065
4206
  }
4066
- // Select profile for current stream state (code vs text).
4067
- profile() { return this.fenceOpen ? this.cfg.PROFILE_CODE : this.cfg.PROFILE_TEXT; }
4068
- // Reset adaptive snapshot budget to base.
4069
- resetBudget() { this.nextSnapshotStep = this.profile().base; }
4070
- // Check whether [from, end) contains only spaces/tabs.
4071
- onlyTrailingWhitespace(s, from, end) {
4072
- for (let i = from; i < end; i++) { const c = s.charCodeAt(i); if (c !== 0x20 && c !== 0x09) return false; }
4073
- return true;
4207
+ if (o.clearMsg === true) {
4208
+ try { this.dom.resetEphemeral(); } catch (_) {}
4074
4209
  }
4075
- // Update fence state based on a fresh chunk and buffer tail; detect openings and closings.
4076
- updateFenceHeuristic(chunk) {
4077
- const prev = (this.fenceBuf || '');
4078
- const s = prev + (chunk || '');
4079
- const preLen = prev.length;
4080
- const n = s.length; let i = 0;
4081
- let opened = false; let closed = false; let splitAt = -1;
4082
- let atLineStart = (preLen === 0) ? true : /[\n\r]$/.test(prev);
4083
-
4084
- const inNewOrCrosses = (j, k) => (j >= preLen) || (k > preLen);
4085
-
4086
- while (i < n) {
4087
- const ch = s[i];
4088
- if (ch === '\r' || ch === '\n') { atLineStart = true; i++; continue; }
4089
- if (!atLineStart) { i++; continue; }
4090
- atLineStart = false;
4091
-
4092
- // Skip list/blockquote/indent normalization (existing logic)
4093
- let j = i;
4094
- while (j < n) {
4095
- let localSpaces = 0;
4096
- while (j < n && (s[j] === ' ' || s[j] === '\t')) { localSpaces += (s[j] === '\t') ? 4 : 1; j++; if (localSpaces > 3) break; }
4097
- if (j < n && s[j] === '>') { j++; if (j < n && s[j] === ' ') j++; continue; }
4098
-
4099
- let saved = j;
4100
- if (j < n && (s[j] === '-' || s[j] === '*' || s[j] === '+')) {
4101
- let jj = j + 1; if (jj < n && s[jj] === ' ') { j = jj + 1; } else { j = saved; }
4102
- } else {
4103
- let k2 = j; let hasDigit = false;
4104
- while (k2 < n && s[k2] >= '0' && s[k2] <= '9') { hasDigit = true; k2++; }
4105
- if (hasDigit && k2 < n && (s[k2] === '.' || s[k2] === ')')) {
4106
- k2++; if (k2 < n && s[k2] === ' ') { j = k2 + 1; } else { j = saved; }
4107
- } else { j = saved; }
4108
- }
4109
- break;
4110
- }
4111
-
4112
- let indent = 0;
4113
- while (j < n && (s[j] === ' ' || s[j] === '\t')) {
4114
- indent += (s[j] === '\t') ? 4 : 1; j++; if (indent > 3) break;
4210
+ if (!o.suppressLog) this._d('ABORT_AND_RESET', { hadActive, ...o });
4211
+ }
4212
+ profile() { return this.fenceOpen ? this.cfg.PROFILE_CODE : this.cfg.PROFILE_TEXT; }
4213
+ resetBudget() { this.nextSnapshotStep = this.profile().base; }
4214
+ onlyTrailingWhitespace(s, from, end) {
4215
+ for (let i = from; i < end; i++) { const c = s.charCodeAt(i); if (c !== 0x20 && c !== 0x09) return false; }
4216
+ return true;
4217
+ }
4218
+ updateFenceHeuristic(chunk) {
4219
+ const prev = (this.fenceBuf || '');
4220
+ const s = prev + (chunk || '');
4221
+ const preLen = prev.length;
4222
+ const n = s.length; let i = 0;
4223
+ let opened = false; let closed = false; let splitAt = -1;
4224
+ let atLineStart = (preLen === 0) ? true : /[\n\r]$/.test(prev);
4225
+
4226
+ const inNewOrCrosses = (j, k) => (j >= preLen) || (k > preLen);
4227
+
4228
+ while (i < n) {
4229
+ const ch = s[i];
4230
+ if (ch === '\r' || ch === '\n') { atLineStart = true; i++; continue; }
4231
+ if (!atLineStart) { i++; continue; }
4232
+ atLineStart = false;
4233
+
4234
+ // Skip list/blockquote/indent normalization (existing logic)
4235
+ let j = i;
4236
+ while (j < n) {
4237
+ let localSpaces = 0;
4238
+ while (j < n && (s[j] === ' ' || s[j] === '\t')) { localSpaces += (s[j] === '\t') ? 4 : 1; j++; if (localSpaces > 3) break; }
4239
+ if (j < n && s[j] === '>') { j++; if (j < n && s[j] === ' ') j++; continue; }
4240
+
4241
+ let saved = j;
4242
+ if (j < n && (s[j] === '-' || s[j] === '*' || s[j] === '+')) {
4243
+ let jj = j + 1; if (jj < n && s[jj] === ' ') { j = jj + 1; } else { j = saved; }
4244
+ } else {
4245
+ let k2 = j; let hasDigit = false;
4246
+ while (k2 < n && s[k2] >= '0' && s[k2] <= '9') { hasDigit = true; k2++; }
4247
+ if (hasDigit && k2 < n && (s[k2] === '.' || s[k2] === ')')) {
4248
+ k2++; if (k2 < n && s[k2] === ' ') { j = k2 + 1; } else { j = saved; }
4249
+ } else { j = saved; }
4115
4250
  }
4116
- if (indent > 3) { i = j; continue; }
4117
-
4118
- // 1) Custom fences first (e.g. [!exec] ... [/!exec], <execute>...</execute>)
4119
- if (!this.fenceOpen && this._customFenceSpecs && this._customFenceSpecs.length) {
4120
- for (let ci = 0; ci < this._customFenceSpecs.length; ci++) {
4121
- const spec = this._customFenceSpecs[ci];
4122
- const open = spec && spec.open ? spec.open : '';
4123
- if (!open) continue;
4124
- const k = j + open.length;
4125
- if (k <= n && s.slice(j, k) === open) {
4126
- if (!inNewOrCrosses(j, k)) { /* seen fully in previous prefix */ }
4127
- else {
4128
- this.fenceOpen = true; this._fenceCustom = spec; opened = true; i = k;
4129
- this._d('FENCE_OPEN_DETECTED_CUSTOM', { open, idxStart: j, idxEnd: k, region: (j >= preLen) ? 'new' : 'cross' });
4130
- continue; // main while
4131
- }
4251
+ break;
4252
+ }
4253
+
4254
+ let indent = 0;
4255
+ while (j < n && (s[j] === ' ' || s[j] === '\t')) {
4256
+ indent += (s[j] === '\t') ? 4 : 1; j++; if (indent > 3) break;
4257
+ }
4258
+ if (indent > 3) { i = j; continue; }
4259
+
4260
+ // 1) Custom fences first (e.g. [!exec] ... [/!exec], <execute>...</execute>)
4261
+ if (!this.fenceOpen && this._customFenceSpecs && this._customFenceSpecs.length) {
4262
+ for (let ci = 0; ci < this._customFenceSpecs.length; ci++) {
4263
+ const spec = this._customFenceSpecs[ci];
4264
+ const open = spec && spec.open ? spec.open : '';
4265
+ if (!open) continue;
4266
+ const k = j + open.length;
4267
+ if (k <= n && s.slice(j, k) === open) {
4268
+ if (!inNewOrCrosses(j, k)) { /* seen fully in previous prefix */ }
4269
+ else {
4270
+ this.fenceOpen = true; this._fenceCustom = spec; opened = true; i = k;
4271
+ this._d('FENCE_OPEN_DETECTED_CUSTOM', { open, idxStart: j, idxEnd: k, region: (j >= preLen) ? 'new' : 'cross' });
4272
+ continue; // main while
4132
4273
  }
4133
4274
  }
4134
- } else if (this.fenceOpen && this._fenceCustom && this._fenceCustom.close) {
4135
- const close = this._fenceCustom.close;
4136
- const k = j + close.length;
4137
- if (k <= n && s.slice(j, k) === close) {
4138
- // Require only trailing whitespace on the line (consistent with ``` logic)
4139
- let eol = k; while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
4140
- const onlyWS = this.onlyTrailingWhitespace(s, k, eol);
4141
- if (onlyWS) {
4142
- if (!inNewOrCrosses(j, k)) { /* seen in previous prefix */ }
4143
- else {
4144
- this.fenceOpen = false; this._fenceCustom = null; closed = true;
4145
- const endInS = k;
4146
- const rel = endInS - preLen;
4147
- splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
4148
- i = k;
4149
- this._d('FENCE_CLOSE_DETECTED_CUSTOM', { close, idxStart: j, idxEnd: k, splitAt, region: (j >= preLen) ? 'new' : 'cross' });
4150
- continue; // main while
4151
- }
4152
- } else {
4153
- this._d('FENCE_CLOSE_REJECTED_CUSTOM_NON_WS_AFTER', { close, idxStart: j, idxEnd: k });
4275
+ }
4276
+ } else if (this.fenceOpen && this._fenceCustom && this._fenceCustom.close) {
4277
+ const close = this._fenceCustom.close;
4278
+ const k = j + close.length;
4279
+ if (k <= n && s.slice(j, k) === close) {
4280
+ // Require only trailing whitespace on the line (consistent with ``` logic)
4281
+ let eol = k; while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
4282
+ const onlyWS = this.onlyTrailingWhitespace(s, k, eol);
4283
+ if (onlyWS) {
4284
+ if (!inNewOrCrosses(j, k)) { /* seen in previous prefix */ }
4285
+ else {
4286
+ this.fenceOpen = false; this._fenceCustom = null; closed = true;
4287
+ const endInS = k;
4288
+ const rel = endInS - preLen;
4289
+ splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
4290
+ i = k;
4291
+ this._d('FENCE_CLOSE_DETECTED_CUSTOM', { close, idxStart: j, idxEnd: k, splitAt, region: (j >= preLen) ? 'new' : 'cross' });
4292
+ continue; // main while
4154
4293
  }
4294
+ } else {
4295
+ this._d('FENCE_CLOSE_REJECTED_CUSTOM_NON_WS_AFTER', { close, idxStart: j, idxEnd: k });
4155
4296
  }
4156
4297
  }
4298
+ }
4157
4299
 
4158
- // 2) Standard markdown-it fences (``` or ~~~) – leave your original logic intact
4159
- if (j < n && (s[j] === '`' || s[j] === '~')) {
4160
- const mark = s[j]; let k = j; while (k < n && s[k] === mark) k++; const run = k - j;
4300
+ // 2) Standard markdown-it fences (``` or ~~~) – leave your original logic intact
4301
+ if (j < n && (s[j] === '`' || s[j] === '~')) {
4302
+ const mark = s[j]; let k = j; while (k < n && s[k] === mark) k++; const run = k - j;
4161
4303
 
4162
- if (!this.fenceOpen) {
4163
- if (run >= 3) {
4164
- if (!inNewOrCrosses(j, k)) { i = k; continue; }
4165
- this.fenceOpen = true; this.fenceMark = mark; this.fenceLen = run; opened = true; i = k;
4166
- this._d('FENCE_OPEN_DETECTED', { mark, run, idxStart: j, idxEnd: k, region: (j >= preLen) ? 'new' : 'cross' });
4304
+ if (!this.fenceOpen) {
4305
+ if (run >= 3) {
4306
+ if (!inNewOrCrosses(j, k)) { i = k; continue; }
4307
+ this.fenceOpen = true; this.fenceMark = mark; this.fenceLen = run; opened = true; i = k;
4308
+ this._d('FENCE_OPEN_DETECTED', { mark, run, idxStart: j, idxEnd: k, region: (j >= preLen) ? 'new' : 'cross' });
4309
+ continue;
4310
+ }
4311
+ } else if (!this._fenceCustom) {
4312
+ if (mark === this.fenceMark && run >= this.fenceLen) {
4313
+ if (!inNewOrCrosses(j, k)) { i = k; continue; }
4314
+ let eol = k; while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
4315
+ if (this.onlyTrailingWhitespace(s, k, eol)) {
4316
+ this.fenceOpen = false; closed = true;
4317
+ const endInS = k;
4318
+ const rel = endInS - preLen;
4319
+ splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
4320
+ i = k;
4321
+ this._d('FENCE_CLOSE_DETECTED', { mark, run, idxStart: j, idxEnd: k, splitAt, region: (j >= preLen) ? 'new' : 'cross' });
4167
4322
  continue;
4168
- }
4169
- } else if (!this._fenceCustom) {
4170
- if (mark === this.fenceMark && run >= this.fenceLen) {
4171
- if (!inNewOrCrosses(j, k)) { i = k; continue; }
4172
- let eol = k; while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
4173
- if (this.onlyTrailingWhitespace(s, k, eol)) {
4174
- this.fenceOpen = false; closed = true;
4175
- const endInS = k;
4176
- const rel = endInS - preLen;
4177
- splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
4178
- i = k;
4179
- this._d('FENCE_CLOSE_DETECTED', { mark, run, idxStart: j, idxEnd: k, splitAt, region: (j >= preLen) ? 'new' : 'cross' });
4180
- continue;
4181
- } else {
4182
- this._d('FENCE_CLOSE_REJECTED_NON_WS_AFTER', { mark, run, idxStart: j, idxEnd: k });
4183
- }
4323
+ } else {
4324
+ this._d('FENCE_CLOSE_REJECTED_NON_WS_AFTER', { mark, run, idxStart: j, idxEnd: k });
4184
4325
  }
4185
4326
  }
4186
4327
  }
4187
-
4188
- i = j + 1;
4189
4328
  }
4190
4329
 
4191
- const MAX_TAIL = 512;
4192
- this.fenceBuf = s.slice(-MAX_TAIL);
4193
- this.fenceTail = s.slice(-3);
4194
- return { opened, closed, splitAt };
4330
+ i = j + 1;
4195
4331
  }
4196
4332
 
4197
- // Ensure message snapshot container exists.
4198
- getMsgSnapshotRoot(msg) {
4199
- if (!msg) return null;
4200
- let snap = msg.querySelector('.md-snapshot-root');
4201
- if (!snap) { snap = document.createElement('div'); snap.className = 'md-snapshot-root'; msg.appendChild(snap); }
4202
- return snap;
4203
- }
4204
- // Detect structural boundaries in a chunk (for snapshot decisions).
4205
- hasStructuralBoundary(chunk) { if (!chunk) return false; return /\n(\n|[-*]\s|\d+\.\s|#{1,6}\s|>\s)/.test(chunk); }
4206
- // Decide whether we should snapshot on this chunk.
4207
- shouldSnapshotOnChunk(chunk, chunkHasNL, hasBoundary) {
4208
- const prof = this.profile(); const now = Utils.now();
4209
- if (this.activeCode && this.fenceOpen) return false;
4210
- if ((now - this.lastSnapshotTs) < prof.minInterval) return false;
4211
- if (hasBoundary) return true;
4333
+ const MAX_TAIL = 512;
4334
+ this.fenceBuf = s.slice(-MAX_TAIL);
4335
+ this.fenceTail = s.slice(-3);
4336
+ return { opened, closed, splitAt };
4337
+ }
4212
4338
 
4213
- const delta = Math.max(0, this.getStreamLength() - (window.__lastSnapshotLen || 0));
4214
- if (this.fenceOpen) { if (chunkHasNL && delta >= this.nextSnapshotStep) return true; return false; }
4215
- if (delta >= this.nextSnapshotStep) return true;
4216
- return false;
4217
- }
4218
- // If we are getting slow, schedule a soft snapshot based on time.
4219
- maybeScheduleSoftSnapshot(msg, chunkHasNL) {
4220
- const prof = this.profile(); const now = Utils.now();
4339
+ // Ensure message snapshot container exists.
4340
+ getMsgSnapshotRoot(msg) {
4341
+ if (!msg) return null;
4342
+ let snap = msg.querySelector('.md-snapshot-root');
4343
+ if (!snap) { snap = document.createElement('div'); snap.className = 'md-snapshot-root'; msg.appendChild(snap); }
4344
+ return snap;
4345
+ }
4346
+ hasStructuralBoundary(chunk) { if (!chunk) return false; return /\n(\n|[-*]\s|\d+\.\s|#{1,6}\s|>\s)/.test(chunk); }
4347
+ shouldSnapshotOnChunk(chunk, chunkHasNL, hasBoundary) {
4348
+ const prof = this.profile(); const now = Utils.now();
4349
+ if (this.activeCode && this.fenceOpen) return false;
4350
+ if ((now - this.lastSnapshotTs) < prof.minInterval) return false;
4351
+ if (hasBoundary) return true;
4352
+
4353
+ const delta = Math.max(0, this.getStreamLength() - (window.__lastSnapshotLen || 0));
4354
+ if (this.fenceOpen) { if (chunkHasNL && delta >= this.nextSnapshotStep) return true; return false; }
4355
+ if (delta >= this.nextSnapshotStep) return true;
4356
+ return false;
4357
+ }
4358
+ maybeScheduleSoftSnapshot(msg, chunkHasNL) {
4359
+ const prof = this.profile(); const now = Utils.now();
4360
+ if (this.activeCode && this.fenceOpen) return;
4361
+ if (this.fenceOpen && this.codeStream.lines < 1 && !chunkHasNL) return;
4362
+ if ((now - this.lastSnapshotTs) >= prof.softLatency) this.scheduleSnapshot(msg);
4363
+ }
4364
+ scheduleSnapshot(msg, force = false) {
4365
+ if (this.snapshotScheduled && !this.raf.isScheduled('SE:snapshot')) this.snapshotScheduled = false;
4366
+ if (!force) {
4367
+ if (this.snapshotScheduled) return;
4221
4368
  if (this.activeCode && this.fenceOpen) return;
4222
- if (this.fenceOpen && this.codeStream.lines < 1 && !chunkHasNL) return;
4223
- if ((now - this.lastSnapshotTs) >= prof.softLatency) this.scheduleSnapshot(msg);
4224
- }
4225
- // Schedule snapshot rendering (coalesced via rAF).
4226
- scheduleSnapshot(msg, force = false) {
4227
- if (this.snapshotScheduled && !this.raf.isScheduled('SE:snapshot')) this.snapshotScheduled = false;
4228
- if (!force) {
4229
- if (this.snapshotScheduled) return;
4230
- if (this.activeCode && this.fenceOpen) return;
4231
- } else {
4232
- if (this.snapshotScheduled && this.raf.isScheduled('SE:snapshot')) return;
4233
- }
4234
- this.snapshotScheduled = true;
4235
- this.raf.schedule('SE:snapshot', () => { this.snapshotScheduled = false; this.renderSnapshot(msg); }, 'StreamEngine', 0);
4236
- }
4237
- // Split code element into frozen and tail spans if needed.
4238
- ensureSplitCodeEl(codeEl) {
4239
- if (!codeEl) return null;
4240
- let frozen = codeEl.querySelector('.hl-frozen'); let tail = codeEl.querySelector('.hl-tail');
4241
- if (frozen && tail) return { codeEl, frozenEl: frozen, tailEl: tail };
4242
- const text = codeEl.textContent || ''; codeEl.innerHTML = '';
4243
- frozen = document.createElement('span'); frozen.className = 'hl-frozen';
4244
- tail = document.createElement('span'); tail.className = 'hl-tail';
4245
- codeEl.appendChild(frozen); codeEl.appendChild(tail);
4246
- if (text) tail.textContent = text; return { codeEl, frozenEl: frozen, tailEl: tail };
4369
+ } else {
4370
+ if (this.snapshotScheduled && this.raf.isScheduled('SE:snapshot')) return;
4247
4371
  }
4248
- // Create active code context from the latest snapshot.
4249
- setupActiveCodeFromSnapshot(snap) {
4250
- const codes = snap.querySelectorAll('pre code'); if (!codes.length) return null;
4251
- const last = codes[codes.length - 1];
4252
- const cls = Array.from(last.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
4253
- const lang = (cls.replace('language-', '') || 'plaintext');
4254
- const parts = this.ensureSplitCodeEl(last); if (!parts) return null;
4255
-
4256
- // If we injected a synthetic EOL for parsing an open fence, remove it from the streaming tail now.
4257
- // This prevents breaking the very first code line into "#\n foo" when the next chunk starts with " foo".
4258
- if (this._lastInjectedEOL && parts.tailEl && parts.tailEl.textContent && parts.tailEl.textContent.endsWith('\n')) {
4259
- parts.tailEl.textContent = parts.tailEl.textContent.slice(0, -1);
4260
- // Reset the marker so we don't accidentally trim again in this snapshot lifecycle.
4261
- this._lastInjectedEOL = false;
4262
- }
4263
-
4264
- const st = this.codeScroll.state(parts.codeEl); st.autoFollow = true; st.userInteracted = false;
4265
- parts.codeEl.dataset._active_stream = '1';
4266
- const baseFrozenNL = Utils.countNewlines(parts.frozenEl.textContent || ''); const baseTailNL = Utils.countNewlines(parts.tailEl.textContent || '');
4267
- const ac = { codeEl: parts.codeEl, frozenEl: parts.frozenEl, tailEl: parts.tailEl, lang, frozenLen: parts.frozenEl.textContent.length, lastPromoteTs: 0,
4268
- lines: 0, tailLines: baseTailNL, linesSincePromote: 0, initialLines: baseFrozenNL + baseTailNL, haltHL: false, plainStream: false };
4269
- this._d('ACTIVE_CODE_SETUP', { lang, frozenLen: ac.frozenLen, tailLines: ac.tailLines, initialLines: ac.initialLines });
4270
- return ac;
4271
- }
4272
- // Copy previous active code state into the new one (after snapshot).
4273
- rehydrateActiveCode(oldAC, newAC) {
4274
- if (!oldAC || !newAC) return;
4275
- newAC.frozenEl.innerHTML = oldAC.frozenEl ? oldAC.frozenEl.innerHTML : '';
4276
- const fullText = newAC.codeEl.textContent || ''; const remainder = fullText.slice(oldAC.frozenLen);
4277
- newAC.tailEl.textContent = remainder;
4278
- newAC.frozenLen = oldAC.frozenLen; newAC.lang = oldAC.lang;
4279
- newAC.lines = oldAC.lines; newAC.tailLines = Utils.countNewlines(remainder);
4280
- newAC.lastPromoteTs = oldAC.lastPromoteTs; newAC.linesSincePromote = oldAC.linesSincePromote || 0;
4281
- newAC.initialLines = oldAC.initialLines || 0; newAC.haltHL = !!oldAC.haltHL;
4282
- newAC.plainStream = !!oldAC.plainStream;
4283
- this._d('ACTIVE_CODE_REHYDRATE', { lang: newAC.lang, frozenLen: newAC.frozenLen, tailLines: newAC.tailLines, initialLines: newAC.initialLines, halted: newAC.haltHL, plainStream: newAC.plainStream });
4284
- }
4285
- // Append text to active tail span and update counters.
4286
- appendToActiveTail(text) {
4287
- if (!this.activeCode || !this.activeCode.tailEl || !text) return;
4288
- this.activeCode.tailEl.insertAdjacentText('beforeend', text);
4289
- const nl = Utils.countNewlines(text);
4290
- this.activeCode.tailLines += nl; this.activeCode.linesSincePromote += nl;
4291
- this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4292
- if (this.logger.isEnabled('STREAM') && (nl > 0 || text.length >= 64)) {
4293
- this._d('TAIL_APPEND', { addLen: text.length, addNL: nl, totalTailNL: this.activeCode.tailLines });
4294
- }
4295
- }
4296
- // Enforce budgets: stop incremental hljs and switch to plain streaming if needed.
4297
- enforceHLStopBudget() {
4298
- if (!this.activeCode) return;
4299
- // If global disable was requested, halt early and switch to plain streaming.
4300
- if (this.cfg.HL.DISABLE_ALL) { this.activeCode.haltHL = true; this.activeCode.plainStream = true; return; }
4301
- const stop = (this.cfg.PROFILE_CODE.stopAfterLines | 0);
4302
- const streamPlainLines = (this.cfg.PROFILE_CODE.streamPlainAfterLines | 0);
4303
- const streamPlainChars = (this.cfg.PROFILE_CODE.streamPlainAfterChars | 0);
4304
- const maxFrozenChars = (this.cfg.PROFILE_CODE.maxFrozenChars | 0);
4305
-
4306
- const totalLines = (this.activeCode.initialLines || 0) + (this.activeCode.lines || 0);
4307
- const frozenChars = this.activeCode.frozenLen | 0;
4308
- const tailChars = (this.activeCode.tailEl?.textContent || '').length | 0;
4309
- const totalStreamedChars = frozenChars + tailChars;
4310
-
4311
- // Switch to plain streaming after budgets – no incremental hljs
4312
- if ((streamPlainLines > 0 && totalLines >= streamPlainLines) ||
4313
- (streamPlainChars > 0 && totalStreamedChars >= streamPlainChars) ||
4314
- (maxFrozenChars > 0 && frozenChars >= maxFrozenChars)) {
4315
- this.activeCode.haltHL = true;
4316
- this.activeCode.plainStream = true;
4317
- try { this.activeCode.codeEl.dataset.hlStreamSuspended = '1'; } catch (_) {}
4318
- this._d('STREAM_HL_SUSPENDED', { totalLines, totalStreamedChars, frozenChars, reason: 'budget' });
4319
- return;
4320
- }
4372
+ this.snapshotScheduled = true;
4373
+ this.raf.schedule('SE:snapshot', () => { this.snapshotScheduled = false; this.renderSnapshot(msg); }, 'StreamEngine', 0);
4374
+ }
4321
4375
 
4322
- if (stop > 0 && totalLines >= stop) {
4323
- this.activeCode.haltHL = true;
4324
- this.activeCode.plainStream = true;
4325
- try { this.activeCode.codeEl.dataset.hlStreamSuspended = '1'; } catch (_) {}
4326
- this._d('STREAM_HL_SUSPENDED', { totalLines, stopAfter: stop, reason: 'stopAfterLines' });
4327
- }
4328
- }
4329
- _aliasLang(token) {
4330
- const ALIAS = {
4331
- txt: 'plaintext', text: 'plaintext', plaintext: 'plaintext',
4332
- sh: 'bash', shell: 'bash', zsh: 'bash', 'shell-session': 'bash',
4333
- py: 'python', python3: 'python', py3: 'python',
4334
- js: 'javascript', node: 'javascript', nodejs: 'javascript',
4335
- ts: 'typescript', 'ts-node': 'typescript',
4336
- yml: 'yaml', kt: 'kotlin', rs: 'rust',
4337
- csharp: 'csharp', 'c#': 'csharp', 'c++': 'cpp',
4338
- ps: 'powershell', ps1: 'powershell', pwsh: 'powershell', powershell7: 'powershell',
4339
- docker: 'dockerfile'
4340
- };
4341
- const v = String(token || '').trim().toLowerCase();
4342
- return ALIAS[v] || v;
4343
- }
4344
- _isHLJSSupported(lang) {
4345
- try { return !!(window.hljs && hljs.getLanguage && hljs.getLanguage(lang)); } catch (_) { return false; }
4346
- }
4347
- // Try to detect language from a "language: X" style first line directive.
4348
- _detectDirectiveLangFromText(text) {
4349
- if (!text) return null;
4350
- let s = String(text);
4351
- if (s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
4352
- const lines = s.split(/\r?\n/);
4353
- let i = 0; while (i < lines.length && !lines[i].trim()) i++;
4354
- if (i >= lines.length) return null;
4355
- let first = lines[i].trim();
4356
- first = first.replace(/^\s*lang(?:uage)?\s*[:=]\s*/i, '').trim();
4357
- let token = first.split(/\s+/)[0].replace(/:$/, '');
4358
- if (!/^[A-Za-z][\w#+\-\.]{0,30}$/.test(token)) return null;
4359
-
4360
- let cand = this._aliasLang(token);
4361
- const rest = lines.slice(i + 1).join('\n');
4362
- if (!rest.trim()) return null;
4363
-
4364
- let pos = 0, seen = 0;
4365
- while (seen < i && pos < s.length) { const nl = s.indexOf('\n', pos); if (nl === -1) return null; pos = nl + 1; seen++; }
4366
- let end = s.indexOf('\n', pos);
4367
- if (end === -1) end = s.length; else end = end + 1;
4368
- return { lang: cand, deleteUpto: end };
4369
- }
4370
- // Update code element class to reflect new lang (language-xxx).
4371
- _updateCodeLangClass(codeEl, newLang) {
4372
- try {
4373
- Array.from(codeEl.classList).forEach(c => { if (c.startsWith('language-')) codeEl.classList.remove(c); });
4374
- codeEl.classList.add('language-' + (newLang || 'plaintext'));
4375
- } catch (_) {}
4376
+ ensureSplitCodeEl(codeEl) {
4377
+ if (!codeEl) return null;
4378
+ let frozen = codeEl.querySelector('.hl-frozen'); let tail = codeEl.querySelector('.hl-tail');
4379
+ if (frozen && tail) return { codeEl, frozenEl: frozen, tailEl: tail };
4380
+ const text = codeEl.textContent || ''; codeEl.innerHTML = '';
4381
+ frozen = document.createElement('span'); frozen.className = 'hl-frozen';
4382
+ tail = document.createElement('span'); tail.className = 'hl-tail';
4383
+ codeEl.appendChild(frozen); codeEl.appendChild(tail);
4384
+ if (text) tail.textContent = text; return { codeEl, frozenEl: frozen, tailEl: tail };
4385
+ }
4386
+ setupActiveCodeFromSnapshot(snap) {
4387
+ const codes = snap.querySelectorAll('pre code'); if (!codes.length) return null;
4388
+ const last = codes[codes.length - 1];
4389
+ const cls = Array.from(last.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
4390
+ const lang = (cls.replace('language-', '') || 'plaintext');
4391
+ const parts = this.ensureSplitCodeEl(last); if (!parts) return null;
4392
+
4393
+ // If we injected a synthetic EOL for parsing an open fence, remove it from the streaming tail now.
4394
+ if (this._lastInjectedEOL && parts.tailEl && parts.tailEl.textContent && parts.tailEl.textContent.endsWith('\n')) {
4395
+ parts.tailEl.textContent = parts.tailEl.textContent.slice(0, -1);
4396
+ this._lastInjectedEOL = false;
4376
4397
  }
4377
- // Update code header label and data attribute.
4378
- _updateCodeHeaderLabel(codeEl, newLabel, newLangToken) {
4379
- try {
4380
- const wrap = codeEl.closest('.code-wrapper');
4381
- if (!wrap) return;
4382
- const span = wrap.querySelector('.code-header-lang');
4383
- if (span) span.textContent = newLabel || (newLangToken || 'code');
4384
- wrap.setAttribute('data-code-lang', newLangToken || '');
4385
- } catch (_) {}
4398
+
4399
+ const st = this.codeScroll.state(parts.codeEl); st.autoFollow = true; st.userInteracted = false;
4400
+ parts.codeEl.dataset._active_stream = '1';
4401
+ const baseFrozenNL = Utils.countNewlines(parts.frozenEl.textContent || ''); const baseTailNL = Utils.countNewlines(parts.tailEl.textContent || '');
4402
+ const ac = { codeEl: parts.codeEl, frozenEl: parts.frozenEl, tailEl: parts.tailEl, lang, frozenLen: parts.frozenEl.textContent.length, lastPromoteTs: 0,
4403
+ lines: 0, tailLines: baseTailNL, linesSincePromote: 0, initialLines: baseFrozenNL + baseTailNL, haltHL: false, plainStream: false };
4404
+ this._d('ACTIVE_CODE_SETUP', { lang, frozenLen: ac.frozenLen, tailLines: ac.tailLines, initialLines: ac.initialLines });
4405
+ return ac;
4406
+ }
4407
+ rehydrateActiveCode(oldAC, newAC) {
4408
+ if (!oldAC || !newAC) return;
4409
+ newAC.frozenEl.innerHTML = oldAC.frozenEl ? oldAC.frozenEl.innerHTML : '';
4410
+ const fullText = newAC.codeEl.textContent || ''; const remainder = fullText.slice(oldAC.frozenLen);
4411
+ newAC.tailEl.textContent = remainder;
4412
+ newAC.frozenLen = oldAC.frozenLen; newAC.lang = oldAC.lang;
4413
+ newAC.lines = oldAC.lines; newAC.tailLines = Utils.countNewlines(remainder);
4414
+ newAC.lastPromoteTs = oldAC.lastPromoteTs; newAC.linesSincePromote = oldAC.linesSincePromote || 0;
4415
+ newAC.initialLines = oldAC.initialLines || 0; newAC.haltHL = !!oldAC.haltHL;
4416
+ newAC.plainStream = !!oldAC.plainStream;
4417
+ this._d('ACTIVE_CODE_REHYDRATE', { lang: newAC.lang, frozenLen: newAC.frozenLen, tailLines: newAC.tailLines, initialLines: newAC.initialLines, halted: newAC.haltHL, plainStream: newAC.plainStream });
4418
+ }
4419
+ appendToActiveTail(text) {
4420
+ if (!this.activeCode || !this.activeCode.tailEl || !text) return;
4421
+ this.activeCode.tailEl.insertAdjacentText('beforeend', text);
4422
+ const nl = Utils.countNewlines(text);
4423
+ this.activeCode.tailLines += nl; this.activeCode.linesSincePromote += nl;
4424
+ this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4425
+ if (this.logger.isEnabled('STREAM') && (nl > 0 || text.length >= 64)) {
4426
+ this._d('TAIL_APPEND', { addLen: text.length, addNL: nl, totalTailNL: this.activeCode.tailLines });
4386
4427
  }
4387
- // Try to promote language from a directive and remove its header line.
4388
- maybePromoteLanguageFromDirective() {
4389
- if (!this.activeCode || !this.activeCode.codeEl) return;
4390
- if (this.activeCode.lang && this.activeCode.lang !== 'plaintext') return;
4428
+ }
4429
+ enforceHLStopBudget() {
4430
+ if (!this.activeCode) return;
4431
+ if (this.cfg.HL.DISABLE_ALL) { this.activeCode.haltHL = true; this.activeCode.plainStream = true; return; }
4432
+ const stop = (this.cfg.PROFILE_CODE.stopAfterLines | 0);
4433
+ const streamPlainLines = (this.cfg.PROFILE_CODE.streamPlainAfterLines | 0);
4434
+ const streamPlainChars = (this.cfg.PROFILE_CODE.streamPlainAfterChars | 0);
4435
+ const maxFrozenChars = (this.cfg.PROFILE_CODE.maxFrozenChars | 0);
4436
+
4437
+ const totalLines = (this.activeCode.initialLines || 0) + (this.activeCode.lines || 0);
4438
+ const frozenChars = this.activeCode.frozenLen | 0;
4439
+ const tailChars = (this.activeCode.tailEl?.textContent || '').length | 0;
4440
+ const totalStreamedChars = frozenChars + tailChars;
4441
+
4442
+ if ((streamPlainLines > 0 && totalLines >= streamPlainLines) ||
4443
+ (streamPlainChars > 0 && totalStreamedChars >= streamPlainChars) ||
4444
+ (maxFrozenChars > 0 && frozenChars >= maxFrozenChars)) {
4445
+ this.activeCode.haltHL = true;
4446
+ this.activeCode.plainStream = true;
4447
+ try { this.activeCode.codeEl.dataset.hlStreamSuspended = '1'; } catch (_) {}
4448
+ this._d('STREAM_HL_SUSPENDED', { totalLines, totalStreamedChars, frozenChars, reason: 'budget' });
4449
+ return;
4450
+ }
4451
+
4452
+ if (stop > 0 && totalLines >= stop) {
4453
+ this.activeCode.haltHL = true;
4454
+ this.activeCode.plainStream = true;
4455
+ try { this.activeCode.codeEl.dataset.hlStreamSuspended = '1'; } catch (_) {}
4456
+ this._d('STREAM_HL_SUSPENDED', { totalLines, stopAfter: stop, reason: 'stopAfterLines' });
4457
+ }
4458
+ }
4459
+ _aliasLang(token) {
4460
+ const ALIAS = {
4461
+ txt: 'plaintext', text: 'plaintext', plaintext: 'plaintext',
4462
+ sh: 'bash', shell: 'bash', zsh: 'bash', 'shell-session': 'bash',
4463
+ py: 'python', python3: 'python', py3: 'python',
4464
+ js: 'javascript', node: 'javascript', nodejs: 'javascript',
4465
+ ts: 'typescript', 'ts-node': 'typescript',
4466
+ yml: 'yaml', kt: 'kotlin', rs: 'rust',
4467
+ csharp: 'csharp', 'c#': 'csharp', 'c++': 'cpp',
4468
+ ps: 'powershell', ps1: 'powershell', pwsh: 'powershell', powershell7: 'powershell',
4469
+ docker: 'dockerfile'
4470
+ };
4471
+ const v = String(token || '').trim().toLowerCase();
4472
+ return ALIAS[v] || v;
4473
+ }
4474
+ _isHLJSSupported(lang) {
4475
+ try { return !!(window.hljs && hljs.getLanguage && hljs.getLanguage(lang)); } catch (_) { return false; }
4476
+ }
4477
+ _detectDirectiveLangFromText(text) {
4478
+ if (!text) return null;
4479
+ let s = String(text);
4480
+ if (s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
4481
+ const lines = s.split(/\r?\n/);
4482
+ let i = 0; while (i < lines.length && !lines[i].trim()) i++;
4483
+ if (i >= lines.length) return null;
4484
+ let first = lines[i].trim();
4485
+ first = first.replace(/^\s*lang(?:uage)?\s*[:=]\s*/i, '').trim();
4486
+ let token = first.split(/\s+/)[0].replace(/:$/, '');
4487
+ if (!/^[A-Za-z][\w#+\-\.]{0,30}$/.test(token)) return null;
4488
+
4489
+ let cand = this._aliasLang(token);
4490
+ const rest = lines.slice(i + 1).join('\n');
4491
+ if (!rest.trim()) return null;
4492
+
4493
+ let pos = 0, seen = 0;
4494
+ while (seen < i && pos < s.length) { const nl = s.indexOf('\n', pos); if (nl === -1) return null; pos = nl + 1; seen++; }
4495
+ let end = s.indexOf('\n', pos);
4496
+ if (end === -1) end = s.length; else end = end + 1;
4497
+ return { lang: cand, deleteUpto: end };
4498
+ }
4499
+ _updateCodeLangClass(codeEl, newLang) {
4500
+ try {
4501
+ Array.from(codeEl.classList).forEach(c => { if (c.startsWith('language-')) codeEl.classList.remove(c); });
4502
+ codeEl.classList.add('language-' + (newLang || 'plaintext'));
4503
+ } catch (_) {}
4504
+ }
4505
+ _updateCodeHeaderLabel(codeEl, newLabel, newLangToken) {
4506
+ try {
4507
+ const wrap = codeEl.closest('.code-wrapper');
4508
+ if (!wrap) return;
4509
+ const span = wrap.querySelector('.code-header-lang');
4510
+ if (span) span.textContent = newLabel || (newLangToken || 'code');
4511
+ wrap.setAttribute('data-code-lang', newLangToken || '');
4512
+ } catch (_) {}
4513
+ }
4514
+ maybePromoteLanguageFromDirective() {
4515
+ if (!this.activeCode || !this.activeCode.codeEl) return;
4516
+ if (this.activeCode.lang && this.activeCode.lang !== 'plaintext') return;
4391
4517
 
4392
- const frozenTxt = this.activeCode.frozenEl ? this.activeCode.frozenEl.textContent : '';
4393
- const tailTxt = this.activeCode.tailEl ? this.activeCode.tailEl.textContent : '';
4394
- const combined = frozenTxt + tailTxt;
4395
- if (!combined) return;
4518
+ const frozenTxt = this.activeCode.frozenEl ? this.activeCode.frozenEl.textContent : '';
4519
+ const tailTxt = this.activeCode.tailEl ? this.activeCode.tailEl.textContent : '';
4520
+ const combined = frozenTxt + tailTxt;
4521
+ if (!combined) return;
4396
4522
 
4397
- const det = this._detectDirectiveLangFromText(combined);
4398
- if (!det || !det.lang) return;
4523
+ const det = this._detectDirectiveLangFromText(combined);
4524
+ if (!det || !det.lang) return;
4399
4525
 
4400
- const newLang = det.lang;
4401
- const newCombined = combined.slice(det.deleteUpto);
4526
+ const newLang = det.lang;
4527
+ const newCombined = combined.slice(det.deleteUpto);
4402
4528
 
4403
- try {
4404
- const codeEl = this.activeCode.codeEl;
4405
- codeEl.innerHTML = '';
4406
- const frozen = document.createElement('span'); frozen.className = 'hl-frozen';
4407
- const tail = document.createElement('span'); tail.className = 'hl-tail';
4408
- tail.textContent = newCombined;
4409
- codeEl.appendChild(frozen); codeEl.appendChild(tail);
4410
- this.activeCode.frozenEl = frozen; this.activeCode.tailEl = tail;
4411
- this.activeCode.frozenLen = 0;
4412
- this.activeCode.tailLines = Utils.countNewlines(newCombined);
4413
- this.activeCode.linesSincePromote = 0;
4414
-
4415
- this.activeCode.lang = newLang;
4416
- this._updateCodeLangClass(codeEl, newLang);
4417
- this._updateCodeHeaderLabel(codeEl, newLang, newLang);
4418
-
4419
- this._d('LANG_PROMOTE', { to: newLang, removedChars: det.deleteUpto, tailLines: this.activeCode.tailLines });
4420
- this.schedulePromoteTail(true);
4421
- } catch (e) {
4422
- this._d('LANG_PROMOTE_ERR', String(e));
4423
- }
4424
- }
4425
- // Highlight a small piece of text based on language (safe fallback to escapeHtml).
4426
- highlightDeltaText(lang, text) {
4427
- if (this.cfg.HL.DISABLE_ALL) return Utils.escapeHtml(text);
4428
- if (window.hljs && lang && hljs.getLanguage && hljs.getLanguage(lang)) {
4429
- try { return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value; }
4430
- catch (_) { return Utils.escapeHtml(text); }
4431
- }
4432
- return Utils.escapeHtml(text);
4433
- }
4434
- // Schedule cooperative tail promotion (async) to avoid blocking UI on each chunk.
4435
- schedulePromoteTail(force = false) {
4436
- if (!this.activeCode || !this.activeCode.tailEl) return;
4437
- if (this._promoteScheduled) return;
4438
- this._promoteScheduled = true;
4439
- this.raf.schedule('SE:promoteTail', () => {
4440
- this._promoteScheduled = false;
4441
- this._promoteTailWork(force);
4442
- }, 'StreamEngine', 1);
4443
- }
4444
- // Move a full-line part of tail into frozen region (with highlight if budgets allow).
4445
- async _promoteTailWork(force = false) {
4446
- if (!this.activeCode || !this.activeCode.tailEl) return;
4447
-
4448
- // If plain streaming mode is on, or incremental hljs is disabled, promote as plain text only.
4449
- const now = Utils.now(); const prof = this.cfg.PROFILE_CODE;
4450
- const tailText0 = this.activeCode.tailEl.textContent || ''; if (!tailText0) return;
4451
-
4452
- if (!force) {
4453
- if ((now - this.activeCode.lastPromoteTs) < prof.promoteMinInterval) return;
4454
- const enoughLines = (this.activeCode.linesSincePromote || 0) >= (prof.promoteMinLines || 10);
4455
- const enoughChars = tailText0.length >= prof.minCharsForHL;
4456
- if (!enoughLines && !enoughChars) return;
4457
- }
4458
-
4459
- // Cut at last full line to avoid moving partial tokens
4460
- const idx = tailText0.lastIndexOf('\n');
4461
- if (idx <= -1 && !force) return;
4462
- const cut = (idx >= 0) ? (idx + 1) : tailText0.length;
4463
- const delta = tailText0.slice(0, cut); if (!delta) return;
4464
-
4465
- // Re-evaluate budgets before performing any heavy work
4466
- this.enforceHLStopBudget();
4467
- const usePlain = this.activeCode.haltHL || this.activeCode.plainStream || !this._isHLJSSupported(this.activeCode.lang);
4468
-
4469
- // Cooperative rAF yield before heavy highlight
4470
- if (!usePlain) await this.asyncer.yield();
4471
-
4472
- // If tail changed since we captured it, validate prefix to avoid duplication
4473
- if (!this.activeCode || !this.activeCode.tailEl) return;
4474
- const tailNow = this.activeCode.tailEl.textContent || '';
4475
- if (!tailNow.startsWith(delta)) {
4476
- // New data arrived; reschedule for next frame without touching DOM
4477
- this.schedulePromoteTail(false);
4478
- return;
4479
- }
4529
+ try {
4530
+ const codeEl = this.activeCode.codeEl;
4531
+ codeEl.innerHTML = '';
4532
+ const frozen = document.createElement('span'); frozen.className = 'hl-frozen';
4533
+ const tail = document.createElement('span'); tail.className = 'hl-tail';
4534
+ tail.textContent = newCombined;
4535
+ codeEl.appendChild(frozen); codeEl.appendChild(tail);
4536
+ this.activeCode.frozenEl = frozen; this.activeCode.tailEl = tail;
4537
+ this.activeCode.frozenLen = 0;
4538
+ this.activeCode.tailLines = Utils.countNewlines(newCombined);
4539
+ this.activeCode.linesSincePromote = 0;
4480
4540
 
4481
- // Apply DOM updates: either highlighted HTML delta or plain text
4482
- if (usePlain) {
4483
- // Plain text promotion – extremely cheap, no spans created.
4484
- this.activeCode.frozenEl.insertAdjacentText('beforeend', delta);
4485
- } else {
4486
- // Highlighted promotion – still capped by budgets above.
4487
- let html = Utils.escapeHtml(delta);
4488
- try { html = this.highlightDeltaText(this.activeCode.lang, delta); } catch (_) { html = Utils.escapeHtml(delta); }
4489
- this.activeCode.frozenEl.insertAdjacentHTML('beforeend', html);
4490
- }
4491
-
4492
- // Update tail and counters
4493
- this.activeCode.tailEl.textContent = tailNow.slice(delta.length);
4494
- this.activeCode.frozenLen += delta.length;
4495
- const promotedLines = Utils.countNewlines(delta);
4496
- this.activeCode.tailLines = Math.max(0, (this.activeCode.tailLines || 0) - promotedLines);
4497
- this.activeCode.linesSincePromote = Math.max(0, (this.activeCode.linesSincePromote || 0) - promotedLines);
4498
- this.activeCode.lastPromoteTs = Utils.now();
4499
- this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4500
- this._d(usePlain ? 'TAIL_PROMOTE_PLAIN' : 'TAIL_PROMOTE_ASYNC', { cut, promotedLines, lang: this.activeCode.lang, plain: usePlain });
4541
+ this.activeCode.lang = newLang;
4542
+ this._updateCodeLangClass(codeEl, newLang);
4543
+ this._updateCodeHeaderLabel(codeEl, newLang, newLang);
4544
+
4545
+ this._d('LANG_PROMOTE', { to: newLang, removedChars: det.deleteUpto, tailLines: this.activeCode.tailLines });
4546
+ this.schedulePromoteTail(true);
4547
+ } catch (e) {
4548
+ this._d('LANG_PROMOTE_ERR', String(e));
4501
4549
  }
4502
- // Finalize the current active code block. Keep it plain for now and schedule highlight lazily.
4503
- finalizeActiveCode() {
4504
- if (!this.activeCode) return;
4505
- const codeEl = this.activeCode.codeEl;
4506
- const fromBottomBefore = Math.max(0, codeEl.scrollHeight - codeEl.clientHeight - codeEl.scrollTop);
4507
- const wasNearBottom = this.codeScroll.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.NEAR_MARGIN_PX);
4508
- const fullText = (this.activeCode.frozenEl.textContent || '') + (this.activeCode.tailEl.textContent || '');
4550
+ }
4551
+ highlightDeltaText(lang, text) {
4552
+ if (this.cfg.HL.DISABLE_ALL) return Utils.escapeHtml(text);
4553
+ if (window.hljs && lang && hljs.getLanguage && hljs.getLanguage(lang)) {
4554
+ try { return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value; }
4555
+ catch (_) { return Utils.escapeHtml(text); }
4556
+ }
4557
+ return Utils.escapeHtml(text);
4558
+ }
4559
+ schedulePromoteTail(force = false) {
4560
+ if (!this.activeCode || !this.activeCode.tailEl) return;
4561
+ if (this._promoteScheduled) return;
4562
+ this._promoteScheduled = true;
4563
+ this.raf.schedule('SE:promoteTail', () => {
4564
+ this._promoteScheduled = false;
4565
+ this._promoteTailWork(force);
4566
+ }, 'StreamEngine', 1);
4567
+ }
4568
+ async _promoteTailWork(force = false) {
4569
+ if (!this.activeCode || !this.activeCode.tailEl) return;
4509
4570
 
4510
- // Non-blocking finalize: place plain text now, schedule highlight via Highlighter later.
4511
- try {
4512
- codeEl.innerHTML = '';
4513
- codeEl.textContent = fullText;
4514
- codeEl.classList.add('hljs'); // keep visual parity until highlight applies
4515
- codeEl.removeAttribute('data-highlighted');
4516
- } catch (_) {}
4571
+ const now = Utils.now(); const prof = this.cfg.PROFILE_CODE;
4572
+ const tailText0 = this.activeCode.tailEl.textContent || ''; if (!tailText0) return;
4517
4573
 
4518
- const st = this.codeScroll.state(codeEl); st.autoFollow = false;
4519
- const maxScrollTop = Math.max(0, codeEl.scrollHeight - codeEl.clientHeight);
4520
- const target = wasNearBottom ? maxScrollTop : Math.max(0, maxScrollTop - fromBottomBefore);
4521
- try { codeEl.scrollTop = target; } catch (_) {}
4522
- st.lastScrollTop = codeEl.scrollTop;
4523
- codeEl.dataset._active_stream = '0';
4574
+ if (!force) {
4575
+ if ((now - this.activeCode.lastPromoteTs) < prof.promoteMinInterval) return;
4576
+ const enoughLines = (this.activeCode.linesSincePromote || 0) >= (prof.promoteMinLines || 10);
4577
+ const enoughChars = tailText0.length >= prof.minCharsForHL;
4578
+ if (!enoughLines && !enoughChars) return;
4579
+ }
4524
4580
 
4525
- try { codeEl.dataset.justFinalized = '1'; } catch (_) {}
4526
- this.codeScroll.scheduleScroll(codeEl, false, true);
4581
+ const idx = tailText0.lastIndexOf('\n');
4582
+ if (idx <= -1 && !force) return;
4583
+ const cut = (idx >= 0) ? (idx + 1) : tailText0.length;
4584
+ const delta = tailText0.slice(0, cut); if (!delta) return;
4527
4585
 
4528
- // Schedule async highlight on the finalized element (viewport-aware).
4529
- try { if (!this.cfg.HL.DISABLE_ALL) this.highlighter.queue(codeEl, null); } catch (_) {}
4586
+ this.enforceHLStopBudget();
4587
+ const usePlain = this.activeCode.haltHL || this.activeCode.plainStream || !this._isHLJSSupported(this.activeCode.lang);
4530
4588
 
4531
- this.suppressPostFinalizePass = true;
4589
+ if (!usePlain) await this.asyncer.yield();
4532
4590
 
4533
- this._d('FINALIZE_CODE_NONBLOCK', { lang: this.activeCode.lang, len: fullText.length, highlighted: false });
4534
- this.activeCode = null;
4591
+ if (!this.activeCode || !this.activeCode.tailEl) return;
4592
+ const tailNow = this.activeCode.tailEl.textContent || '';
4593
+ if (!tailNow.startsWith(delta)) {
4594
+ this.schedulePromoteTail(false);
4595
+ return;
4596
+ }
4597
+
4598
+ if (usePlain) {
4599
+ this.activeCode.frozenEl.insertAdjacentText('beforeend', delta);
4600
+ } else {
4601
+ let html = Utils.escapeHtml(delta);
4602
+ try { html = this.highlightDeltaText(this.activeCode.lang, delta); } catch (_) { html = Utils.escapeHtml(delta); }
4603
+ this.activeCode.frozenEl.insertAdjacentHTML('beforeend', html);
4535
4604
  }
4536
- // Make a simple fingerprint to reuse identical closed code blocks between snapshots.
4537
- codeFingerprint(codeEl) {
4605
+
4606
+ this.activeCode.tailEl.textContent = tailNow.slice(delta.length);
4607
+ this.activeCode.frozenLen += delta.length;
4608
+ const promotedLines = Utils.countNewlines(delta);
4609
+ this.activeCode.tailLines = Math.max(0, (this.activeCode.tailLines || 0) - promotedLines);
4610
+ this.activeCode.linesSincePromote = Math.max(0, (this.activeCode.linesSincePromote || 0) - promotedLines);
4611
+ this.activeCode.lastPromoteTs = Utils.now();
4612
+ this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4613
+ this._d(usePlain ? 'TAIL_PROMOTE_PLAIN' : 'TAIL_PROMOTE_ASYNC', { cut, promotedLines, lang: this.activeCode.lang, plain: usePlain });
4614
+ }
4615
+ finalizeActiveCode() {
4616
+ if (!this.activeCode) return;
4617
+ const codeEl = this.activeCode.codeEl;
4618
+ const fromBottomBefore = Math.max(0, codeEl.scrollHeight - codeEl.clientHeight - codeEl.scrollTop);
4619
+ const wasNearBottom = this.codeScroll.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.NEAR_MARGIN_PX);
4620
+ const fullText = (this.activeCode.frozenEl.textContent || '') + (this.activeCode.tailEl.textContent || '');
4621
+
4622
+ try {
4623
+ codeEl.innerHTML = '';
4624
+ codeEl.textContent = fullText;
4625
+ codeEl.classList.add('hljs');
4626
+ codeEl.removeAttribute('data-highlighted');
4627
+ } catch (_) {}
4628
+
4629
+ const st = this.codeScroll.state(codeEl); st.autoFollow = false;
4630
+ const maxScrollTop = Math.max(0, codeEl.scrollHeight - codeEl.clientHeight);
4631
+ const target = wasNearBottom ? maxScrollTop : Math.max(0, maxScrollTop - fromBottomBefore);
4632
+ try { codeEl.scrollTop = target; } catch (_) {}
4633
+ st.lastScrollTop = codeEl.scrollTop;
4634
+ codeEl.dataset._active_stream = '0';
4635
+
4636
+ try { codeEl.dataset.justFinalized = '1'; } catch (_) {}
4637
+ this.codeScroll.scheduleScroll(codeEl, false, true);
4638
+
4639
+ try { if (!this.cfg.HL.DISABLE_ALL) this.highlighter.queue(codeEl, null); } catch (_) {}
4640
+
4641
+ this.suppressPostFinalizePass = true;
4642
+
4643
+ this._d('FINALIZE_CODE_NONBLOCK', { lang: this.activeCode.lang, len: fullText.length, highlighted: false });
4644
+ this.activeCode = null;
4645
+ }
4646
+ codeFingerprint(codeEl) {
4647
+ const cls = Array.from(codeEl.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
4648
+ const lang = cls.replace('language-', '') || 'plaintext';
4649
+ const t = codeEl.textContent || ''; const len = t.length; const head = t.slice(0, 64); const tail = t.slice(-64);
4650
+ return `${lang}|${len}|${head}|${tail}`;
4651
+ }
4652
+ codeFingerprintFromWrapper(codeEl) {
4653
+ try {
4654
+ const wrap = codeEl.closest('.code-wrapper'); if (!wrap) return null;
4538
4655
  const cls = Array.from(codeEl.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
4539
- const lang = cls.replace('language-', '') || 'plaintext';
4540
- const t = codeEl.textContent || ''; const len = t.length; const head = t.slice(0, 64); const tail = t.slice(-64);
4656
+ const lang = (cls.replace('language-', '') || 'plaintext');
4657
+ const len = wrap.getAttribute('data-code-len') || '';
4658
+ const head = wrap.getAttribute('data-code-head') || '';
4659
+ const tail = wrap.getAttribute('data-code-tail') || '';
4660
+ if (!len) return null;
4541
4661
  return `${lang}|${len}|${head}|${tail}`;
4662
+ } catch (_) {
4663
+ return null;
4542
4664
  }
4543
- // fingerprint using precomputed meta on wrapper (avoids .textContent for heavy blocks).
4544
- codeFingerprintFromWrapper(codeEl) {
4545
- try {
4546
- const wrap = codeEl.closest('.code-wrapper'); if (!wrap) return null;
4547
- const cls = Array.from(codeEl.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
4548
- const lang = (cls.replace('language-', '') || 'plaintext');
4549
- const len = wrap.getAttribute('data-code-len') || '';
4550
- const head = wrap.getAttribute('data-code-head') || '';
4551
- const tail = wrap.getAttribute('data-code-tail') || '';
4552
- if (!len) return null; // ensure at least length exists
4553
- return `${lang}|${len}|${head}|${tail}`;
4554
- } catch (_) {
4555
- return null;
4556
- }
4557
- }
4558
- // Try to reuse old finalized code block DOM nodes to avoid re-highlighting.
4559
- preserveStableClosedCodes(oldSnap, newRoot, skipLastIfStreaming) {
4560
- try {
4561
- const oldCodes = Array.from(oldSnap.querySelectorAll('pre code')); if (!oldCodes.length) return;
4562
- // Safety guard: avoid heavy fingerprint work on extremely large outputs
4563
- const newCodesPre = Array.from(newRoot.querySelectorAll('pre code'));
4564
- if (newCodesPre.length > this.cfg.STREAM.PRESERVE_CODES_MAX || oldCodes.length > this.cfg.STREAM.PRESERVE_CODES_MAX) return;
4565
-
4566
- const map = new Map();
4567
- for (const el of oldCodes) {
4568
- if (el.querySelector('.hl-frozen')) continue; // skip streaming blocks
4569
- if (this.activeCode && el === this.activeCode.codeEl) continue;
4570
- // Try wrapper-based fingerprint first, fallback to text-based
4571
- let fp = this.codeFingerprintFromWrapper(el);
4572
- if (!fp) fp = el.dataset.fp || (el.dataset.fp = this.codeFingerprint(el));
4573
- const arr = map.get(fp) || []; arr.push(el); map.set(fp, arr);
4574
- }
4575
- const newCodes = newCodesPre;
4576
- const end = (skipLastIfStreaming && newCodes.length > 0) ? (newCodes.length - 1) : newCodes.length;
4577
- let reuseCount = 0;
4578
- for (let i = 0; i < end; i++) {
4579
- const nc = newCodes[i];
4580
- if (nc.getAttribute('data-highlighted') === 'yes') continue;
4581
- // Fingerprint new code: prefer wrapper meta (no .textContent read)
4582
- let fp = this.codeFingerprintFromWrapper(nc);
4583
- if (!fp) fp = this.codeFingerprint(nc);
4584
- const arr = map.get(fp);
4585
- if (arr && arr.length) {
4586
- const oldEl = arr.shift();
4587
- if (oldEl && oldEl.isConnected) {
4588
- try {
4589
- nc.replaceWith(oldEl);
4590
- this.codeScroll.attachHandlers(oldEl);
4591
- // Preserve whatever final state the old element had
4592
- if (!oldEl.getAttribute('data-highlighted')) oldEl.setAttribute('data-highlighted', 'yes');
4593
- const st = this.codeScroll.state(oldEl); st.autoFollow = false;
4594
- reuseCount++;
4595
- } catch (_) {}
4596
- }
4597
- if (!arr.length) map.delete(fp);
4665
+ }
4666
+ preserveStableClosedCodes(oldSnap, newRoot, skipLastIfStreaming) {
4667
+ try {
4668
+ const oldCodes = Array.from(oldSnap.querySelectorAll('pre code')); if (!oldCodes.length) return;
4669
+ const newCodesPre = Array.from(newRoot.querySelectorAll('pre code'));
4670
+ if (newCodesPre.length > this.cfg.STREAM.PRESERVE_CODES_MAX || oldCodes.length > this.cfg.STREAM.PRESERVE_CODES_MAX) return;
4671
+
4672
+ const map = new Map();
4673
+ for (const el of oldCodes) {
4674
+ if (el.querySelector('.hl-frozen')) continue;
4675
+ if (this.activeCode && el === this.activeCode.codeEl) continue;
4676
+ let fp = this.codeFingerprintFromWrapper(el);
4677
+ if (!fp) fp = el.dataset.fp || (el.dataset.fp = this.codeFingerprint(el));
4678
+ const arr = map.get(fp) || []; arr.push(el); map.set(fp, arr);
4679
+ }
4680
+ const newCodes = newCodesPre;
4681
+ const end = (skipLastIfStreaming && newCodes.length > 0) ? (newCodes.length - 1) : newCodes.length;
4682
+ let reuseCount = 0;
4683
+ for (let i = 0; i < end; i++) {
4684
+ const nc = newCodes[i];
4685
+ if (nc.getAttribute('data-highlighted') === 'yes') continue;
4686
+ let fp = this.codeFingerprintFromWrapper(nc);
4687
+ if (!fp) fp = this.codeFingerprint(nc);
4688
+ const arr = map.get(fp);
4689
+ if (arr && arr.length) {
4690
+ const oldEl = arr.shift();
4691
+ if (oldEl && oldEl.isConnected) {
4692
+ try {
4693
+ nc.replaceWith(oldEl);
4694
+ this.codeScroll.attachHandlers(oldEl);
4695
+ if (!oldEl.getAttribute('data-highlighted')) oldEl.setAttribute('data-highlighted', 'yes');
4696
+ const st = this.codeScroll.state(oldEl); st.autoFollow = false;
4697
+ reuseCount++;
4698
+ } catch (_) {}
4598
4699
  }
4700
+ if (!arr.length) map.delete(fp);
4599
4701
  }
4600
- if (reuseCount) this._d('PRESERVE_CODES_REUSED', { reuseCount, skipLastIfStreaming });
4601
- } catch (e) {
4602
- this._d('PRESERVE_CODES_ERROR', String(e));
4603
4702
  }
4703
+ if (reuseCount) this._d('PRESERVE_CODES_REUSED', { reuseCount, skipLastIfStreaming });
4704
+ } catch (e) {
4705
+ this._d('PRESERVE_CODES_ERROR', String(e));
4604
4706
  }
4605
- // Ensure blocks marked as just-finalized are scrolled to bottom.
4606
- _ensureBottomForJustFinalized(root) {
4607
- try {
4608
- const scope = root || document;
4609
- const nodes = scope.querySelectorAll('pre code[data-just-finalized="1"]');
4610
- if (!nodes || !nodes.length) return;
4611
- nodes.forEach((codeEl) => {
4612
- this.codeScroll.scheduleScroll(codeEl, false, true);
4613
- const key = { t: 'JF:forceBottom', el: codeEl, n: Math.random() };
4614
- this.raf.schedule(key, () => {
4615
- this.codeScroll.scrollToBottom(codeEl, false, true);
4616
- try { codeEl.dataset.justFinalized = '0'; } catch (_) {}
4617
- }, 'CodeScroll', 2);
4618
- });
4619
- } catch (_) {}
4620
- }
4621
- // If stream is visible but something got stuck, force a quick refresh.
4622
- kickVisibility() {
4623
- const msg = this.getMsg(false, '');
4624
- if (!msg) return;
4625
- if (this.codeStream.open && !this.activeCode) {
4626
- this.scheduleSnapshot(msg, true);
4627
- return;
4628
- }
4629
- const needSnap = (this.getStreamLength() !== (window.__lastSnapshotLen || 0));
4630
- if (needSnap) this.scheduleSnapshot(msg, true);
4631
- if (this.activeCode && this.activeCode.codeEl) {
4632
- this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4633
- this.schedulePromoteTail(true);
4634
- }
4707
+ }
4708
+ _ensureBottomForJustFinalized(root) {
4709
+ try {
4710
+ const scope = root || document;
4711
+ const nodes = scope.querySelectorAll('pre code[data-just-finalized="1"]');
4712
+ if (!nodes || !nodes.length) return;
4713
+ nodes.forEach((codeEl) => {
4714
+ this.codeScroll.scheduleScroll(codeEl, false, true);
4715
+ const key = { t: 'JF:forceBottom', el: codeEl, n: Math.random() };
4716
+ this.raf.schedule(key, () => {
4717
+ this.codeScroll.scrollToBottom(codeEl, false, true);
4718
+ try { codeEl.dataset.justFinalized = '0'; } catch (_) {}
4719
+ }, 'CodeScroll', 2);
4720
+ });
4721
+ } catch (_) {}
4722
+ }
4723
+ kickVisibility() {
4724
+ const msg = this.getMsg(false, '');
4725
+ if (!msg) return;
4726
+ if (this.codeStream.open && !this.activeCode) {
4727
+ this.scheduleSnapshot(msg, true);
4728
+ return;
4729
+ }
4730
+ const needSnap = (this.getStreamLength() !== (window.__lastSnapshotLen || 0));
4731
+ if (needSnap) this.scheduleSnapshot(msg, true);
4732
+ if (this.activeCode && this.activeCode.codeEl) {
4733
+ this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4734
+ this.schedulePromoteTail(true);
4635
4735
  }
4636
- // Keep language header stable across snapshots for the active streaming code.
4637
- // If the current snapshot produced a tiny/unsupported token (e.g. 'on', 'ml', 's'),
4638
- // reuse the last known good language (from previous active state or sticky attribute).
4639
- stabilizeHeaderLabel(prevAC, newAC) {
4640
- try {
4641
- if (!newAC || !newAC.codeEl || !newAC.codeEl.isConnected) return;
4736
+ }
4737
+ stabilizeHeaderLabel(prevAC, newAC) {
4738
+ try {
4739
+ if (!newAC || !newAC.codeEl || !newAC.codeEl.isConnected) return;
4642
4740
 
4643
- const wrap = newAC.codeEl.closest('.code-wrapper');
4644
- if (!wrap) return;
4741
+ const wrap = newAC.codeEl.closest('.code-wrapper');
4742
+ if (!wrap) return;
4645
4743
 
4646
- const span = wrap.querySelector('.code-header-lang');
4647
- const curLabel = (span && span.textContent ? span.textContent.trim() : '').toLowerCase();
4744
+ const span = wrap.querySelector('.code-header-lang');
4745
+ const curLabel = (span && span.textContent ? span.textContent.trim() : '').toLowerCase();
4648
4746
 
4649
- // Do not touch tool/output blocks
4650
- if (curLabel === 'output') return;
4747
+ if (curLabel === 'output') return;
4651
4748
 
4652
- const tokNow = (wrap.getAttribute('data-code-lang') || '').trim().toLowerCase();
4653
- const sticky = (wrap.getAttribute('data-lang-sticky') || '').trim().toLowerCase();
4654
- const prev = (prevAC && prevAC.lang && prevAC.lang !== 'plaintext') ? prevAC.lang.toLowerCase() : '';
4749
+ const tokNow = (wrap.getAttribute('data-code-lang') || '').trim().toLowerCase();
4750
+ const sticky = (wrap.getAttribute('data-lang-sticky') || '').trim().toLowerCase();
4751
+ const prev = (prevAC && prevAC.lang && prevAC.lang !== 'plaintext') ? prevAC.lang.toLowerCase() : '';
4655
4752
 
4656
- const valid = (t) => !!t && t !== 'plaintext' && this._isHLJSSupported(t);
4753
+ const valid = (t) => !!t && t !== 'plaintext' && this._isHLJSSupported(t);
4657
4754
 
4658
- let finalTok = '';
4659
- if (valid(tokNow)) finalTok = tokNow;
4660
- else if (valid(prev)) finalTok = prev;
4661
- else if (valid(sticky)) finalTok = sticky;
4755
+ let finalTok = '';
4756
+ if (valid(tokNow)) finalTok = tokNow;
4757
+ else if (valid(prev)) finalTok = prev;
4758
+ else if (valid(sticky)) finalTok = sticky;
4662
4759
 
4663
- if (finalTok) {
4664
- // Update code class and header label consistently
4665
- this._updateCodeLangClass(newAC.codeEl, finalTok);
4666
- this._updateCodeHeaderLabel(newAC.codeEl, finalTok, finalTok);
4667
- try { wrap.setAttribute('data-code-lang', finalTok); } catch (_) {}
4668
- try { wrap.setAttribute('data-lang-sticky', finalTok); } catch (_) {}
4669
- newAC.lang = finalTok; // keep AC state in sync
4670
- } else {
4671
- // If current label looks like a tiny/incomplete token, normalize to 'code'
4672
- if (span && curLabel && curLabel.length < 3) {
4673
- span.textContent = 'code';
4674
- }
4675
- }
4676
- } catch (_) { /* defensive: never break streaming path */ }
4677
- }
4678
- // Render a snapshot of current stream buffer into the DOM.
4679
- renderSnapshot(msg) {
4680
- const streaming = !!this.isStreaming;
4681
- const snap = this.getMsgSnapshotRoot(msg); if (!snap) return;
4682
-
4683
- // No-op if nothing changed and no active code
4684
- const prevLen = (window.__lastSnapshotLen || 0);
4685
- const curLen = this.getStreamLength();
4686
- if (!this.fenceOpen && !this.activeCode && curLen === prevLen) {
4687
- this.lastSnapshotTs = Utils.now();
4688
- this._d('SNAPSHOT_SKIPPED_NO_DELTA', { bufLen: curLen });
4689
- return;
4760
+ if (finalTok) {
4761
+ this._updateCodeLangClass(newAC.codeEl, finalTok);
4762
+ this._updateCodeHeaderLabel(newAC.codeEl, finalTok, finalTok);
4763
+ try { wrap.setAttribute('data-code-lang', finalTok); } catch (_) {}
4764
+ try { wrap.setAttribute('data-lang-sticky', finalTok); } catch (_) {}
4765
+ newAC.lang = finalTok;
4766
+ } else {
4767
+ if (span && curLabel && curLabel.length < 3) {
4768
+ span.textContent = 'code';
4769
+ }
4690
4770
  }
4771
+ } catch (_) { /* defensive: never break streaming path */ }
4772
+ }
4691
4773
 
4692
- const t0 = Utils.now();
4774
+ renderSnapshot(msg) {
4775
+ const streaming = !!this.isStreaming;
4776
+ const snap = this.getMsgSnapshotRoot(msg); if (!snap) return;
4693
4777
 
4694
- // When an open fence is present, append a synthetic EOL only if the current buffer
4695
- // does not already end with EOL. This stabilizes markdown-it parsing without polluting
4696
- // the real code tail (we will strip this EOL from the active tail right after snapshot).
4697
- const allText = this.getStreamText();
4698
- const needSyntheticEOL = (this.fenceOpen && !/[\r\n]$/.test(allText));
4699
- this._lastInjectedEOL = !!needSyntheticEOL;
4700
- const src = needSyntheticEOL ? (allText + '\n') : allText;
4778
+ const prevLen = (window.__lastSnapshotLen || 0);
4779
+ const curLen = this.getStreamLength();
4780
+ if (!this.fenceOpen && !this.activeCode && curLen === prevLen) {
4781
+ this.lastSnapshotTs = Utils.now();
4782
+ this._d('SNAPSHOT_SKIPPED_NO_DELTA', { bufLen: curLen });
4783
+ return;
4784
+ }
4701
4785
 
4702
- // Use streaming renderer (no linkify) on hot path to reduce CPU/allocs
4703
- const html = streaming ? this.renderer.renderStreamingSnapshot(src) : this.renderer.renderFinalSnapshot(src);
4786
+ const t0 = Utils.now();
4704
4787
 
4705
- // parse HTML into a DocumentFragment directly to avoid intermediate container allocations.
4706
- let frag = null;
4707
- try {
4708
- const range = document.createRange();
4709
- range.selectNodeContents(snap);
4710
- frag = range.createContextualFragment(html);
4711
- } catch (_) {
4712
- const tmp = document.createElement('div');
4713
- tmp.innerHTML = html;
4714
- frag = document.createDocumentFragment();
4715
- while (tmp.firstChild) frag.appendChild(tmp.firstChild);
4716
- }
4788
+ // When an open fence is present, append a synthetic EOL only if the current buffer
4789
+ // does not already end with EOL. This stabilizes markdown-it parsing without polluting
4790
+ // the real code tail (we will strip this EOL from the active tail right after snapshot).
4791
+ const allText = this.getStreamText();
4792
+ const needSyntheticEOL = (this.fenceOpen && !/[\r\n]$/.test(allText));
4793
+ this._lastInjectedEOL = !!needSyntheticEOL;
4794
+ const src = needSyntheticEOL ? (allText + '\n') : allText;
4717
4795
 
4718
- // (stream-aware custom markup):
4719
- // Apply Custom Markup on the fragment only if at least one rule opted-in for stream.
4720
- try {
4721
- if (this.renderer && this.renderer.customMarkup && this.renderer.customMarkup.hasStreamRules()) {
4722
- const MDinline = this.renderer.MD_STREAM || this.renderer.MD || null;
4723
- this.renderer.customMarkup.applyStream(frag, MDinline);
4724
- }
4725
- } catch (_) { /* keep snapshot path resilient */ }
4796
+ const html = streaming ? this.renderer.renderStreamingSnapshot(src) : this.renderer.renderFinalSnapshot(src);
4726
4797
 
4727
- // Reuse closed, stable code blocks from previous snapshot to avoid re-highlighting
4728
- this.preserveStableClosedCodes(snap, frag, this.fenceOpen === true);
4798
+ let frag = null;
4799
+ try {
4800
+ const range = document.createRange();
4801
+ range.selectNodeContents(snap);
4802
+ frag = range.createContextualFragment(html);
4803
+ } catch (_) {
4804
+ const tmp = document.createElement('div');
4805
+ tmp.innerHTML = html;
4806
+ frag = document.createDocumentFragment();
4807
+ while (tmp.firstChild) frag.appendChild(tmp.firstChild);
4808
+ }
4729
4809
 
4730
- // Replace content
4731
- snap.replaceChildren(frag);
4810
+ // (stream-aware custom markup):
4811
+ try {
4812
+ if (this.renderer && this.renderer.customMarkup && this.renderer.customMarkup.hasStreamRules()) {
4813
+ const MDinline = this.renderer.MD_STREAM || this.renderer.MD || null;
4814
+ this.renderer.customMarkup.applyStream(frag, MDinline);
4815
+ }
4816
+ } catch (_) { /* keep snapshot path resilient */ }
4732
4817
 
4733
- // Restore code UI state and ensure bottoming for freshly finalized elements
4734
- this.renderer.restoreCollapsedCode(snap);
4735
- this._ensureBottomForJustFinalized(snap);
4818
+ this.preserveStableClosedCodes(snap, frag, this.fenceOpen === true);
4736
4819
 
4737
- // Setup active streaming code if fence is open, otherwise clear active state
4738
- const prevAC = this.activeCode; // remember previous active streaming state (if any)
4820
+ snap.replaceChildren(frag);
4739
4821
 
4740
- if (this.fenceOpen) {
4741
- const newAC = this.setupActiveCodeFromSnapshot(snap);
4822
+ this.renderer.restoreCollapsedCode(snap);
4823
+ this._ensureBottomForJustFinalized(snap);
4742
4824
 
4743
- // preserve previous frozen/tail state and stable lang/header across snapshots
4744
- if (prevAC && newAC) {
4745
- this.rehydrateActiveCode(prevAC, newAC);
4746
- this.stabilizeHeaderLabel(prevAC, newAC);
4747
- }
4825
+ const prevAC = this.activeCode;
4748
4826
 
4749
- this.activeCode = newAC || null;
4750
- } else {
4751
- this.activeCode = null;
4752
- }
4827
+ if (this.fenceOpen) {
4828
+ const newAC = this.setupActiveCodeFromSnapshot(snap);
4753
4829
 
4754
- // Attach scroll/highlight observers (viewport aware)
4755
- if (!this.fenceOpen) {
4756
- this.codeScroll.initScrollableBlocks(snap);
4830
+ if (prevAC && newAC) {
4831
+ this.rehydrateActiveCode(prevAC, newAC);
4832
+ this.stabilizeHeaderLabel(prevAC, newAC);
4757
4833
  }
4758
- this.highlighter.observeNewCode(snap, {
4834
+
4835
+ this.activeCode = newAC || null;
4836
+ } else {
4837
+ this.activeCode = null;
4838
+ }
4839
+
4840
+ if (!this.fenceOpen) {
4841
+ this.codeScroll.initScrollableBlocks(snap);
4842
+ }
4843
+ this.highlighter.observeNewCode(snap, {
4844
+ deferLastIfStreaming: true,
4845
+ minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
4846
+ minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
4847
+ }, this.activeCode);
4848
+ this.highlighter.observeMsgBoxes(snap, (box) => {
4849
+ this.highlighter.observeNewCode(box, {
4759
4850
  deferLastIfStreaming: true,
4760
4851
  minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
4761
4852
  minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
4762
4853
  }, this.activeCode);
4763
- this.highlighter.observeMsgBoxes(snap, (box) => {
4764
- this.highlighter.observeNewCode(box, {
4765
- deferLastIfStreaming: true,
4766
- minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
4767
- minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
4768
- }, this.activeCode);
4769
- this.codeScroll.initScrollableBlocks(box);
4770
- });
4854
+ this.codeScroll.initScrollableBlocks(box);
4855
+ });
4771
4856
 
4772
- // Schedule math render according to mode; keep "finalize-only" cheap on hot path
4773
- const mm = getMathMode();
4774
- if (!this.suppressPostFinalizePass) {
4775
- if (mm === 'idle') this.math.schedule(snap);
4776
- else if (mm === 'always') this.math.schedule(snap, 0, true);
4777
- }
4857
+ const mm = getMathMode();
4858
+ if (!this.suppressPostFinalizePass) {
4859
+ if (mm === 'idle') this.math.schedule(snap);
4860
+ else if (mm === 'always') this.math.schedule(snap, 0, true);
4861
+ }
4778
4862
 
4779
- // If streaming code is visible, keep it glued to bottom
4780
- if (this.fenceOpen && this.activeCode && this.activeCode.codeEl) {
4781
- this.codeScroll.attachHandlers(this.activeCode.codeEl);
4782
- this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4783
- } else if (!this.fenceOpen) {
4784
- this.codeScroll.initScrollableBlocks(snap);
4785
- }
4863
+ if (this.fenceOpen && this.activeCode && this.activeCode.codeEl) {
4864
+ this.codeScroll.attachHandlers(this.activeCode.codeEl);
4865
+ this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4866
+ } else if (!this.fenceOpen) {
4867
+ this.codeScroll.initScrollableBlocks(snap);
4868
+ }
4786
4869
 
4787
- // Advance snapshot budget and remember progress
4788
- window.__lastSnapshotLen = this.getStreamLength();
4789
- this.lastSnapshotTs = Utils.now();
4870
+ window.__lastSnapshotLen = this.getStreamLength();
4871
+ this.lastSnapshotTs = Utils.now();
4790
4872
 
4791
- const prof = this.profile();
4792
- if (prof.adaptiveStep) {
4793
- const maxStep = this.cfg.STREAM.SNAPSHOT_MAX_STEP || 8000;
4794
- this.nextSnapshotStep = Math.min(Math.ceil(this.nextSnapshotStep * prof.growth), maxStep);
4795
- } else {
4796
- this.nextSnapshotStep = prof.base;
4797
- }
4873
+ const prof = this.profile();
4874
+ if (prof.adaptiveStep) {
4875
+ const maxStep = this.cfg.STREAM.SNAPSHOT_MAX_STEP || 8000;
4876
+ this.nextSnapshotStep = Math.min(Math.ceil(this.nextSnapshotStep * prof.growth), maxStep);
4877
+ } else {
4878
+ this.nextSnapshotStep = prof.base;
4879
+ }
4798
4880
 
4799
- // Keep page scroll/fab in sync
4800
- this.scrollMgr.scheduleScroll(true);
4801
- this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
4802
- this.scrollMgr.scheduleScrollFabUpdate();
4881
+ this.scrollMgr.scheduleScroll(true);
4882
+ this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
4883
+ this.scrollMgr.scheduleScrollFabUpdate();
4803
4884
 
4804
- if (this.suppressPostFinalizePass) this.suppressPostFinalizePass = false;
4885
+ if (this.suppressPostFinalizePass) this.suppressPostFinalizePass = false;
4805
4886
 
4806
- const dt = Utils.now() - t0;
4807
- this._d('SNAPSHOT', { fenceOpen: this.fenceOpen, activeCode: !!this.activeCode, bufLen: this.getStreamLength(), timeMs: Math.round(dt), streaming });
4808
- }
4887
+ const dt = Utils.now() - t0;
4888
+ this._d('SNAPSHOT', { fenceOpen: this.fenceOpen, activeCode: !!this.activeCode, bufLen: this.getStreamLength(), timeMs: Math.round(dt), streaming });
4889
+ }
4809
4890
 
4810
- // Get current message container (.msg) or create if allowed.
4811
- getMsg(create, name_header) { return this.dom.getStreamMsg(create, name_header); }
4812
- // Start a new streaming session (clear state and display loader, if any).
4813
- beginStream(chunk = false) {
4814
- this.isStreaming = true; // engage streaming mode (no linkify etc.)
4815
- if (chunk) runtime.loading.hide();
4816
- this.scrollMgr.userInteracted = false;
4817
- this.dom.clearOutput();
4818
- this.reset();
4819
- this.scrollMgr.forceScrollToBottomImmediate();
4820
- this.scrollMgr.scheduleScroll();
4821
- this._d('BEGIN_STREAM', { chunkFlag: !!chunk });
4891
+ getMsg(create, name_header) { return this.dom.getStreamMsg(create, name_header); }
4892
+ beginStream(chunk = false) {
4893
+ this.isStreaming = true;
4894
+ if (chunk) runtime.loading.hide();
4895
+ this.scrollMgr.userInteracted = false;
4896
+ this.dom.clearOutput();
4897
+ this.reset();
4898
+ this.scrollMgr.forceScrollToBottomImmediate();
4899
+ this.scrollMgr.scheduleScroll();
4900
+ this._d('BEGIN_STREAM', { chunkFlag: !!chunk });
4901
+ }
4902
+ endStream() {
4903
+ this.isStreaming = false;
4904
+
4905
+ const msg = this.getMsg(false, '');
4906
+ if (msg) this.renderSnapshot(msg);
4907
+
4908
+ this.snapshotScheduled = false;
4909
+ try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4910
+ this.snapshotRAF = 0;
4911
+
4912
+ const hadActive = !!this.activeCode;
4913
+ if (this.activeCode) this.finalizeActiveCode();
4914
+
4915
+ if (!hadActive) {
4916
+ if (this.highlighter.hlQueue && this.highlighter.hlQueue.length) {
4917
+ this.highlighter.flush(this.activeCode);
4918
+ }
4919
+ const snap = msg ? this.getMsgSnapshotRoot(msg) : null;
4920
+ if (snap) this.math.renderAsync(snap);
4822
4921
  }
4823
- // End streaming session, finalize active code if present, and complete math/highlight.
4824
- endStream() {
4825
- // Switch to final mode before the last snapshot to allow full renderer (linkify etc.)
4826
- this.isStreaming = false;
4827
4922
 
4828
- const msg = this.getMsg(false, '');
4829
- if (msg) this.renderSnapshot(msg);
4923
+ this.fenceOpen = false; this.codeStream.open = false; this.activeCode = null; this.lastSnapshotTs = Utils.now();
4924
+ this.suppressPostFinalizePass = false;
4925
+ this._d('END_STREAM', { hadActive });
4926
+ }
4927
+
4928
+ // NEW: eager snapshot detection for custom stream openers (e.g., <think>, <tool>)
4929
+ _maybeEagerSnapshotForCustomOpeners(msg, chunkStr) {
4930
+ try {
4931
+ const CM = this.renderer && this.renderer.customMarkup;
4932
+ if (!CM || !CM.hasStreamRules()) return;
4830
4933
 
4831
- this.snapshotScheduled = false;
4832
- try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4833
- this.snapshotRAF = 0;
4934
+ // Do not interfere with code fence streaming.
4935
+ if (this.fenceOpen || this.codeStream.open) return;
4834
4936
 
4835
- const hadActive = !!this.activeCode;
4836
- if (this.activeCode) this.finalizeActiveCode();
4937
+ const isFirstSnapshot = ((window.__lastSnapshotLen || 0) === 0);
4837
4938
 
4838
- if (!hadActive) {
4839
- if (this.highlighter.hlQueue && this.highlighter.hlQueue.length) {
4840
- this.highlighter.flush(this.activeCode);
4939
+ if (isFirstSnapshot) {
4940
+ // Cheap first-chunk check: if stream starts with a custom opener, snapshot immediately.
4941
+ let head;
4942
+ try { head = this.getStreamText(); } catch (_) { head = String(chunkStr || ''); }
4943
+ if (CM.hasStreamOpenerAtStart(head)) {
4944
+ this._d('CM_EAGER_SNAPSHOT_START', { openerAtStart: true });
4945
+ this.scheduleSnapshot(msg, true);
4946
+ return;
4841
4947
  }
4842
- const snap = msg ? this.getMsgSnapshotRoot(msg) : null;
4843
- if (snap) this.math.renderAsync(snap); // ensure math completes eagerly but async
4844
4948
  }
4845
4949
 
4846
- this.fenceOpen = false; this.codeStream.open = false; this.activeCode = null; this.lastSnapshotTs = Utils.now();
4847
- this.suppressPostFinalizePass = false;
4848
- this._d('END_STREAM', { hadActive });
4849
- }
4850
- // Apply incoming chunk to stream buffer and update DOM when needed.
4851
- applyStream(name_header, chunk, alreadyBuffered = false) {
4852
- if (!this.activeCode && !this.fenceOpen) {
4853
- try { if (document.querySelector('pre code[data-_active_stream="1"]')) this.defuseOrphanActiveBlocks(); } catch (_) {}
4950
+ // For later chunks: if this chunk contains any stream opener token, snapshot soon
4951
+ // to let CustomMarkup.applyStreamPartialOpeners extend the pending block.
4952
+ const rules = (CM.getRules() || []).filter(r => r && r.stream && typeof r.open === 'string');
4953
+ if (rules.length && CM.hasAnyOpenToken(String(chunkStr || ''), rules)) {
4954
+ this._d('CM_EAGER_SNAPSHOT_CHUNK', { tokenFound: true, chunkLen: (chunkStr || '').length });
4955
+ this.scheduleSnapshot(msg); // normal (coalesced) schedule is enough
4854
4956
  }
4855
- if (this.snapshotScheduled && !this.raf.isScheduled('SE:snapshot')) this.snapshotScheduled = false;
4957
+ } catch (_) {
4958
+ // Keep streaming resilient; never throw here.
4959
+ }
4960
+ }
4856
4961
 
4857
- const msg = this.getMsg(true, name_header); if (!msg || !chunk) return;
4858
- const s = String(chunk);
4859
- if (!alreadyBuffered) this._appendChunk(s);
4962
+ // Apply incoming chunk to stream buffer and update DOM when needed.
4963
+ applyStream(name_header, chunk, alreadyBuffered = false) {
4964
+ if (!this.activeCode && !this.fenceOpen) {
4965
+ try { if (document.querySelector('pre code[data-_active_stream="1"]')) this.defuseOrphanActiveBlocks(); } catch (_) {}
4966
+ }
4967
+ if (this.snapshotScheduled && !this.raf.isScheduled('SE:snapshot')) this.snapshotScheduled = false;
4860
4968
 
4861
- const change = this.updateFenceHeuristic(s);
4862
- const nlCount = Utils.countNewlines(s); const chunkHasNL = nlCount > 0;
4969
+ const msg = this.getMsg(true, name_header); if (!msg || !chunk) return;
4970
+ const s = String(chunk);
4971
+ if (!alreadyBuffered) this._appendChunk(s);
4863
4972
 
4864
- this._d('APPLY_CHUNK', { len: s.length, nl: nlCount, opened: change.opened, closed: change.closed, splitAt: change.splitAt, fenceOpenBefore: this.fenceOpen || false, codeOpenBefore: this.codeStream.open || false, rebroadcast: !!alreadyBuffered });
4973
+ const change = this.updateFenceHeuristic(s);
4974
+ const nlCount = Utils.countNewlines(s); const chunkHasNL = nlCount > 0;
4865
4975
 
4866
- // Track if we just materialized the first code-open snapshot synchronously.
4867
- let didImmediateOpenSnap = false;
4976
+ this._d('APPLY_CHUNK', { len: s.length, nl: nlCount, opened: change.opened, closed: change.closed, splitAt: change.splitAt, fenceOpenBefore: this.fenceOpen || false, codeOpenBefore: this.codeStream.open || false, rebroadcast: !!alreadyBuffered });
4868
4977
 
4869
- if (change.opened) {
4870
- this.codeStream.open = true; this.codeStream.lines = 0; this.codeStream.chars = 0;
4871
- this.resetBudget();
4872
- this.scheduleSnapshot(msg);
4873
- this._d('CODE_STREAM_OPEN', { });
4978
+ // Eager snapshot for custom stream openers (non-code contexts).
4979
+ // This ensures tags like <think> immediately turn on "pending block" behavior across the rest of the snapshot.
4980
+ if (!change.opened && !this.fenceOpen) {
4981
+ this._maybeEagerSnapshotForCustomOpeners(msg, s);
4982
+ }
4874
4983
 
4875
- // Fast-path: if stream starts with a code fence and no snapshot was made yet,
4876
- // immediately materialize the code block so tail streaming can proceed without click.
4877
- if (!this._firstCodeOpenSnapDone && !this.activeCode && ((window.__lastSnapshotLen || 0) === 0)) {
4878
- try {
4879
- this.renderSnapshot(msg);
4880
- try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4881
- this.snapshotScheduled = false;
4882
- this._firstCodeOpenSnapDone = true;
4883
- didImmediateOpenSnap = true;
4884
- this._d('CODE_OPEN_IMMEDIATE_SNAPSHOT', { bufLen: this.getStreamLength() });
4885
- } catch (_) {
4886
- // Keep going; normal scheduled snapshot will land soon.
4887
- }
4984
+ // Track if we just materialized the first code-open snapshot synchronously.
4985
+ let didImmediateOpenSnap = false;
4986
+
4987
+ if (change.opened) {
4988
+ this.codeStream.open = true; this.codeStream.lines = 0; this.codeStream.chars = 0;
4989
+ this.resetBudget();
4990
+ this.scheduleSnapshot(msg);
4991
+ this._d('CODE_STREAM_OPEN', { });
4992
+
4993
+ if (!this._firstCodeOpenSnapDone && !this.activeCode && ((window.__lastSnapshotLen || 0) === 0)) {
4994
+ try {
4995
+ this.renderSnapshot(msg);
4996
+ try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4997
+ this.snapshotScheduled = false;
4998
+ this._firstCodeOpenSnapDone = true;
4999
+ didImmediateOpenSnap = true;
5000
+ this._d('CODE_OPEN_IMMEDIATE_SNAPSHOT', { bufLen: this.getStreamLength() });
5001
+ } catch (_) {
5002
+ // Normal scheduled snapshot will land soon.
4888
5003
  }
4889
5004
  }
5005
+ }
4890
5006
 
4891
- if (this.codeStream.open) {
4892
- this.codeStream.lines += nlCount; this.codeStream.chars += s.length;
5007
+ if (this.codeStream.open) {
5008
+ this.codeStream.lines += nlCount; this.codeStream.chars += s.length;
4893
5009
 
4894
- if (this.activeCode && this.activeCode.codeEl && this.activeCode.codeEl.isConnected) {
4895
- let partForCode = s; let remainder = '';
5010
+ if (this.activeCode && this.activeCode.codeEl && this.activeCode.codeEl.isConnected) {
5011
+ let partForCode = s; let remainder = '';
4896
5012
 
4897
- if (didImmediateOpenSnap) {
4898
- partForCode = '';
4899
- } else {
4900
- if (change.closed && change.splitAt >= 0 && change.splitAt <= s.length) {
4901
- partForCode = s.slice(0, change.splitAt); remainder = s.slice(change.splitAt);
4902
- }
5013
+ if (didImmediateOpenSnap) {
5014
+ partForCode = '';
5015
+ } else {
5016
+ if (change.closed && change.splitAt >= 0 && change.splitAt <= s.length) {
5017
+ partForCode = s.slice(0, change.splitAt); remainder = s.slice(change.splitAt);
4903
5018
  }
5019
+ }
4904
5020
 
4905
- if (partForCode) {
4906
- this.appendToActiveTail(partForCode);
4907
- this.activeCode.lines += Utils.countNewlines(partForCode);
5021
+ if (partForCode) {
5022
+ this.appendToActiveTail(partForCode);
5023
+ this.activeCode.lines += Utils.countNewlines(partForCode);
4908
5024
 
4909
- this.maybePromoteLanguageFromDirective();
4910
- this.enforceHLStopBudget();
5025
+ this.maybePromoteLanguageFromDirective();
5026
+ this.enforceHLStopBudget();
4911
5027
 
4912
- if (!this.activeCode.haltHL) {
4913
- if (partForCode.indexOf('\n') >= 0 || (this.activeCode.tailEl.textContent || '').length >= this.cfg.PROFILE_CODE.minCharsForHL) {
4914
- this.schedulePromoteTail(false);
4915
- }
5028
+ if (!this.activeCode.haltHL) {
5029
+ if (partForCode.indexOf('\n') >= 0 || (this.activeCode.tailEl.textContent || '').length >= this.cfg.PROFILE_CODE.minCharsForHL) {
5030
+ this.schedulePromoteTail(false);
4916
5031
  }
4917
5032
  }
4918
- this.scrollMgr.scrollFabUpdateScheduled = false;
4919
- this.scrollMgr.scheduleScroll(true);
4920
- this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
4921
- this.scrollMgr.scheduleScrollFabUpdate();
4922
-
4923
- if (change.closed) {
4924
- this.finalizeActiveCode();
4925
- this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
4926
- this._d('CODE_STREAM_CLOSE_FINALIZED', { remainderLen: remainder.length });
4927
- if (remainder && remainder.length) { this.applyStream(name_header, remainder, true); }
4928
- }
5033
+ }
5034
+ this.scrollMgr.scrollFabUpdateScheduled = false;
5035
+ this.scrollMgr.scheduleScroll(true);
5036
+ this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
5037
+ this.scrollMgr.scheduleScrollFabUpdate();
5038
+
5039
+ if (change.closed) {
5040
+ this.finalizeActiveCode();
5041
+ this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
5042
+ this._d('CODE_STREAM_CLOSE_FINALIZED', { remainderLen: remainder.length });
5043
+ if (remainder && remainder.length) { this.applyStream(name_header, remainder, true); }
5044
+ }
5045
+ return;
5046
+ } else {
5047
+ if (!this.activeCode && (this.codeStream.lines >= 2 || this.codeStream.chars >= 80)) {
5048
+ this.scheduleSnapshot(msg, true);
4929
5049
  return;
5050
+ }
5051
+ if (change.closed) {
5052
+ this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
5053
+ this._d('CODE_CLOSED_WITHOUT_ACTIVE', { sinceLastSnapMs: Math.round(Utils.now() - this.lastSnapshotTs), snapshotScheduled: this.snapshotScheduled });
4930
5054
  } else {
4931
- if (!this.activeCode && (this.codeStream.lines >= 2 || this.codeStream.chars >= 80)) {
4932
- this.scheduleSnapshot(msg, true);
4933
- return;
4934
- }
4935
- if (change.closed) {
4936
- this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
4937
- this._d('CODE_CLOSED_WITHOUT_ACTIVE', { sinceLastSnapMs: Math.round(Utils.now() - this.lastSnapshotTs), snapshotScheduled: this.snapshotScheduled });
4938
- } else {
4939
- const boundary = this.hasStructuralBoundary(s);
4940
- if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) this.scheduleSnapshot(msg);
4941
- else this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
4942
- }
4943
- return;
5055
+ const boundary = this.hasStructuralBoundary(s);
5056
+ if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) this.scheduleSnapshot(msg);
5057
+ else this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
4944
5058
  }
5059
+ return;
4945
5060
  }
5061
+ }
4946
5062
 
4947
- if (change.closed) {
4948
- this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
4949
- this._d('CODE_STREAM_CLOSE', { });
5063
+ if (change.closed) {
5064
+ this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
5065
+ this._d('CODE_STREAM_CLOSE', { });
5066
+ } else {
5067
+ const boundary = this.hasStructuralBoundary(s);
5068
+ if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) {
5069
+ this.scheduleSnapshot(msg);
5070
+ this._d('SCHEDULE_SNAPSHOT_BOUNDARY', { boundary });
4950
5071
  } else {
4951
- const boundary = this.hasStructuralBoundary(s);
4952
- if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) {
4953
- this.scheduleSnapshot(msg);
4954
- this._d('SCHEDULE_SNAPSHOT_BOUNDARY', { boundary });
4955
- } else {
4956
- this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
4957
- }
5072
+ this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
4958
5073
  }
4959
5074
  }
4960
5075
  }
5076
+ }
4961
5077
 
4962
5078
  // ==========================================================================
4963
5079
  // 12) Stream queue