pygpt-net 2.6.46__py3-none-any.whl → 2.6.48__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 (40) hide show
  1. pygpt_net/CHANGELOG.txt +9 -0
  2. pygpt_net/__init__.py +1 -1
  3. pygpt_net/app.py +5 -0
  4. pygpt_net/app_core.py +39 -39
  5. pygpt_net/controller/__init__.py +72 -64
  6. pygpt_net/controller/audio/audio.py +2 -0
  7. pygpt_net/controller/chat/text.py +2 -1
  8. pygpt_net/controller/ctx/common.py +0 -7
  9. pygpt_net/controller/ctx/ctx.py +172 -6
  10. pygpt_net/controller/ctx/extra.py +3 -3
  11. pygpt_net/controller/notepad/notepad.py +0 -2
  12. pygpt_net/controller/settings/editor.py +3 -1
  13. pygpt_net/controller/theme/common.py +8 -2
  14. pygpt_net/controller/theme/theme.py +5 -5
  15. pygpt_net/controller/ui/tabs.py +9 -2
  16. pygpt_net/core/ctx/ctx.py +79 -26
  17. pygpt_net/core/render/web/renderer.py +2 -0
  18. pygpt_net/core/tabs/tab.py +2 -2
  19. pygpt_net/core/tabs/tabs.py +57 -10
  20. pygpt_net/data/config/config.json +2 -2
  21. pygpt_net/data/config/models.json +2 -2
  22. pygpt_net/data/css/web-blocks.css +256 -270
  23. pygpt_net/data/css/web-chatgpt.css +276 -301
  24. pygpt_net/data/css/web-chatgpt_wide.css +286 -294
  25. pygpt_net/data/js/app.js +1218 -1186
  26. pygpt_net/js_rc.py +14192 -14641
  27. pygpt_net/provider/core/config/patch.py +9 -0
  28. pygpt_net/provider/core/ctx/db_sqlite/storage.py +19 -5
  29. pygpt_net/ui/__init__.py +9 -14
  30. pygpt_net/ui/layout/chat/chat.py +2 -2
  31. pygpt_net/ui/layout/ctx/ctx_list.py +71 -1
  32. pygpt_net/ui/widget/lists/base.py +32 -1
  33. pygpt_net/ui/widget/lists/context.py +45 -2
  34. pygpt_net/ui/widget/tabs/body.py +11 -3
  35. pygpt_net/ui/widget/textarea/notepad.py +0 -4
  36. {pygpt_net-2.6.46.dist-info → pygpt_net-2.6.48.dist-info}/METADATA +11 -2
  37. {pygpt_net-2.6.46.dist-info → pygpt_net-2.6.48.dist-info}/RECORD +40 -40
  38. {pygpt_net-2.6.46.dist-info → pygpt_net-2.6.48.dist-info}/LICENSE +0 -0
  39. {pygpt_net-2.6.46.dist-info → pygpt_net-2.6.48.dist-info}/WHEEL +0 -0
  40. {pygpt_net-2.6.46.dist-info → pygpt_net-2.6.48.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
@@ -1220,8 +1224,6 @@
1220
1224
  _d(line, ctx) { try { this.logger.debug('CM', line, ctx); } catch (_) {} }
1221
1225
 
1222
1226
  // Decode HTML entities once (safe)
1223
- // This addresses cases when linkify/full markdown path leaves literal "&quot;" etc. in text nodes.
1224
- // We decode only for rules that explicitly opt-in (see compile()) to avoid changing semantics globally.
1225
1227
  decodeEntitiesOnce(s) {
1226
1228
  if (!s || s.indexOf('&') === -1) return String(s || '');
1227
1229
  const ta = CustomMarkup._decTA || (CustomMarkup._decTA = document.createElement('textarea'));
@@ -1229,14 +1231,22 @@
1229
1231
  return ta.value;
1230
1232
  }
1231
1233
 
1232
- // Small helper: escape text to safe HTML (shared Utils or fallback)
1234
+ // Escape helpers
1233
1235
  _escHtml(s) {
1234
1236
  try { return Utils.escapeHtml(s); } catch (_) {
1235
1237
  return String(s || '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
1236
1238
  }
1237
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
+ }
1238
1249
 
1239
- // quick check if any rule's open token is present in text (used to skip expensive work early)
1240
1250
  hasAnyOpenToken(text, rules) {
1241
1251
  if (!text || !rules || !rules.length) return false;
1242
1252
  for (let i = 0; i < rules.length; i++) {
@@ -1247,19 +1257,25 @@
1247
1257
  return false;
1248
1258
  }
1249
1259
 
1250
- // Build inner HTML from text according to rule's mode (markdown-inline | text) with optional entity decode.
1251
1260
  _materializeInnerHTML(rule, text, MD) {
1252
1261
  let payload = String(text || '');
1262
+ const wantsBr = !!(rule && (rule.nl2br || rule.allowBr));
1263
+
1253
1264
  if (rule && rule.decodeEntities && payload && payload.indexOf('&') !== -1) {
1254
1265
  try { payload = this.decodeEntitiesOnce(payload); } catch (_) { /* keep original */ }
1255
1266
  }
1267
+
1268
+ if (wantsBr) {
1269
+ try { return this._escapeHtmlAllowBr(payload, { convertNewlines: !!rule.nl2br }); }
1270
+ catch (_) { return this._escHtml(payload); }
1271
+ }
1272
+
1256
1273
  if (rule && rule.innerMode === 'markdown-inline' && MD && typeof MD.renderInline === 'function') {
1257
1274
  try { return MD.renderInline(payload); } catch (_) { return this._escHtml(payload); }
1258
1275
  }
1259
1276
  return this._escHtml(payload);
1260
1277
  }
1261
1278
 
1262
- // Make a DOM Fragment from HTML string (robust across contexts).
1263
1279
  _fragmentFromHTML(html, ctxNode) {
1264
1280
  let frag = null;
1265
1281
  try {
@@ -1276,18 +1292,14 @@
1276
1292
  return frag;
1277
1293
  }
1278
1294
  }
1279
-
1280
- // Replace one element in DOM with HTML string (keeps siblings intact).
1281
1295
  _replaceElementWithHTML(el, html) {
1282
1296
  if (!el || !el.parentNode) return;
1283
1297
  const parent = el.parentNode;
1284
1298
  const frag = this._fragmentFromHTML(html, el);
1285
1299
  try {
1286
- // Insert new nodes before the old element, then remove the old element (widely supported).
1287
1300
  parent.insertBefore(frag, el);
1288
1301
  parent.removeChild(el);
1289
1302
  } catch (_) {
1290
- // Conservative fallback: wrap in a span if direct fragment insertion failed for some reason.
1291
1303
  const tmp = document.createElement('span');
1292
1304
  tmp.innerHTML = String(html || '');
1293
1305
  while (tmp.firstChild) parent.insertBefore(tmp.firstChild, el);
@@ -1295,7 +1307,6 @@
1295
1307
  }
1296
1308
  }
1297
1309
 
1298
- // Compile rules once; also precompile strict and whitespace-tolerant "full match" regexes.
1299
1310
  compile(rules) {
1300
1311
  const src = Array.isArray(rules) ? rules : (window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES || []);
1301
1312
  const compiled = [];
@@ -1312,18 +1323,12 @@
1312
1323
  const openReplace = String((r.openReplace != null ? r.openReplace : (r.openReplace || '')) || '');
1313
1324
  const closeReplace = String((r.closeReplace != null ? r.closeReplace : (r.closeReplace || '')) || '');
1314
1325
 
1315
- // Back-compat: decode entities default true for cmd-like
1316
1326
  const decodeEntities = (typeof r.decodeEntities === 'boolean')
1317
1327
  ? r.decodeEntities
1318
1328
  : ((r.name || '').toLowerCase() === 'cmd' || className === 'cmd');
1319
1329
 
1320
- // Optional application phase (where replacement should happen)
1321
- // - 'source' => before markdown-it
1322
- // - 'html' => after markdown-it (DOM fragment)
1323
- // - 'both'
1324
1330
  let phaseRaw = (typeof r.phase === 'string') ? r.phase.toLowerCase() : '';
1325
1331
  if (phaseRaw !== 'source' && phaseRaw !== 'html' && phaseRaw !== 'both') phaseRaw = '';
1326
- // Heuristic: if replacement contains fenced code backticks, default to 'source'
1327
1332
  const looksLikeFence = (openReplace.indexOf('```') !== -1) || (closeReplace.indexOf('```') !== -1);
1328
1333
  const phase = phaseRaw || (looksLikeFence ? 'source' : 'html');
1329
1334
 
@@ -1331,6 +1336,9 @@
1331
1336
  const reFull = new RegExp('^' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '$');
1332
1337
  const reFullTrim = new RegExp('^\\s*' + Utils.reEscape(r.open) + '([\\s\\S]*?)' + Utils.reEscape(r.close) + '\\s*$');
1333
1338
 
1339
+ const nl2br = !!r.nl2br;
1340
+ const allowBr = !!r.allowBr;
1341
+
1334
1342
  const item = {
1335
1343
  name: r.name || tag,
1336
1344
  tag, className, innerMode,
@@ -1339,12 +1347,13 @@
1339
1347
  re, reFull, reFullTrim,
1340
1348
  stream,
1341
1349
  openReplace, closeReplace,
1342
- phase, // where this rule should be applied
1343
- isSourceFence: looksLikeFence // hints StreamEngine to treat as custom fence
1350
+ phase,
1351
+ isSourceFence: looksLikeFence,
1352
+ nl2br, allowBr
1344
1353
  };
1345
1354
  compiled.push(item);
1346
1355
  if (stream) hasStream = true;
1347
- this._d('COMPILE_RULE', { name: item.name, phase: item.phase, stream: item.stream });
1356
+ this._d('COMPILE_RULE', { name: item.name, phase: item.phase, stream: item.stream, nl2br: item.nl2br, allowBr: item.allowBr });
1348
1357
  }
1349
1358
 
1350
1359
  if (compiled.length === 0) {
@@ -1357,7 +1366,8 @@
1357
1366
  reFullTrim: new RegExp('^\\s*' + Utils.reEscape(open) + '([\\s\\S]*?)' + Utils.reEscape(close) + '\\s*$'),
1358
1367
  stream: false,
1359
1368
  openReplace: '', closeReplace: '',
1360
- phase: 'html', isSourceFence: false
1369
+ phase: 'html', isSourceFence: false,
1370
+ nl2br: false, allowBr: false
1361
1371
  };
1362
1372
  compiled.push(item);
1363
1373
  this._d('COMPILE_RULE_FALLBACK', { name: item.name });
@@ -1367,16 +1377,12 @@
1367
1377
  return compiled;
1368
1378
  }
1369
1379
 
1370
- // pre-markdown source transformer – applies only rules for 'source'/'both' with replacements
1371
- // - Skips replacements inside fenced code blocks (``` / ~~~).
1372
- // - Applies only when the rule opener is at top-level of the line (no list markers/blockquote).
1373
1380
  transformSource(src, opts) {
1374
1381
  let s = String(src || '');
1375
1382
  this.ensureCompiled();
1376
1383
  const rules = this.__compiled;
1377
1384
  if (!rules || !rules.length) return s;
1378
1385
 
1379
- // Pick only source-phase rules with explicit replacements
1380
1386
  const candidates = [];
1381
1387
  for (let i = 0; i < rules.length; i++) {
1382
1388
  const r = rules[i];
@@ -1385,14 +1391,11 @@
1385
1391
  }
1386
1392
  if (!candidates.length) return s;
1387
1393
 
1388
- // Compute fenced-code ranges once to exclude them from replacements (production-safe).
1389
1394
  const fences = this._findFenceRanges(s);
1390
1395
  if (!fences.length) {
1391
- // No code fences in source; apply top-level guarded replacements globally.
1392
1396
  return this._applySourceReplacementsInChunk(s, s, 0, candidates);
1393
1397
  }
1394
1398
 
1395
- // Apply replacements only in segments outside fenced code.
1396
1399
  let out = '';
1397
1400
  let last = 0;
1398
1401
  for (let k = 0; k < fences.length; k++) {
@@ -1401,7 +1404,7 @@
1401
1404
  const chunk = s.slice(last, a);
1402
1405
  out += this._applySourceReplacementsInChunk(s, chunk, last, candidates);
1403
1406
  }
1404
- out += s.slice(a, b); // pass fenced code verbatim
1407
+ out += s.slice(a, b);
1405
1408
  last = b;
1406
1409
  }
1407
1410
  if (last < s.length) {
@@ -1411,7 +1414,6 @@
1411
1414
  return out;
1412
1415
  }
1413
1416
 
1414
- // expose custom fence specs (to StreamEngine)
1415
1417
  getSourceFenceSpecs() {
1416
1418
  this.ensureCompiled();
1417
1419
  const rules = this.__compiled || [];
@@ -1419,14 +1421,12 @@
1419
1421
  for (let i = 0; i < rules.length; i++) {
1420
1422
  const r = rules[i];
1421
1423
  if (!r || !r.isSourceFence) continue;
1422
- // Only expose when they actually look like fences in source phase
1423
1424
  if (r.phase !== 'source' && r.phase !== 'both') continue;
1424
1425
  out.push({ open: r.open, close: r.close });
1425
1426
  }
1426
1427
  return out;
1427
1428
  }
1428
1429
 
1429
- // Ensure rules are compiled and cached.
1430
1430
  ensureCompiled() {
1431
1431
  if (!this.__compiled) {
1432
1432
  this.__compiled = this.compile(window.CUSTOM_MARKUP_RULES || this.cfg.CUSTOM_MARKUP_RULES);
@@ -1435,14 +1435,11 @@
1435
1435
  return this.__compiled;
1436
1436
  }
1437
1437
 
1438
- // Replace rules set (also exposes rules on window).
1439
1438
  setRules(rules) {
1440
1439
  this.__compiled = this.compile(rules);
1441
1440
  window.CUSTOM_MARKUP_RULES = Array.isArray(rules) ? rules.slice() : (this.cfg.CUSTOM_MARKUP_RULES || []).slice();
1442
1441
  this._d('SET_RULES', { count: this.__compiled.length, hasStream: this.__hasStreamRules });
1443
1442
  }
1444
-
1445
- // Return current rules as array.
1446
1443
  getRules() {
1447
1444
  const list = (window.CUSTOM_MARKUP_RULES ? window.CUSTOM_MARKUP_RULES.slice()
1448
1445
  : (this.cfg.CUSTOM_MARKUP_RULES || []).slice());
@@ -1450,25 +1447,34 @@
1450
1447
  return list;
1451
1448
  }
1452
1449
 
1453
- // Fast switch: do we have any rules that want streaming parsing?
1454
1450
  hasStreamRules() {
1455
1451
  this.ensureCompiled();
1456
1452
  return !!this.__hasStreamRules;
1457
1453
  }
1458
1454
 
1459
- // Context guards
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
+
1460
1469
  isInsideForbiddenContext(node) {
1461
1470
  const p = node.parentElement; if (!p) return true;
1462
- // IMPORTANT: exclude code/math/hljs/wrappers AND list contexts (ul/ol/li/dl/dt/dd)
1463
1471
  return !!p.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper, ul, ol, li, dl, dt, dd');
1464
1472
  }
1465
1473
  isInsideForbiddenElement(el) {
1466
1474
  if (!el) return true;
1467
- // IMPORTANT: exclude code/math/hljs/wrappers AND list contexts (ul/ol/li/dl/dt/dd)
1468
1475
  return !!el.closest('pre, code, kbd, samp, var, script, style, textarea, .math-pending, .hljs, .code-wrapper, ul, ol, li, dl, dt, dd');
1469
1476
  }
1470
1477
 
1471
- // Global finder on a single text blob (original per-text-node logic).
1472
1478
  findNextMatch(text, from, rules) {
1473
1479
  let best = null;
1474
1480
  for (const rule of rules) {
@@ -1481,8 +1487,6 @@
1481
1487
  }
1482
1488
  return best;
1483
1489
  }
1484
-
1485
- // Strict full match of a pure text node (legacy path).
1486
1490
  findFullMatch(text, rules) {
1487
1491
  for (const rule of rules) {
1488
1492
  if (rule.reFull) {
@@ -1500,13 +1504,19 @@
1500
1504
  return null;
1501
1505
  }
1502
1506
 
1503
- // Set inner content according to the rule's mode, with optional entity decode (element mode).
1504
- setInnerByMode(el, mode, text, MD, decodeEntities = false) {
1507
+ setInnerByMode(el, mode, text, MD, decodeEntities = false, rule = null) {
1505
1508
  let payload = String(text || '');
1509
+ const wantsBr = !!(rule && (rule.nl2br || rule.allowBr));
1510
+
1506
1511
  if (decodeEntities && payload && payload.indexOf('&') !== -1) {
1507
1512
  try { payload = this.decodeEntitiesOnce(payload); } catch (_) {}
1508
1513
  }
1509
1514
 
1515
+ if (wantsBr) {
1516
+ el.innerHTML = this._escapeHtmlAllowBr(payload, { convertNewlines: !!(rule && rule.nl2br) });
1517
+ return;
1518
+ }
1519
+
1510
1520
  if (mode === 'markdown-inline' && typeof window.markdownit !== 'undefined') {
1511
1521
  try {
1512
1522
  if (MD && typeof MD.renderInline === 'function') { el.innerHTML = MD.renderInline(payload); return; }
@@ -1517,7 +1527,6 @@
1517
1527
  el.textContent = payload;
1518
1528
  }
1519
1529
 
1520
- // Try to replace an entire <p> that is a full custom markup match.
1521
1530
  _tryReplaceFullParagraph(el, rules, MD) {
1522
1531
  if (!el || el.tagName !== 'P') return false;
1523
1532
  if (this.isInsideForbiddenElement(el)) {
@@ -1533,8 +1542,7 @@
1533
1542
  if (!m) continue;
1534
1543
 
1535
1544
  const innerText = m[1] || '';
1536
-
1537
- if (rule.phase !== 'html' && rule.phase !== 'both') continue; // element materialization is html-phase only
1545
+ if (rule.phase !== 'html' && rule.phase !== 'both') continue;
1538
1546
 
1539
1547
  if (rule.openReplace || rule.closeReplace) {
1540
1548
  const innerHTML = this._materializeInnerHTML(rule, innerText, MD);
@@ -1548,7 +1556,7 @@
1548
1556
  const out = document.createElement(outTag === 'p' ? 'p' : outTag);
1549
1557
  if (rule.className) out.className = rule.className;
1550
1558
  out.setAttribute('data-cm', rule.name);
1551
- this.setInnerByMode(out, rule.innerMode, innerText, MD, !!rule.decodeEntities);
1559
+ this.setInnerByMode(out, rule.innerMode, innerText, MD, !!rule.decodeEntities, rule);
1552
1560
 
1553
1561
  try { el.replaceWith(out); } catch (_) {
1554
1562
  const par = el.parentNode; if (par) par.replaceChild(out, el);
@@ -1560,7 +1568,6 @@
1560
1568
  return false;
1561
1569
  }
1562
1570
 
1563
- // Core implementation shared by static and streaming passes.
1564
1571
  applyRules(root, MD, rules) {
1565
1572
  if (!root || !rules || !rules.length) return;
1566
1573
 
@@ -1577,7 +1584,6 @@
1577
1584
  if (p && p.getAttribute && p.getAttribute('data-cm')) continue;
1578
1585
  const tc = p && (p.textContent || '');
1579
1586
  if (!tc || !this.hasAnyOpenToken(tc, rules)) continue;
1580
- // Skip paragraphs inside forbidden contexts (includes lists now)
1581
1587
  if (this.isInsideForbiddenElement(p)) continue;
1582
1588
  this._tryReplaceFullParagraph(p, rules, MD);
1583
1589
  }
@@ -1586,7 +1592,7 @@
1586
1592
  this._d('P_TOLERANT_SCAN_ERR', String(e));
1587
1593
  }
1588
1594
 
1589
- // Phase 2: legacy per-text-node pass for partial inline cases.
1595
+ // Phase 2: per-text-node inline replacements
1590
1596
  const self = this;
1591
1597
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
1592
1598
  acceptNode: (node) => {
@@ -1600,14 +1606,12 @@
1600
1606
  let node;
1601
1607
  while ((node = walker.nextNode())) {
1602
1608
  const text = node.nodeValue;
1603
- if (!text || !this.hasAnyOpenToken(text, rules)) continue; // quick skip
1609
+ if (!text || !this.hasAnyOpenToken(text, rules)) continue;
1604
1610
  const parent = node.parentElement;
1605
1611
 
1606
- // Entire text node equals one full match and parent is <p>.
1607
1612
  if (parent && parent.tagName === 'P' && parent.childNodes.length === 1) {
1608
1613
  const fm = this.findFullMatch(text, rules);
1609
1614
  if (fm) {
1610
- // If explicit HTML replacements are provided, swap <p> for exact HTML (only for html/both phase).
1611
1615
  if ((fm.rule.phase === 'html' || fm.rule.phase === 'both') && (fm.rule.openReplace || fm.rule.closeReplace)) {
1612
1616
  const innerHTML = this._materializeInnerHTML(fm.rule, fm.inner, MD);
1613
1617
  const html = String(fm.rule.openReplace || '') + innerHTML + String(fm.rule.closeReplace || '');
@@ -1615,13 +1619,11 @@
1615
1619
  this._d('WALKER_FULL_REPLACE_HTML', { rule: fm.rule.name, preview: this.logger.pv(text, 160) });
1616
1620
  continue;
1617
1621
  }
1618
-
1619
- // Backward-compatible: only replace as <p> when rule tag is 'p'
1620
1622
  if (fm.rule.tag === 'p') {
1621
1623
  const out = document.createElement('p');
1622
1624
  if (fm.rule.className) out.className = fm.rule.className;
1623
1625
  out.setAttribute('data-cm', fm.rule.name);
1624
- this.setInnerByMode(out, fm.rule.innerMode, fm.inner, MD, !!fm.rule.decodeEntities);
1626
+ this.setInnerByMode(out, fm.rule.innerMode, fm.inner, MD, !!fm.rule.decodeEntities, fm.rule);
1625
1627
  try { parent.replaceWith(out); } catch (_) {
1626
1628
  const par = parent.parentNode; if (par) par.replaceChild(out, parent);
1627
1629
  }
@@ -1631,7 +1633,6 @@
1631
1633
  }
1632
1634
  }
1633
1635
 
1634
- // General inline replacement inside the text node (span-like or HTML-replace).
1635
1636
  let i = 0;
1636
1637
  let didReplace = false;
1637
1638
  const frag = document.createDocumentFragment();
@@ -1644,7 +1645,6 @@
1644
1645
  frag.appendChild(document.createTextNode(text.slice(i, m.start)));
1645
1646
  }
1646
1647
 
1647
- // If HTML replacements are provided, build exact HTML around processed inner – only for html/both phase.
1648
1648
  if ((m.rule.openReplace || m.rule.closeReplace) && (m.rule.phase === 'html' || m.rule.phase === 'both')) {
1649
1649
  const innerHTML = this._materializeInnerHTML(m.rule, m.inner, MD);
1650
1650
  const html = String(m.rule.openReplace || '') + innerHTML + String(m.rule.closeReplace || '');
@@ -1654,20 +1654,17 @@
1654
1654
  i = m.end; didReplace = true; continue;
1655
1655
  }
1656
1656
 
1657
- // If rule is not html-phase, do NOT inject open/close replacements here (source-only rules are handled pre-md).
1658
1657
  if (m.rule.openReplace || m.rule.closeReplace) {
1659
- // Source-only replacement met in DOM pass – keep original text verbatim for this match.
1660
1658
  frag.appendChild(document.createTextNode(text.slice(m.start, m.end)));
1661
1659
  this._d('WALKER_INLINE_SKIP_SOURCE_PHASE_HTML', { rule: m.rule.name, start: m.start, end: m.end });
1662
1660
  i = m.end; didReplace = true; continue;
1663
1661
  }
1664
1662
 
1665
- // Element-based inline replacement (original behavior).
1666
1663
  const tag = (m.rule.tag === 'p') ? 'span' : m.rule.tag;
1667
1664
  const el = document.createElement(tag);
1668
1665
  if (m.rule.className) el.className = m.rule.className;
1669
1666
  el.setAttribute('data-cm', m.rule.name);
1670
- this.setInnerByMode(el, m.rule.innerMode, m.inner, MD, !!m.rule.decodeEntities);
1667
+ this.setInnerByMode(el, m.rule.innerMode, m.inner, MD, !!m.rule.decodeEntities, m.rule);
1671
1668
  frag.appendChild(el);
1672
1669
  this._d('WALKER_INLINE_MATCH', { rule: m.rule.name, start: m.start, end: m.end });
1673
1670
 
@@ -1676,10 +1673,7 @@
1676
1673
  }
1677
1674
 
1678
1675
  if (!didReplace) continue;
1679
-
1680
- if (i < text.length) {
1681
- frag.appendChild(document.createTextNode(text.slice(i)));
1682
- }
1676
+ if (i < text.length) frag.appendChild(document.createTextNode(text.slice(i)));
1683
1677
 
1684
1678
  const parentNode = node.parentNode;
1685
1679
  if (parentNode) {
@@ -1689,42 +1683,35 @@
1689
1683
  }
1690
1684
  }
1691
1685
 
1692
- // Public API: apply custom markup for full (static) paths – unchanged behavior.
1686
+ // Public API: full pass (static)
1693
1687
  apply(root, MD) {
1694
1688
  this.ensureCompiled();
1695
1689
  this.applyRules(root, MD, this.__compiled);
1696
1690
  }
1697
1691
 
1698
- // Public API: apply only stream-enabled rules (used in snapshots).
1692
+ // Public API: stream pass (stream-enabled rules only)
1699
1693
  applyStream(root, MD) {
1700
1694
  this.ensureCompiled();
1701
1695
  if (!this.__hasStreamRules) return;
1702
1696
  const rules = this.__compiled.filter(r => !!r.stream);
1703
1697
  if (!rules.length) return;
1704
1698
 
1705
- // Existing full open+close pass
1699
+ // 1) Normal html-phase replacements where both tokens are within one node/paragraph
1706
1700
  this.applyRules(root, MD, rules);
1707
1701
 
1708
- // streaming-only partial opener fallback (begin materialization on open without close)
1702
+ // 2) If only opener is present, start pending block and wrap the remainder of the snapshot
1709
1703
  try { this.applyStreamPartialOpeners(root, MD, rules); } catch (_) {}
1710
- }
1711
1704
 
1712
- // -----------------------------
1713
- // INTERNAL HELPERS
1714
- // -----------------------------
1705
+ // 3) If a pending block exists and we can see a closer now – finalize immediately
1706
+ try { this.applyStreamFinalizeClosers(root, rules); } catch (_) {}
1707
+ }
1715
1708
 
1716
- // Streaming-only: begin replacement when an opening token is present without a closing token
1717
- // in the SAME text node. We wrap the tail after the opener into the target element and mark
1718
- // it as pending (data-cm-pending="1"). On subsequent snapshots (when a close arrives),
1719
- // the standard full-pass (applyRules) will supersede this.
1709
+ // Streaming: begin pending wrapper when opener is unmatched in the same text node.
1720
1710
  applyStreamPartialOpeners(root, MD, rulesAll) {
1721
1711
  if (!root) return;
1722
1712
 
1723
- // Consider only DOM-phase element rules (html/both) without explicit HTML replacements.
1724
- // Source-phase rules (e.g. exec fences) are intentionally excluded here to avoid changing
1725
- // code streaming semantics (handled by transformSource/StreamEngine).
1726
1713
  const rules = (rulesAll || []).filter(r =>
1727
- (r && (r.phase === 'html' || r.phase === 'both') && !(r.openReplace || r.closeReplace))
1714
+ (r && (r.phase === 'html' || r.phase === 'both') && !(r.openReplace || r.closeReplace) && r.open && r.close)
1728
1715
  );
1729
1716
  if (!rules.length) return;
1730
1717
 
@@ -1745,18 +1732,14 @@
1745
1732
  const text = node.nodeValue || '';
1746
1733
  if (!text) continue;
1747
1734
 
1748
- // Find the last unmatched opener among eligible rules in this node.
1749
1735
  let best = null; // { rule, start }
1750
1736
  for (let i = 0; i < rules.length; i++) {
1751
1737
  const r = rules[i];
1752
1738
  if (!r || !r.open || !r.close) continue;
1753
1739
 
1754
- // Find last occurrence for better UX on multiple openers; keeps earlier content intact.
1755
1740
  const idx = text.lastIndexOf(r.open);
1756
1741
  if (idx === -1) continue;
1757
1742
 
1758
- // If a closing token for this rule exists after this opener within the same node,
1759
- // let the standard pass handle it (we only want truly unmatched opens).
1760
1743
  const after = text.indexOf(r.close, idx + r.open.length);
1761
1744
  if (after !== -1) continue;
1762
1745
 
@@ -1765,37 +1748,144 @@
1765
1748
 
1766
1749
  if (!best) continue;
1767
1750
 
1768
- // Build fragment: keep prefix as text, wrap the tail into the rule element and mark as pending.
1769
1751
  const r = best.rule;
1770
1752
  const start = best.start;
1753
+ const openLen = r.open.length;
1754
+ const prefixText = text.slice(0, start);
1755
+ const fromOffset = start + openLen;
1771
1756
 
1772
- const prefix = text.slice(0, start);
1773
- const tail = text.slice(start + r.open.length);
1774
- const frag = document.createDocumentFragment();
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);
1775
1770
 
1776
- if (prefix) frag.appendChild(document.createTextNode(prefix));
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;
1777
1816
 
1778
- const outTag = (r.tag && typeof r.tag === 'string') ? r.tag.toLowerCase() : 'span';
1779
- const el = document.createElement(outTag === 'p' ? 'span' : outTag); // never create <p> inline
1780
- if (r.className) el.className = r.className;
1781
- el.setAttribute('data-cm', r.name);
1782
- el.setAttribute('data-cm-pending', '1'); // allows styling/debug on "open-but-not-closed"
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
+ });
1783
1840
 
1784
- // Populate inner according to innerMode and decode policy.
1785
- this.setInnerByMode(el, r.innerMode, tail, MD, !!r.decodeEntities);
1786
- frag.appendChild(el);
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
1787
1849
 
1788
1850
  try {
1789
- node.parentNode.replaceChild(frag, node);
1790
- this._d('STREAM_PENDING_OPEN_WRAP', { rule: r.name, start, open: r.open, preview: this.logger.pv(text, 160) });
1791
- } catch (_) {
1792
- // In the worst case, do nothing – keep original text node untouched.
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).
1793
1884
  }
1794
1885
  }
1795
1886
  }
1796
1887
 
1797
- // Scan source and return ranges [start, end) of fenced code blocks (``` or ~~~).
1798
- // Matches Markdown fences at line-start with up to 3 spaces/tabs indentation.
1888
+ // Source fence scan (unchanged)
1799
1889
  _findFenceRanges(s) {
1800
1890
  const ranges = [];
1801
1891
  const n = s.length;
@@ -1807,7 +1897,6 @@
1807
1897
 
1808
1898
  while (i < n) {
1809
1899
  const lineStart = i;
1810
- // Find line end and newline length
1811
1900
  let j = lineStart;
1812
1901
  while (j < n && s.charCodeAt(j) !== 10 && s.charCodeAt(j) !== 13) j++;
1813
1902
  const lineEnd = j;
@@ -1817,30 +1906,24 @@
1817
1906
  else nl = 1;
1818
1907
  }
1819
1908
 
1820
- // Compute indentation up to 3 "spaces" (tabs count as 1 here – safe heuristic)
1821
1909
  let k = lineStart;
1822
1910
  let indent = 0;
1823
1911
  while (k < lineEnd) {
1824
1912
  const c = s.charCodeAt(k);
1825
- if (c === 32 /* space */) { indent++; if (indent > 3) break; k++; }
1826
- else if (c === 9 /* tab */) { indent++; if (indent > 3) break; k++; }
1913
+ if (c === 32) { indent++; if (indent > 3) break; k++; }
1914
+ else if (c === 9) { indent++; if (indent > 3) break; k++; }
1827
1915
  else break;
1828
1916
  }
1829
1917
 
1830
1918
  if (!inFence) {
1831
1919
  if (indent <= 3 && k < lineEnd) {
1832
1920
  const ch = s.charCodeAt(k);
1833
- if (ch === 0x60 /* ` */ || ch === 0x7E /* ~ */) {
1921
+ if (ch === 0x60 || ch === 0x7E) {
1834
1922
  const mark = String.fromCharCode(ch);
1835
1923
  let m = k;
1836
1924
  while (m < lineEnd && s.charCodeAt(m) === ch) m++;
1837
1925
  const run = m - k;
1838
- if (run >= 3) {
1839
- inFence = true;
1840
- fenceMark = mark;
1841
- fenceLen = run;
1842
- startLineStart = lineStart;
1843
- }
1926
+ if (run >= 3) { inFence = true; fenceMark = mark; fenceLen = run; startLineStart = lineStart; }
1844
1927
  }
1845
1928
  }
1846
1929
  } else {
@@ -1849,14 +1932,13 @@
1849
1932
  while (m < lineEnd && s.charCodeAt(m) === fenceMark.charCodeAt(0)) m++;
1850
1933
  const run = m - k;
1851
1934
  if (run >= fenceLen) {
1852
- // Only whitespace is allowed after closing fence on the same line
1853
1935
  let onlyWS = true;
1854
1936
  for (let t = m; t < lineEnd; t++) {
1855
1937
  const cc = s.charCodeAt(t);
1856
1938
  if (cc !== 32 && cc !== 9) { onlyWS = false; break; }
1857
1939
  }
1858
1940
  if (onlyWS) {
1859
- const endIdx = lineEnd + nl; // include trailing newline if present
1941
+ const endIdx = lineEnd + nl;
1860
1942
  ranges.push([startLineStart, endIdx]);
1861
1943
  inFence = false; fenceMark = ''; fenceLen = 0; startLineStart = 0;
1862
1944
  }
@@ -1865,26 +1947,19 @@
1865
1947
  }
1866
1948
  i = lineEnd + nl;
1867
1949
  }
1868
-
1869
- // If EOF while still in fence, mark until end of string.
1870
1950
  if (inFence) ranges.push([startLineStart, n]);
1871
1951
  return ranges;
1872
1952
  }
1873
1953
 
1874
- // Check if match starts at "top-level" of a line:
1875
- // - up to 3 leading spaces/tabs allowed
1876
- // - not a list item marker ("- ", "+ ", "* ", "1. ", "1) ") and not a blockquote ("> ")
1877
- // - nothing else precedes the token on the same line
1878
1954
  _isTopLevelLineInSource(s, absIdx) {
1879
1955
  let ls = absIdx;
1880
1956
  while (ls > 0) {
1881
1957
  const ch = s.charCodeAt(ls - 1);
1882
- if (ch === 10 /* \n */ || ch === 13 /* \r */) break;
1958
+ if (ch === 10 || ch === 13) break;
1883
1959
  ls--;
1884
1960
  }
1885
1961
  const prefix = s.slice(ls, absIdx);
1886
1962
 
1887
- // Strip up to 3 leading "spaces" (tabs treated as 1 – acceptable heuristic)
1888
1963
  let i = 0, indent = 0;
1889
1964
  while (i < prefix.length) {
1890
1965
  const c = prefix.charCodeAt(i);
@@ -1895,18 +1970,14 @@
1895
1970
  if (indent > 3) return false;
1896
1971
  const rest = prefix.slice(i);
1897
1972
 
1898
- // Reject lists/blockquote
1899
1973
  if (/^>\s?/.test(rest)) return false;
1900
1974
  if (/^[-+*]\s/.test(rest)) return false;
1901
1975
  if (/^\d+[.)]\s/.test(rest)) return false;
1902
1976
 
1903
- // If any other non-whitespace text precedes the token on this line – not top-level
1904
1977
  if (rest.trim().length > 0) return false;
1905
-
1906
1978
  return true;
1907
1979
  }
1908
1980
 
1909
- // Apply source-phase replacements to one outside-of-fence chunk with top-level guard.
1910
1981
  _applySourceReplacementsInChunk(full, chunk, baseOffset, rules) {
1911
1982
  let t = chunk;
1912
1983
  for (let i = 0; i < rules.length; i++) {
@@ -1914,15 +1985,14 @@
1914
1985
  if (!r || !(r.openReplace || r.closeReplace)) continue;
1915
1986
  try {
1916
1987
  r.re.lastIndex = 0;
1917
- t = t.replace(r.re, (match, inner, offset /*, ...rest*/) => {
1988
+ t = t.replace(r.re, (match, inner, offset) => {
1918
1989
  const abs = baseOffset + (offset | 0);
1919
- // Only apply when opener is at top-level on that line (not in lists/blockquote)
1920
1990
  if (!this._isTopLevelLineInSource(full, abs)) return match;
1921
1991
  const open = r.openReplace || '';
1922
1992
  const close = r.closeReplace || '';
1923
1993
  return open + (inner || '') + close;
1924
1994
  });
1925
- } catch (_) { /* keep chunk as is on any error */ }
1995
+ } catch (_) {}
1926
1996
  }
1927
1997
  return t;
1928
1998
  }
@@ -3976,1072 +4046,1034 @@
3976
4046
  // ==========================================================================
3977
4047
 
3978
4048
  class StreamEngine {
3979
- constructor(cfg, dom, renderer, math, highlighter, codeScroll, scrollMgr, raf, asyncer, logger) {
3980
- this.cfg = cfg; this.dom = dom; this.renderer = renderer; this.math = math;
3981
- this.highlighter = highlighter; this.codeScroll = codeScroll; this.scrollMgr = scrollMgr; this.raf = raf;
3982
- this.asyncer = asyncer;
3983
- this.logger = logger || new Logger(cfg);
3984
-
3985
- // Streaming buffer (rope-like) – avoids O(n^2) string concatenation when many small chunks arrive.
3986
- // streamBuf holds the already materialized prefix; _sbParts keeps recent tail parts; _sbLen tracks their length.
3987
- this.streamBuf = ''; // materialized prefix (string used by render)
3988
- this._sbParts = []; // pending string chunks (array) not yet joined
3989
- 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);
3990
4054
 
3991
- this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
3992
- this.fenceTail = ''; this.fenceBuf = '';
3993
- this.lastSnapshotTs = 0; this.nextSnapshotStep = cfg.PROFILE_TEXT.base;
3994
- 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
3995
4060
 
3996
- this.codeStream = { open: false, lines: 0, chars: 0 };
3997
- 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;
3998
4065
 
3999
- this.suppressPostFinalizePass = false;
4066
+ this.codeStream = { open: false, lines: 0, chars: 0 };
4067
+ this.activeCode = null;
4000
4068
 
4001
- this._promoteScheduled = false;
4069
+ this.suppressPostFinalizePass = false;
4002
4070
 
4003
- // Guard to ensure first fence-open is materialized immediately when stream starts with code.
4004
- this._firstCodeOpenSnapDone = false;
4071
+ this._promoteScheduled = false;
4005
4072
 
4006
- // Streaming mode flag controls reduced rendering (no linkify etc.) on hot path.
4007
- this.isStreaming = false;
4073
+ // Guard to ensure first fence-open is materialized immediately when stream starts with code.
4074
+ this._firstCodeOpenSnapDone = false;
4008
4075
 
4009
- // Tracks whether renderSnapshot injected a one-off synthetic EOL for parsing an open fence
4010
- // (used to strip it from the initial streaming tail to avoid "#\n foo" on first line).
4011
- this._lastInjectedEOL = false;
4076
+ // Streaming mode flag controls reduced rendering (no linkify etc.) on hot path.
4077
+ this.isStreaming = false;
4012
4078
 
4013
- this._customFenceSpecs = []; // [{ open, close }, ...]
4014
- this._fenceCustom = null; // currently active custom fence spec or null
4015
- }
4016
- _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;
4017
4082
 
4018
- setCustomFenceSpecs(specs) {
4019
- this._customFenceSpecs = Array.isArray(specs) ? specs.slice() : [];
4020
- }
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); }
4021
4087
 
4022
- // --- Rope buffer helpers (internal) ---
4088
+ setCustomFenceSpecs(specs) {
4089
+ this._customFenceSpecs = Array.isArray(specs) ? specs.slice() : [];
4090
+ }
4023
4091
 
4024
- // Append a chunk into the rope without immediately touching the large string.
4025
- _appendChunk(s) {
4026
- if (!s) return;
4027
- this._sbParts.push(s);
4028
- this._sbLen += s.length;
4029
- }
4030
- // Current logical length of the stream text (materialized prefix + pending tail).
4031
- getStreamLength() {
4032
- return (this.streamBuf.length + this._sbLen);
4033
- }
4034
- // Materialize the rope into a single string for rendering (cheap if nothing pending).
4035
- getStreamText() {
4036
- if (this._sbLen > 0) {
4037
- // Join pending parts into the materialized prefix and clear the tail.
4038
- // Single-part fast path avoids a temporary array join.
4039
- this.streamBuf += (this._sbParts.length === 1 ? this._sbParts[0] : this._sbParts.join(''));
4040
- this._sbParts.length = 0;
4041
- this._sbLen = 0;
4042
- }
4043
- return this.streamBuf;
4044
- }
4045
- // Reset the rope to an empty state.
4046
- _clearStreamBuffer() {
4047
- 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(''));
4048
4106
  this._sbParts.length = 0;
4049
4107
  this._sbLen = 0;
4050
4108
  }
4109
+ return this.streamBuf;
4110
+ }
4111
+ _clearStreamBuffer() {
4112
+ this.streamBuf = '';
4113
+ this._sbParts.length = 0;
4114
+ this._sbLen = 0;
4115
+ }
4051
4116
 
4052
- // Reset all streaming state and counters.
4053
- reset() {
4054
- this._clearStreamBuffer();
4055
- this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
4056
- this.fenceTail = ''; this.fenceBuf = '';
4057
- this.lastSnapshotTs = 0; this.nextSnapshotStep = this.profile().base;
4058
- this.snapshotScheduled = false; this.snapshotRAF = 0;
4059
- this.codeStream = { open: false, lines: 0, chars: 0 };
4060
- this.activeCode = null; this.suppressPostFinalizePass = false;
4061
- this._promoteScheduled = false;
4062
- this._firstCodeOpenSnapDone = false;
4063
-
4064
- // Clear any previous synthetic EOL marker.
4065
- this._lastInjectedEOL = false;
4066
- this._fenceCustom = null;
4067
-
4068
- this._d('RESET', { });
4069
- }
4070
- // Turn active streaming code block into plain text (safety on abort).
4071
- defuseActiveToPlain() {
4072
- if (!this.activeCode || !this.activeCode.codeEl || !this.activeCode.codeEl.isConnected) return;
4073
- const codeEl = this.activeCode.codeEl;
4074
- const fullText = (this.activeCode.frozenEl?.textContent || '') + (this.activeCode.tailEl?.textContent || '');
4075
- try {
4076
- 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;
4077
4162
  codeEl.removeAttribute('data-highlighted');
4078
4163
  codeEl.classList.remove('hljs');
4079
4164
  codeEl.dataset._active_stream = '0';
4080
- const st = this.codeScroll.state(codeEl); st.autoFollow = false;
4081
- } catch (_) {}
4082
- this._d('DEFUSE_ACTIVE_TO_PLAIN', { len: fullText.length });
4083
- this.activeCode = null;
4084
- }
4085
- // If there are orphan streaming code blocks in DOM, finalize them as plain text.
4086
- defuseOrphanActiveBlocks(root) {
4087
- try {
4088
- const scope = root || document;
4089
- const nodes = scope.querySelectorAll('pre code[data-_active_stream="1"]');
4090
- let n = 0;
4091
- nodes.forEach(codeEl => {
4092
- if (!codeEl.isConnected) return;
4093
- let text = '';
4094
- const frozen = codeEl.querySelector('.hl-frozen');
4095
- const tail = codeEl.querySelector('.hl-tail');
4096
- if (frozen || tail) text = (frozen?.textContent || '') + (tail?.textContent || '');
4097
- else text = codeEl.textContent || '';
4098
- codeEl.textContent = text;
4099
- codeEl.removeAttribute('data-highlighted');
4100
- codeEl.classList.remove('hljs');
4101
- codeEl.dataset._active_stream = '0';
4102
- try { this.codeScroll.attachHandlers(codeEl); } catch (_) {}
4103
- n++;
4104
- });
4105
- if (n) this._d('DEFUSE_ORPHAN_ACTIVE_BLOCKS', { count: n });
4106
- } catch (e) { this._d('DEFUSE_ORPHAN_ACTIVE_ERR', String(e)); }
4107
- }
4108
- // Abort streaming and clear state with options.
4109
- abortAndReset(opts) {
4110
- const o = Object.assign({
4111
- finalizeActive: true,
4112
- clearBuffer: true,
4113
- clearMsg: false,
4114
- defuseOrphans: true,
4115
- reason: '',
4116
- suppressLog: false
4117
- }, (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 || {}));
4118
4180
 
4119
- try { this.raf.cancelGroup('StreamEngine'); } catch (_) {}
4120
- try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4121
- 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;
4122
4184
 
4123
- const hadActive = !!this.activeCode;
4124
- try {
4125
- if (this.activeCode) {
4126
- if (o.finalizeActive === true) this.finalizeActiveCode();
4127
- else this.defuseActiveToPlain();
4128
- }
4129
- } catch (e) {
4130
- 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();
4131
4190
  }
4191
+ } catch (e) {
4192
+ this._d('ABORT_FINALIZE_ERR', String(e));
4193
+ }
4132
4194
 
4133
- if (o.defuseOrphans) {
4134
- try { this.defuseOrphanActiveBlocks(); }
4135
- catch (e) { this._d('ABORT_DEFUSE_ORPHANS_ERR', String(e)); }
4136
- }
4195
+ if (o.defuseOrphans) {
4196
+ try { this.defuseOrphanActiveBlocks(); }
4197
+ catch (e) { this._d('ABORT_DEFUSE_ORPHANS_ERR', String(e)); }
4198
+ }
4137
4199
 
4138
- if (o.clearBuffer) {
4139
- this._clearStreamBuffer();
4140
- this.fenceOpen = false; this.fenceMark = '`'; this.fenceLen = 3;
4141
- this.fenceTail = ''; this.fenceBuf = '';
4142
- this.codeStream.open = false; this.codeStream.lines = 0; this.codeStream.chars = 0;
4143
- window.__lastSnapshotLen = 0;
4144
- }
4145
- if (o.clearMsg === true) {
4146
- try { this.dom.resetEphemeral(); } catch (_) {}
4147
- }
4148
- 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;
4149
4206
  }
4150
- // Select profile for current stream state (code vs text).
4151
- profile() { return this.fenceOpen ? this.cfg.PROFILE_CODE : this.cfg.PROFILE_TEXT; }
4152
- // Reset adaptive snapshot budget to base.
4153
- resetBudget() { this.nextSnapshotStep = this.profile().base; }
4154
- // Check whether [from, end) contains only spaces/tabs.
4155
- onlyTrailingWhitespace(s, from, end) {
4156
- for (let i = from; i < end; i++) { const c = s.charCodeAt(i); if (c !== 0x20 && c !== 0x09) return false; }
4157
- return true;
4207
+ if (o.clearMsg === true) {
4208
+ try { this.dom.resetEphemeral(); } catch (_) {}
4158
4209
  }
4159
- // Update fence state based on a fresh chunk and buffer tail; detect openings and closings.
4160
- updateFenceHeuristic(chunk) {
4161
- const prev = (this.fenceBuf || '');
4162
- const s = prev + (chunk || '');
4163
- const preLen = prev.length;
4164
- const n = s.length; let i = 0;
4165
- let opened = false; let closed = false; let splitAt = -1;
4166
- let atLineStart = (preLen === 0) ? true : /[\n\r]$/.test(prev);
4167
-
4168
- const inNewOrCrosses = (j, k) => (j >= preLen) || (k > preLen);
4169
-
4170
- while (i < n) {
4171
- const ch = s[i];
4172
- if (ch === '\r' || ch === '\n') { atLineStart = true; i++; continue; }
4173
- if (!atLineStart) { i++; continue; }
4174
- atLineStart = false;
4175
-
4176
- // Skip list/blockquote/indent normalization (existing logic)
4177
- let j = i;
4178
- while (j < n) {
4179
- let localSpaces = 0;
4180
- while (j < n && (s[j] === ' ' || s[j] === '\t')) { localSpaces += (s[j] === '\t') ? 4 : 1; j++; if (localSpaces > 3) break; }
4181
- if (j < n && s[j] === '>') { j++; if (j < n && s[j] === ' ') j++; continue; }
4182
-
4183
- let saved = j;
4184
- if (j < n && (s[j] === '-' || s[j] === '*' || s[j] === '+')) {
4185
- let jj = j + 1; if (jj < n && s[jj] === ' ') { j = jj + 1; } else { j = saved; }
4186
- } else {
4187
- let k2 = j; let hasDigit = false;
4188
- while (k2 < n && s[k2] >= '0' && s[k2] <= '9') { hasDigit = true; k2++; }
4189
- if (hasDigit && k2 < n && (s[k2] === '.' || s[k2] === ')')) {
4190
- k2++; if (k2 < n && s[k2] === ' ') { j = k2 + 1; } else { j = saved; }
4191
- } else { j = saved; }
4192
- }
4193
- break;
4194
- }
4195
-
4196
- let indent = 0;
4197
- while (j < n && (s[j] === ' ' || s[j] === '\t')) {
4198
- 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; }
4199
4250
  }
4200
- if (indent > 3) { i = j; continue; }
4201
-
4202
- // 1) Custom fences first (e.g. [!exec] ... [/!exec], <execute>...</execute>)
4203
- if (!this.fenceOpen && this._customFenceSpecs && this._customFenceSpecs.length) {
4204
- for (let ci = 0; ci < this._customFenceSpecs.length; ci++) {
4205
- const spec = this._customFenceSpecs[ci];
4206
- const open = spec && spec.open ? spec.open : '';
4207
- if (!open) continue;
4208
- const k = j + open.length;
4209
- if (k <= n && s.slice(j, k) === open) {
4210
- if (!inNewOrCrosses(j, k)) { /* seen fully in previous prefix */ }
4211
- else {
4212
- this.fenceOpen = true; this._fenceCustom = spec; opened = true; i = k;
4213
- this._d('FENCE_OPEN_DETECTED_CUSTOM', { open, idxStart: j, idxEnd: k, region: (j >= preLen) ? 'new' : 'cross' });
4214
- continue; // main while
4215
- }
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
4216
4273
  }
4217
4274
  }
4218
- } else if (this.fenceOpen && this._fenceCustom && this._fenceCustom.close) {
4219
- const close = this._fenceCustom.close;
4220
- const k = j + close.length;
4221
- if (k <= n && s.slice(j, k) === close) {
4222
- // Require only trailing whitespace on the line (consistent with ``` logic)
4223
- let eol = k; while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
4224
- const onlyWS = this.onlyTrailingWhitespace(s, k, eol);
4225
- if (onlyWS) {
4226
- if (!inNewOrCrosses(j, k)) { /* seen in previous prefix */ }
4227
- else {
4228
- this.fenceOpen = false; this._fenceCustom = null; closed = true;
4229
- const endInS = k;
4230
- const rel = endInS - preLen;
4231
- splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
4232
- i = k;
4233
- this._d('FENCE_CLOSE_DETECTED_CUSTOM', { close, idxStart: j, idxEnd: k, splitAt, region: (j >= preLen) ? 'new' : 'cross' });
4234
- continue; // main while
4235
- }
4236
- } else {
4237
- 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
4238
4293
  }
4294
+ } else {
4295
+ this._d('FENCE_CLOSE_REJECTED_CUSTOM_NON_WS_AFTER', { close, idxStart: j, idxEnd: k });
4239
4296
  }
4240
4297
  }
4298
+ }
4241
4299
 
4242
- // 2) Standard markdown-it fences (``` or ~~~) – leave your original logic intact
4243
- if (j < n && (s[j] === '`' || s[j] === '~')) {
4244
- 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;
4245
4303
 
4246
- if (!this.fenceOpen) {
4247
- if (run >= 3) {
4248
- if (!inNewOrCrosses(j, k)) { i = k; continue; }
4249
- this.fenceOpen = true; this.fenceMark = mark; this.fenceLen = run; opened = true; i = k;
4250
- 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' });
4251
4322
  continue;
4252
- }
4253
- } else if (!this._fenceCustom) {
4254
- if (mark === this.fenceMark && run >= this.fenceLen) {
4255
- if (!inNewOrCrosses(j, k)) { i = k; continue; }
4256
- let eol = k; while (eol < n && s[eol] !== '\n' && s[eol] !== '\r') eol++;
4257
- if (this.onlyTrailingWhitespace(s, k, eol)) {
4258
- this.fenceOpen = false; closed = true;
4259
- const endInS = k;
4260
- const rel = endInS - preLen;
4261
- splitAt = Math.max(0, Math.min((chunk ? chunk.length : 0), rel));
4262
- i = k;
4263
- this._d('FENCE_CLOSE_DETECTED', { mark, run, idxStart: j, idxEnd: k, splitAt, region: (j >= preLen) ? 'new' : 'cross' });
4264
- continue;
4265
- } else {
4266
- this._d('FENCE_CLOSE_REJECTED_NON_WS_AFTER', { mark, run, idxStart: j, idxEnd: k });
4267
- }
4323
+ } else {
4324
+ this._d('FENCE_CLOSE_REJECTED_NON_WS_AFTER', { mark, run, idxStart: j, idxEnd: k });
4268
4325
  }
4269
4326
  }
4270
4327
  }
4271
-
4272
- i = j + 1;
4273
4328
  }
4274
4329
 
4275
- const MAX_TAIL = 512;
4276
- this.fenceBuf = s.slice(-MAX_TAIL);
4277
- this.fenceTail = s.slice(-3);
4278
- return { opened, closed, splitAt };
4330
+ i = j + 1;
4279
4331
  }
4280
4332
 
4281
- // Ensure message snapshot container exists.
4282
- getMsgSnapshotRoot(msg) {
4283
- if (!msg) return null;
4284
- let snap = msg.querySelector('.md-snapshot-root');
4285
- if (!snap) { snap = document.createElement('div'); snap.className = 'md-snapshot-root'; msg.appendChild(snap); }
4286
- return snap;
4287
- }
4288
- // Detect structural boundaries in a chunk (for snapshot decisions).
4289
- hasStructuralBoundary(chunk) { if (!chunk) return false; return /\n(\n|[-*]\s|\d+\.\s|#{1,6}\s|>\s)/.test(chunk); }
4290
- // Decide whether we should snapshot on this chunk.
4291
- shouldSnapshotOnChunk(chunk, chunkHasNL, hasBoundary) {
4292
- const prof = this.profile(); const now = Utils.now();
4293
- if (this.activeCode && this.fenceOpen) return false;
4294
- if ((now - this.lastSnapshotTs) < prof.minInterval) return false;
4295
- 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
+ }
4296
4338
 
4297
- const delta = Math.max(0, this.getStreamLength() - (window.__lastSnapshotLen || 0));
4298
- if (this.fenceOpen) { if (chunkHasNL && delta >= this.nextSnapshotStep) return true; return false; }
4299
- if (delta >= this.nextSnapshotStep) return true;
4300
- return false;
4301
- }
4302
- // If we are getting slow, schedule a soft snapshot based on time.
4303
- maybeScheduleSoftSnapshot(msg, chunkHasNL) {
4304
- 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;
4305
4368
  if (this.activeCode && this.fenceOpen) return;
4306
- if (this.fenceOpen && this.codeStream.lines < 1 && !chunkHasNL) return;
4307
- if ((now - this.lastSnapshotTs) >= prof.softLatency) this.scheduleSnapshot(msg);
4308
- }
4309
- // Schedule snapshot rendering (coalesced via rAF).
4310
- scheduleSnapshot(msg, force = false) {
4311
- if (this.snapshotScheduled && !this.raf.isScheduled('SE:snapshot')) this.snapshotScheduled = false;
4312
- if (!force) {
4313
- if (this.snapshotScheduled) return;
4314
- if (this.activeCode && this.fenceOpen) return;
4315
- } else {
4316
- if (this.snapshotScheduled && this.raf.isScheduled('SE:snapshot')) return;
4317
- }
4318
- this.snapshotScheduled = true;
4319
- this.raf.schedule('SE:snapshot', () => { this.snapshotScheduled = false; this.renderSnapshot(msg); }, 'StreamEngine', 0);
4320
- }
4321
- // Split code element into frozen and tail spans if needed.
4322
- ensureSplitCodeEl(codeEl) {
4323
- if (!codeEl) return null;
4324
- let frozen = codeEl.querySelector('.hl-frozen'); let tail = codeEl.querySelector('.hl-tail');
4325
- if (frozen && tail) return { codeEl, frozenEl: frozen, tailEl: tail };
4326
- const text = codeEl.textContent || ''; codeEl.innerHTML = '';
4327
- frozen = document.createElement('span'); frozen.className = 'hl-frozen';
4328
- tail = document.createElement('span'); tail.className = 'hl-tail';
4329
- codeEl.appendChild(frozen); codeEl.appendChild(tail);
4330
- if (text) tail.textContent = text; return { codeEl, frozenEl: frozen, tailEl: tail };
4369
+ } else {
4370
+ if (this.snapshotScheduled && this.raf.isScheduled('SE:snapshot')) return;
4331
4371
  }
4332
- // Create active code context from the latest snapshot.
4333
- setupActiveCodeFromSnapshot(snap) {
4334
- const codes = snap.querySelectorAll('pre code'); if (!codes.length) return null;
4335
- const last = codes[codes.length - 1];
4336
- const cls = Array.from(last.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
4337
- const lang = (cls.replace('language-', '') || 'plaintext');
4338
- const parts = this.ensureSplitCodeEl(last); if (!parts) return null;
4339
-
4340
- // If we injected a synthetic EOL for parsing an open fence, remove it from the streaming tail now.
4341
- // This prevents breaking the very first code line into "#\n foo" when the next chunk starts with " foo".
4342
- if (this._lastInjectedEOL && parts.tailEl && parts.tailEl.textContent && parts.tailEl.textContent.endsWith('\n')) {
4343
- parts.tailEl.textContent = parts.tailEl.textContent.slice(0, -1);
4344
- // Reset the marker so we don't accidentally trim again in this snapshot lifecycle.
4345
- this._lastInjectedEOL = false;
4346
- }
4347
-
4348
- const st = this.codeScroll.state(parts.codeEl); st.autoFollow = true; st.userInteracted = false;
4349
- parts.codeEl.dataset._active_stream = '1';
4350
- const baseFrozenNL = Utils.countNewlines(parts.frozenEl.textContent || ''); const baseTailNL = Utils.countNewlines(parts.tailEl.textContent || '');
4351
- const ac = { codeEl: parts.codeEl, frozenEl: parts.frozenEl, tailEl: parts.tailEl, lang, frozenLen: parts.frozenEl.textContent.length, lastPromoteTs: 0,
4352
- lines: 0, tailLines: baseTailNL, linesSincePromote: 0, initialLines: baseFrozenNL + baseTailNL, haltHL: false, plainStream: false };
4353
- this._d('ACTIVE_CODE_SETUP', { lang, frozenLen: ac.frozenLen, tailLines: ac.tailLines, initialLines: ac.initialLines });
4354
- return ac;
4355
- }
4356
- // Copy previous active code state into the new one (after snapshot).
4357
- rehydrateActiveCode(oldAC, newAC) {
4358
- if (!oldAC || !newAC) return;
4359
- newAC.frozenEl.innerHTML = oldAC.frozenEl ? oldAC.frozenEl.innerHTML : '';
4360
- const fullText = newAC.codeEl.textContent || ''; const remainder = fullText.slice(oldAC.frozenLen);
4361
- newAC.tailEl.textContent = remainder;
4362
- newAC.frozenLen = oldAC.frozenLen; newAC.lang = oldAC.lang;
4363
- newAC.lines = oldAC.lines; newAC.tailLines = Utils.countNewlines(remainder);
4364
- newAC.lastPromoteTs = oldAC.lastPromoteTs; newAC.linesSincePromote = oldAC.linesSincePromote || 0;
4365
- newAC.initialLines = oldAC.initialLines || 0; newAC.haltHL = !!oldAC.haltHL;
4366
- newAC.plainStream = !!oldAC.plainStream;
4367
- this._d('ACTIVE_CODE_REHYDRATE', { lang: newAC.lang, frozenLen: newAC.frozenLen, tailLines: newAC.tailLines, initialLines: newAC.initialLines, halted: newAC.haltHL, plainStream: newAC.plainStream });
4368
- }
4369
- // Append text to active tail span and update counters.
4370
- appendToActiveTail(text) {
4371
- if (!this.activeCode || !this.activeCode.tailEl || !text) return;
4372
- this.activeCode.tailEl.insertAdjacentText('beforeend', text);
4373
- const nl = Utils.countNewlines(text);
4374
- this.activeCode.tailLines += nl; this.activeCode.linesSincePromote += nl;
4375
- this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4376
- if (this.logger.isEnabled('STREAM') && (nl > 0 || text.length >= 64)) {
4377
- this._d('TAIL_APPEND', { addLen: text.length, addNL: nl, totalTailNL: this.activeCode.tailLines });
4378
- }
4379
- }
4380
- // Enforce budgets: stop incremental hljs and switch to plain streaming if needed.
4381
- enforceHLStopBudget() {
4382
- if (!this.activeCode) return;
4383
- // If global disable was requested, halt early and switch to plain streaming.
4384
- if (this.cfg.HL.DISABLE_ALL) { this.activeCode.haltHL = true; this.activeCode.plainStream = true; return; }
4385
- const stop = (this.cfg.PROFILE_CODE.stopAfterLines | 0);
4386
- const streamPlainLines = (this.cfg.PROFILE_CODE.streamPlainAfterLines | 0);
4387
- const streamPlainChars = (this.cfg.PROFILE_CODE.streamPlainAfterChars | 0);
4388
- const maxFrozenChars = (this.cfg.PROFILE_CODE.maxFrozenChars | 0);
4389
-
4390
- const totalLines = (this.activeCode.initialLines || 0) + (this.activeCode.lines || 0);
4391
- const frozenChars = this.activeCode.frozenLen | 0;
4392
- const tailChars = (this.activeCode.tailEl?.textContent || '').length | 0;
4393
- const totalStreamedChars = frozenChars + tailChars;
4394
-
4395
- // Switch to plain streaming after budgets – no incremental hljs
4396
- if ((streamPlainLines > 0 && totalLines >= streamPlainLines) ||
4397
- (streamPlainChars > 0 && totalStreamedChars >= streamPlainChars) ||
4398
- (maxFrozenChars > 0 && frozenChars >= maxFrozenChars)) {
4399
- this.activeCode.haltHL = true;
4400
- this.activeCode.plainStream = true;
4401
- try { this.activeCode.codeEl.dataset.hlStreamSuspended = '1'; } catch (_) {}
4402
- this._d('STREAM_HL_SUSPENDED', { totalLines, totalStreamedChars, frozenChars, reason: 'budget' });
4403
- return;
4404
- }
4372
+ this.snapshotScheduled = true;
4373
+ this.raf.schedule('SE:snapshot', () => { this.snapshotScheduled = false; this.renderSnapshot(msg); }, 'StreamEngine', 0);
4374
+ }
4405
4375
 
4406
- if (stop > 0 && totalLines >= stop) {
4407
- this.activeCode.haltHL = true;
4408
- this.activeCode.plainStream = true;
4409
- try { this.activeCode.codeEl.dataset.hlStreamSuspended = '1'; } catch (_) {}
4410
- this._d('STREAM_HL_SUSPENDED', { totalLines, stopAfter: stop, reason: 'stopAfterLines' });
4411
- }
4412
- }
4413
- _aliasLang(token) {
4414
- const ALIAS = {
4415
- txt: 'plaintext', text: 'plaintext', plaintext: 'plaintext',
4416
- sh: 'bash', shell: 'bash', zsh: 'bash', 'shell-session': 'bash',
4417
- py: 'python', python3: 'python', py3: 'python',
4418
- js: 'javascript', node: 'javascript', nodejs: 'javascript',
4419
- ts: 'typescript', 'ts-node': 'typescript',
4420
- yml: 'yaml', kt: 'kotlin', rs: 'rust',
4421
- csharp: 'csharp', 'c#': 'csharp', 'c++': 'cpp',
4422
- ps: 'powershell', ps1: 'powershell', pwsh: 'powershell', powershell7: 'powershell',
4423
- docker: 'dockerfile'
4424
- };
4425
- const v = String(token || '').trim().toLowerCase();
4426
- return ALIAS[v] || v;
4427
- }
4428
- _isHLJSSupported(lang) {
4429
- try { return !!(window.hljs && hljs.getLanguage && hljs.getLanguage(lang)); } catch (_) { return false; }
4430
- }
4431
- // Try to detect language from a "language: X" style first line directive.
4432
- _detectDirectiveLangFromText(text) {
4433
- if (!text) return null;
4434
- let s = String(text);
4435
- if (s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
4436
- const lines = s.split(/\r?\n/);
4437
- let i = 0; while (i < lines.length && !lines[i].trim()) i++;
4438
- if (i >= lines.length) return null;
4439
- let first = lines[i].trim();
4440
- first = first.replace(/^\s*lang(?:uage)?\s*[:=]\s*/i, '').trim();
4441
- let token = first.split(/\s+/)[0].replace(/:$/, '');
4442
- if (!/^[A-Za-z][\w#+\-\.]{0,30}$/.test(token)) return null;
4443
-
4444
- let cand = this._aliasLang(token);
4445
- const rest = lines.slice(i + 1).join('\n');
4446
- if (!rest.trim()) return null;
4447
-
4448
- let pos = 0, seen = 0;
4449
- while (seen < i && pos < s.length) { const nl = s.indexOf('\n', pos); if (nl === -1) return null; pos = nl + 1; seen++; }
4450
- let end = s.indexOf('\n', pos);
4451
- if (end === -1) end = s.length; else end = end + 1;
4452
- return { lang: cand, deleteUpto: end };
4453
- }
4454
- // Update code element class to reflect new lang (language-xxx).
4455
- _updateCodeLangClass(codeEl, newLang) {
4456
- try {
4457
- Array.from(codeEl.classList).forEach(c => { if (c.startsWith('language-')) codeEl.classList.remove(c); });
4458
- codeEl.classList.add('language-' + (newLang || 'plaintext'));
4459
- } 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;
4460
4397
  }
4461
- // Update code header label and data attribute.
4462
- _updateCodeHeaderLabel(codeEl, newLabel, newLangToken) {
4463
- try {
4464
- const wrap = codeEl.closest('.code-wrapper');
4465
- if (!wrap) return;
4466
- const span = wrap.querySelector('.code-header-lang');
4467
- if (span) span.textContent = newLabel || (newLangToken || 'code');
4468
- wrap.setAttribute('data-code-lang', newLangToken || '');
4469
- } 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 });
4470
4427
  }
4471
- // Try to promote language from a directive and remove its header line.
4472
- maybePromoteLanguageFromDirective() {
4473
- if (!this.activeCode || !this.activeCode.codeEl) return;
4474
- 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;
4475
4517
 
4476
- const frozenTxt = this.activeCode.frozenEl ? this.activeCode.frozenEl.textContent : '';
4477
- const tailTxt = this.activeCode.tailEl ? this.activeCode.tailEl.textContent : '';
4478
- const combined = frozenTxt + tailTxt;
4479
- 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;
4480
4522
 
4481
- const det = this._detectDirectiveLangFromText(combined);
4482
- if (!det || !det.lang) return;
4523
+ const det = this._detectDirectiveLangFromText(combined);
4524
+ if (!det || !det.lang) return;
4483
4525
 
4484
- const newLang = det.lang;
4485
- const newCombined = combined.slice(det.deleteUpto);
4526
+ const newLang = det.lang;
4527
+ const newCombined = combined.slice(det.deleteUpto);
4486
4528
 
4487
- try {
4488
- const codeEl = this.activeCode.codeEl;
4489
- codeEl.innerHTML = '';
4490
- const frozen = document.createElement('span'); frozen.className = 'hl-frozen';
4491
- const tail = document.createElement('span'); tail.className = 'hl-tail';
4492
- tail.textContent = newCombined;
4493
- codeEl.appendChild(frozen); codeEl.appendChild(tail);
4494
- this.activeCode.frozenEl = frozen; this.activeCode.tailEl = tail;
4495
- this.activeCode.frozenLen = 0;
4496
- this.activeCode.tailLines = Utils.countNewlines(newCombined);
4497
- this.activeCode.linesSincePromote = 0;
4498
-
4499
- this.activeCode.lang = newLang;
4500
- this._updateCodeLangClass(codeEl, newLang);
4501
- this._updateCodeHeaderLabel(codeEl, newLang, newLang);
4502
-
4503
- this._d('LANG_PROMOTE', { to: newLang, removedChars: det.deleteUpto, tailLines: this.activeCode.tailLines });
4504
- this.schedulePromoteTail(true);
4505
- } catch (e) {
4506
- this._d('LANG_PROMOTE_ERR', String(e));
4507
- }
4508
- }
4509
- // Highlight a small piece of text based on language (safe fallback to escapeHtml).
4510
- highlightDeltaText(lang, text) {
4511
- if (this.cfg.HL.DISABLE_ALL) return Utils.escapeHtml(text);
4512
- if (window.hljs && lang && hljs.getLanguage && hljs.getLanguage(lang)) {
4513
- try { return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value; }
4514
- catch (_) { return Utils.escapeHtml(text); }
4515
- }
4516
- return Utils.escapeHtml(text);
4517
- }
4518
- // Schedule cooperative tail promotion (async) to avoid blocking UI on each chunk.
4519
- schedulePromoteTail(force = false) {
4520
- if (!this.activeCode || !this.activeCode.tailEl) return;
4521
- if (this._promoteScheduled) return;
4522
- this._promoteScheduled = true;
4523
- this.raf.schedule('SE:promoteTail', () => {
4524
- this._promoteScheduled = false;
4525
- this._promoteTailWork(force);
4526
- }, 'StreamEngine', 1);
4527
- }
4528
- // Move a full-line part of tail into frozen region (with highlight if budgets allow).
4529
- async _promoteTailWork(force = false) {
4530
- if (!this.activeCode || !this.activeCode.tailEl) return;
4531
-
4532
- // If plain streaming mode is on, or incremental hljs is disabled, promote as plain text only.
4533
- const now = Utils.now(); const prof = this.cfg.PROFILE_CODE;
4534
- const tailText0 = this.activeCode.tailEl.textContent || ''; if (!tailText0) return;
4535
-
4536
- if (!force) {
4537
- if ((now - this.activeCode.lastPromoteTs) < prof.promoteMinInterval) return;
4538
- const enoughLines = (this.activeCode.linesSincePromote || 0) >= (prof.promoteMinLines || 10);
4539
- const enoughChars = tailText0.length >= prof.minCharsForHL;
4540
- if (!enoughLines && !enoughChars) return;
4541
- }
4542
-
4543
- // Cut at last full line to avoid moving partial tokens
4544
- const idx = tailText0.lastIndexOf('\n');
4545
- if (idx <= -1 && !force) return;
4546
- const cut = (idx >= 0) ? (idx + 1) : tailText0.length;
4547
- const delta = tailText0.slice(0, cut); if (!delta) return;
4548
-
4549
- // Re-evaluate budgets before performing any heavy work
4550
- this.enforceHLStopBudget();
4551
- const usePlain = this.activeCode.haltHL || this.activeCode.plainStream || !this._isHLJSSupported(this.activeCode.lang);
4552
-
4553
- // Cooperative rAF yield before heavy highlight
4554
- if (!usePlain) await this.asyncer.yield();
4555
-
4556
- // If tail changed since we captured it, validate prefix to avoid duplication
4557
- if (!this.activeCode || !this.activeCode.tailEl) return;
4558
- const tailNow = this.activeCode.tailEl.textContent || '';
4559
- if (!tailNow.startsWith(delta)) {
4560
- // New data arrived; reschedule for next frame without touching DOM
4561
- this.schedulePromoteTail(false);
4562
- return;
4563
- }
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;
4564
4540
 
4565
- // Apply DOM updates: either highlighted HTML delta or plain text
4566
- if (usePlain) {
4567
- // Plain text promotion – extremely cheap, no spans created.
4568
- this.activeCode.frozenEl.insertAdjacentText('beforeend', delta);
4569
- } else {
4570
- // Highlighted promotion – still capped by budgets above.
4571
- let html = Utils.escapeHtml(delta);
4572
- try { html = this.highlightDeltaText(this.activeCode.lang, delta); } catch (_) { html = Utils.escapeHtml(delta); }
4573
- this.activeCode.frozenEl.insertAdjacentHTML('beforeend', html);
4574
- }
4575
-
4576
- // Update tail and counters
4577
- this.activeCode.tailEl.textContent = tailNow.slice(delta.length);
4578
- this.activeCode.frozenLen += delta.length;
4579
- const promotedLines = Utils.countNewlines(delta);
4580
- this.activeCode.tailLines = Math.max(0, (this.activeCode.tailLines || 0) - promotedLines);
4581
- this.activeCode.linesSincePromote = Math.max(0, (this.activeCode.linesSincePromote || 0) - promotedLines);
4582
- this.activeCode.lastPromoteTs = Utils.now();
4583
- this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4584
- 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));
4585
4549
  }
4586
- // Finalize the current active code block. Keep it plain for now and schedule highlight lazily.
4587
- finalizeActiveCode() {
4588
- if (!this.activeCode) return;
4589
- const codeEl = this.activeCode.codeEl;
4590
- const fromBottomBefore = Math.max(0, codeEl.scrollHeight - codeEl.clientHeight - codeEl.scrollTop);
4591
- const wasNearBottom = this.codeScroll.isNearBottomEl(codeEl, this.cfg.CODE_SCROLL.NEAR_MARGIN_PX);
4592
- 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;
4593
4570
 
4594
- // Non-blocking finalize: place plain text now, schedule highlight via Highlighter later.
4595
- try {
4596
- codeEl.innerHTML = '';
4597
- codeEl.textContent = fullText;
4598
- codeEl.classList.add('hljs'); // keep visual parity until highlight applies
4599
- codeEl.removeAttribute('data-highlighted');
4600
- } catch (_) {}
4571
+ const now = Utils.now(); const prof = this.cfg.PROFILE_CODE;
4572
+ const tailText0 = this.activeCode.tailEl.textContent || ''; if (!tailText0) return;
4601
4573
 
4602
- const st = this.codeScroll.state(codeEl); st.autoFollow = false;
4603
- const maxScrollTop = Math.max(0, codeEl.scrollHeight - codeEl.clientHeight);
4604
- const target = wasNearBottom ? maxScrollTop : Math.max(0, maxScrollTop - fromBottomBefore);
4605
- try { codeEl.scrollTop = target; } catch (_) {}
4606
- st.lastScrollTop = codeEl.scrollTop;
4607
- 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
+ }
4608
4580
 
4609
- try { codeEl.dataset.justFinalized = '1'; } catch (_) {}
4610
- 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;
4611
4585
 
4612
- // Schedule async highlight on the finalized element (viewport-aware).
4613
- 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);
4614
4588
 
4615
- this.suppressPostFinalizePass = true;
4589
+ if (!usePlain) await this.asyncer.yield();
4616
4590
 
4617
- this._d('FINALIZE_CODE_NONBLOCK', { lang: this.activeCode.lang, len: fullText.length, highlighted: false });
4618
- 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;
4619
4596
  }
4620
- // Make a simple fingerprint to reuse identical closed code blocks between snapshots.
4621
- codeFingerprint(codeEl) {
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);
4604
+ }
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;
4622
4655
  const cls = Array.from(codeEl.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
4623
- const lang = cls.replace('language-', '') || 'plaintext';
4624
- 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;
4625
4661
  return `${lang}|${len}|${head}|${tail}`;
4662
+ } catch (_) {
4663
+ return null;
4626
4664
  }
4627
- // fingerprint using precomputed meta on wrapper (avoids .textContent for heavy blocks).
4628
- codeFingerprintFromWrapper(codeEl) {
4629
- try {
4630
- const wrap = codeEl.closest('.code-wrapper'); if (!wrap) return null;
4631
- const cls = Array.from(codeEl.classList).find(c => c.startsWith('language-')) || 'language-plaintext';
4632
- const lang = (cls.replace('language-', '') || 'plaintext');
4633
- const len = wrap.getAttribute('data-code-len') || '';
4634
- const head = wrap.getAttribute('data-code-head') || '';
4635
- const tail = wrap.getAttribute('data-code-tail') || '';
4636
- if (!len) return null; // ensure at least length exists
4637
- return `${lang}|${len}|${head}|${tail}`;
4638
- } catch (_) {
4639
- return null;
4640
- }
4641
- }
4642
- // Try to reuse old finalized code block DOM nodes to avoid re-highlighting.
4643
- preserveStableClosedCodes(oldSnap, newRoot, skipLastIfStreaming) {
4644
- try {
4645
- const oldCodes = Array.from(oldSnap.querySelectorAll('pre code')); if (!oldCodes.length) return;
4646
- // Safety guard: avoid heavy fingerprint work on extremely large outputs
4647
- const newCodesPre = Array.from(newRoot.querySelectorAll('pre code'));
4648
- if (newCodesPre.length > this.cfg.STREAM.PRESERVE_CODES_MAX || oldCodes.length > this.cfg.STREAM.PRESERVE_CODES_MAX) return;
4649
-
4650
- const map = new Map();
4651
- for (const el of oldCodes) {
4652
- if (el.querySelector('.hl-frozen')) continue; // skip streaming blocks
4653
- if (this.activeCode && el === this.activeCode.codeEl) continue;
4654
- // Try wrapper-based fingerprint first, fallback to text-based
4655
- let fp = this.codeFingerprintFromWrapper(el);
4656
- if (!fp) fp = el.dataset.fp || (el.dataset.fp = this.codeFingerprint(el));
4657
- const arr = map.get(fp) || []; arr.push(el); map.set(fp, arr);
4658
- }
4659
- const newCodes = newCodesPre;
4660
- const end = (skipLastIfStreaming && newCodes.length > 0) ? (newCodes.length - 1) : newCodes.length;
4661
- let reuseCount = 0;
4662
- for (let i = 0; i < end; i++) {
4663
- const nc = newCodes[i];
4664
- if (nc.getAttribute('data-highlighted') === 'yes') continue;
4665
- // Fingerprint new code: prefer wrapper meta (no .textContent read)
4666
- let fp = this.codeFingerprintFromWrapper(nc);
4667
- if (!fp) fp = this.codeFingerprint(nc);
4668
- const arr = map.get(fp);
4669
- if (arr && arr.length) {
4670
- const oldEl = arr.shift();
4671
- if (oldEl && oldEl.isConnected) {
4672
- try {
4673
- nc.replaceWith(oldEl);
4674
- this.codeScroll.attachHandlers(oldEl);
4675
- // Preserve whatever final state the old element had
4676
- if (!oldEl.getAttribute('data-highlighted')) oldEl.setAttribute('data-highlighted', 'yes');
4677
- const st = this.codeScroll.state(oldEl); st.autoFollow = false;
4678
- reuseCount++;
4679
- } catch (_) {}
4680
- }
4681
- 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 (_) {}
4682
4699
  }
4700
+ if (!arr.length) map.delete(fp);
4683
4701
  }
4684
- if (reuseCount) this._d('PRESERVE_CODES_REUSED', { reuseCount, skipLastIfStreaming });
4685
- } catch (e) {
4686
- this._d('PRESERVE_CODES_ERROR', String(e));
4687
4702
  }
4703
+ if (reuseCount) this._d('PRESERVE_CODES_REUSED', { reuseCount, skipLastIfStreaming });
4704
+ } catch (e) {
4705
+ this._d('PRESERVE_CODES_ERROR', String(e));
4688
4706
  }
4689
- // Ensure blocks marked as just-finalized are scrolled to bottom.
4690
- _ensureBottomForJustFinalized(root) {
4691
- try {
4692
- const scope = root || document;
4693
- const nodes = scope.querySelectorAll('pre code[data-just-finalized="1"]');
4694
- if (!nodes || !nodes.length) return;
4695
- nodes.forEach((codeEl) => {
4696
- this.codeScroll.scheduleScroll(codeEl, false, true);
4697
- const key = { t: 'JF:forceBottom', el: codeEl, n: Math.random() };
4698
- this.raf.schedule(key, () => {
4699
- this.codeScroll.scrollToBottom(codeEl, false, true);
4700
- try { codeEl.dataset.justFinalized = '0'; } catch (_) {}
4701
- }, 'CodeScroll', 2);
4702
- });
4703
- } catch (_) {}
4704
- }
4705
- // If stream is visible but something got stuck, force a quick refresh.
4706
- kickVisibility() {
4707
- const msg = this.getMsg(false, '');
4708
- if (!msg) return;
4709
- if (this.codeStream.open && !this.activeCode) {
4710
- this.scheduleSnapshot(msg, true);
4711
- return;
4712
- }
4713
- const needSnap = (this.getStreamLength() !== (window.__lastSnapshotLen || 0));
4714
- if (needSnap) this.scheduleSnapshot(msg, true);
4715
- if (this.activeCode && this.activeCode.codeEl) {
4716
- this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4717
- this.schedulePromoteTail(true);
4718
- }
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);
4719
4735
  }
4720
- // Keep language header stable across snapshots for the active streaming code.
4721
- // If the current snapshot produced a tiny/unsupported token (e.g. 'on', 'ml', 's'),
4722
- // reuse the last known good language (from previous active state or sticky attribute).
4723
- stabilizeHeaderLabel(prevAC, newAC) {
4724
- try {
4725
- if (!newAC || !newAC.codeEl || !newAC.codeEl.isConnected) return;
4736
+ }
4737
+ stabilizeHeaderLabel(prevAC, newAC) {
4738
+ try {
4739
+ if (!newAC || !newAC.codeEl || !newAC.codeEl.isConnected) return;
4726
4740
 
4727
- const wrap = newAC.codeEl.closest('.code-wrapper');
4728
- if (!wrap) return;
4741
+ const wrap = newAC.codeEl.closest('.code-wrapper');
4742
+ if (!wrap) return;
4729
4743
 
4730
- const span = wrap.querySelector('.code-header-lang');
4731
- 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();
4732
4746
 
4733
- // Do not touch tool/output blocks
4734
- if (curLabel === 'output') return;
4747
+ if (curLabel === 'output') return;
4735
4748
 
4736
- const tokNow = (wrap.getAttribute('data-code-lang') || '').trim().toLowerCase();
4737
- const sticky = (wrap.getAttribute('data-lang-sticky') || '').trim().toLowerCase();
4738
- 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() : '';
4739
4752
 
4740
- const valid = (t) => !!t && t !== 'plaintext' && this._isHLJSSupported(t);
4753
+ const valid = (t) => !!t && t !== 'plaintext' && this._isHLJSSupported(t);
4741
4754
 
4742
- let finalTok = '';
4743
- if (valid(tokNow)) finalTok = tokNow;
4744
- else if (valid(prev)) finalTok = prev;
4745
- 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;
4746
4759
 
4747
- if (finalTok) {
4748
- // Update code class and header label consistently
4749
- this._updateCodeLangClass(newAC.codeEl, finalTok);
4750
- this._updateCodeHeaderLabel(newAC.codeEl, finalTok, finalTok);
4751
- try { wrap.setAttribute('data-code-lang', finalTok); } catch (_) {}
4752
- try { wrap.setAttribute('data-lang-sticky', finalTok); } catch (_) {}
4753
- newAC.lang = finalTok; // keep AC state in sync
4754
- } else {
4755
- // If current label looks like a tiny/incomplete token, normalize to 'code'
4756
- if (span && curLabel && curLabel.length < 3) {
4757
- span.textContent = 'code';
4758
- }
4759
- }
4760
- } catch (_) { /* defensive: never break streaming path */ }
4761
- }
4762
- // Render a snapshot of current stream buffer into the DOM.
4763
- renderSnapshot(msg) {
4764
- const streaming = !!this.isStreaming;
4765
- const snap = this.getMsgSnapshotRoot(msg); if (!snap) return;
4766
-
4767
- // No-op if nothing changed and no active code
4768
- const prevLen = (window.__lastSnapshotLen || 0);
4769
- const curLen = this.getStreamLength();
4770
- if (!this.fenceOpen && !this.activeCode && curLen === prevLen) {
4771
- this.lastSnapshotTs = Utils.now();
4772
- this._d('SNAPSHOT_SKIPPED_NO_DELTA', { bufLen: curLen });
4773
- 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
+ }
4774
4770
  }
4771
+ } catch (_) { /* defensive: never break streaming path */ }
4772
+ }
4775
4773
 
4776
- const t0 = Utils.now();
4774
+ renderSnapshot(msg) {
4775
+ const streaming = !!this.isStreaming;
4776
+ const snap = this.getMsgSnapshotRoot(msg); if (!snap) return;
4777
4777
 
4778
- // When an open fence is present, append a synthetic EOL only if the current buffer
4779
- // does not already end with EOL. This stabilizes markdown-it parsing without polluting
4780
- // the real code tail (we will strip this EOL from the active tail right after snapshot).
4781
- const allText = this.getStreamText();
4782
- const needSyntheticEOL = (this.fenceOpen && !/[\r\n]$/.test(allText));
4783
- this._lastInjectedEOL = !!needSyntheticEOL;
4784
- 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
+ }
4785
4785
 
4786
- // Use streaming renderer (no linkify) on hot path to reduce CPU/allocs
4787
- const html = streaming ? this.renderer.renderStreamingSnapshot(src) : this.renderer.renderFinalSnapshot(src);
4786
+ const t0 = Utils.now();
4788
4787
 
4789
- // parse HTML into a DocumentFragment directly to avoid intermediate container allocations.
4790
- let frag = null;
4791
- try {
4792
- const range = document.createRange();
4793
- range.selectNodeContents(snap);
4794
- frag = range.createContextualFragment(html);
4795
- } catch (_) {
4796
- const tmp = document.createElement('div');
4797
- tmp.innerHTML = html;
4798
- frag = document.createDocumentFragment();
4799
- while (tmp.firstChild) frag.appendChild(tmp.firstChild);
4800
- }
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;
4801
4795
 
4802
- // (stream-aware custom markup):
4803
- // Apply Custom Markup on the fragment only if at least one rule opted-in for stream.
4804
- try {
4805
- if (this.renderer && this.renderer.customMarkup && this.renderer.customMarkup.hasStreamRules()) {
4806
- const MDinline = this.renderer.MD_STREAM || this.renderer.MD || null;
4807
- this.renderer.customMarkup.applyStream(frag, MDinline);
4808
- }
4809
- } catch (_) { /* keep snapshot path resilient */ }
4796
+ const html = streaming ? this.renderer.renderStreamingSnapshot(src) : this.renderer.renderFinalSnapshot(src);
4810
4797
 
4811
- // Reuse closed, stable code blocks from previous snapshot to avoid re-highlighting
4812
- 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
+ }
4813
4809
 
4814
- // Replace content
4815
- 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 */ }
4816
4817
 
4817
- // Restore code UI state and ensure bottoming for freshly finalized elements
4818
- this.renderer.restoreCollapsedCode(snap);
4819
- this._ensureBottomForJustFinalized(snap);
4818
+ this.preserveStableClosedCodes(snap, frag, this.fenceOpen === true);
4820
4819
 
4821
- // Setup active streaming code if fence is open, otherwise clear active state
4822
- const prevAC = this.activeCode; // remember previous active streaming state (if any)
4820
+ snap.replaceChildren(frag);
4823
4821
 
4824
- if (this.fenceOpen) {
4825
- const newAC = this.setupActiveCodeFromSnapshot(snap);
4822
+ this.renderer.restoreCollapsedCode(snap);
4823
+ this._ensureBottomForJustFinalized(snap);
4826
4824
 
4827
- // preserve previous frozen/tail state and stable lang/header across snapshots
4828
- if (prevAC && newAC) {
4829
- this.rehydrateActiveCode(prevAC, newAC);
4830
- this.stabilizeHeaderLabel(prevAC, newAC);
4831
- }
4825
+ const prevAC = this.activeCode;
4832
4826
 
4833
- this.activeCode = newAC || null;
4834
- } else {
4835
- this.activeCode = null;
4836
- }
4827
+ if (this.fenceOpen) {
4828
+ const newAC = this.setupActiveCodeFromSnapshot(snap);
4837
4829
 
4838
- // Attach scroll/highlight observers (viewport aware)
4839
- if (!this.fenceOpen) {
4840
- this.codeScroll.initScrollableBlocks(snap);
4830
+ if (prevAC && newAC) {
4831
+ this.rehydrateActiveCode(prevAC, newAC);
4832
+ this.stabilizeHeaderLabel(prevAC, newAC);
4841
4833
  }
4842
- 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, {
4843
4850
  deferLastIfStreaming: true,
4844
4851
  minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
4845
4852
  minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
4846
4853
  }, this.activeCode);
4847
- this.highlighter.observeMsgBoxes(snap, (box) => {
4848
- this.highlighter.observeNewCode(box, {
4849
- deferLastIfStreaming: true,
4850
- minLinesForLast: this.cfg.PROFILE_CODE.minLinesForHL,
4851
- minCharsForLast: this.cfg.PROFILE_CODE.minCharsForHL
4852
- }, this.activeCode);
4853
- this.codeScroll.initScrollableBlocks(box);
4854
- });
4854
+ this.codeScroll.initScrollableBlocks(box);
4855
+ });
4855
4856
 
4856
- // Schedule math render according to mode; keep "finalize-only" cheap on hot path
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
- }
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
+ }
4862
4862
 
4863
- // If streaming code is visible, keep it glued to bottom
4864
- if (this.fenceOpen && this.activeCode && this.activeCode.codeEl) {
4865
- this.codeScroll.attachHandlers(this.activeCode.codeEl);
4866
- this.codeScroll.scheduleScroll(this.activeCode.codeEl, true, false);
4867
- } else if (!this.fenceOpen) {
4868
- this.codeScroll.initScrollableBlocks(snap);
4869
- }
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
+ }
4870
4869
 
4871
- // Advance snapshot budget and remember progress
4872
- window.__lastSnapshotLen = this.getStreamLength();
4873
- this.lastSnapshotTs = Utils.now();
4870
+ window.__lastSnapshotLen = this.getStreamLength();
4871
+ this.lastSnapshotTs = Utils.now();
4874
4872
 
4875
- const prof = this.profile();
4876
- if (prof.adaptiveStep) {
4877
- const maxStep = this.cfg.STREAM.SNAPSHOT_MAX_STEP || 8000;
4878
- this.nextSnapshotStep = Math.min(Math.ceil(this.nextSnapshotStep * prof.growth), maxStep);
4879
- } else {
4880
- this.nextSnapshotStep = prof.base;
4881
- }
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
+ }
4882
4880
 
4883
- // Keep page scroll/fab in sync
4884
- this.scrollMgr.scheduleScroll(true);
4885
- this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
4886
- this.scrollMgr.scheduleScrollFabUpdate();
4881
+ this.scrollMgr.scheduleScroll(true);
4882
+ this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
4883
+ this.scrollMgr.scheduleScrollFabUpdate();
4887
4884
 
4888
- if (this.suppressPostFinalizePass) this.suppressPostFinalizePass = false;
4885
+ if (this.suppressPostFinalizePass) this.suppressPostFinalizePass = false;
4889
4886
 
4890
- const dt = Utils.now() - t0;
4891
- this._d('SNAPSHOT', { fenceOpen: this.fenceOpen, activeCode: !!this.activeCode, bufLen: this.getStreamLength(), timeMs: Math.round(dt), streaming });
4892
- }
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
+ }
4893
4890
 
4894
- // Get current message container (.msg) or create if allowed.
4895
- getMsg(create, name_header) { return this.dom.getStreamMsg(create, name_header); }
4896
- // Start a new streaming session (clear state and display loader, if any).
4897
- beginStream(chunk = false) {
4898
- this.isStreaming = true; // engage streaming mode (no linkify etc.)
4899
- if (chunk) runtime.loading.hide();
4900
- this.scrollMgr.userInteracted = false;
4901
- this.dom.clearOutput();
4902
- this.reset();
4903
- this.scrollMgr.forceScrollToBottomImmediate();
4904
- this.scrollMgr.scheduleScroll();
4905
- 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);
4906
4921
  }
4907
- // End streaming session, finalize active code if present, and complete math/highlight.
4908
- endStream() {
4909
- // Switch to final mode before the last snapshot to allow full renderer (linkify etc.)
4910
- this.isStreaming = false;
4911
4922
 
4912
- const msg = this.getMsg(false, '');
4913
- 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;
4914
4933
 
4915
- this.snapshotScheduled = false;
4916
- try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4917
- this.snapshotRAF = 0;
4934
+ // Do not interfere with code fence streaming.
4935
+ if (this.fenceOpen || this.codeStream.open) return;
4918
4936
 
4919
- const hadActive = !!this.activeCode;
4920
- if (this.activeCode) this.finalizeActiveCode();
4937
+ const isFirstSnapshot = ((window.__lastSnapshotLen || 0) === 0);
4921
4938
 
4922
- if (!hadActive) {
4923
- if (this.highlighter.hlQueue && this.highlighter.hlQueue.length) {
4924
- 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;
4925
4947
  }
4926
- const snap = msg ? this.getMsgSnapshotRoot(msg) : null;
4927
- if (snap) this.math.renderAsync(snap); // ensure math completes eagerly but async
4928
4948
  }
4929
4949
 
4930
- this.fenceOpen = false; this.codeStream.open = false; this.activeCode = null; this.lastSnapshotTs = Utils.now();
4931
- this.suppressPostFinalizePass = false;
4932
- this._d('END_STREAM', { hadActive });
4933
- }
4934
- // Apply incoming chunk to stream buffer and update DOM when needed.
4935
- applyStream(name_header, chunk, alreadyBuffered = false) {
4936
- if (!this.activeCode && !this.fenceOpen) {
4937
- 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
4938
4956
  }
4939
- if (this.snapshotScheduled && !this.raf.isScheduled('SE:snapshot')) this.snapshotScheduled = false;
4957
+ } catch (_) {
4958
+ // Keep streaming resilient; never throw here.
4959
+ }
4960
+ }
4940
4961
 
4941
- const msg = this.getMsg(true, name_header); if (!msg || !chunk) return;
4942
- const s = String(chunk);
4943
- 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;
4944
4968
 
4945
- const change = this.updateFenceHeuristic(s);
4946
- 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);
4947
4972
 
4948
- 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;
4949
4975
 
4950
- // Track if we just materialized the first code-open snapshot synchronously.
4951
- 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 });
4952
4977
 
4953
- if (change.opened) {
4954
- this.codeStream.open = true; this.codeStream.lines = 0; this.codeStream.chars = 0;
4955
- this.resetBudget();
4956
- this.scheduleSnapshot(msg);
4957
- 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
+ }
4958
4983
 
4959
- // Fast-path: if stream starts with a code fence and no snapshot was made yet,
4960
- // immediately materialize the code block so tail streaming can proceed without click.
4961
- if (!this._firstCodeOpenSnapDone && !this.activeCode && ((window.__lastSnapshotLen || 0) === 0)) {
4962
- try {
4963
- this.renderSnapshot(msg);
4964
- try { this.raf.cancel('SE:snapshot'); } catch (_) {}
4965
- this.snapshotScheduled = false;
4966
- this._firstCodeOpenSnapDone = true;
4967
- didImmediateOpenSnap = true;
4968
- this._d('CODE_OPEN_IMMEDIATE_SNAPSHOT', { bufLen: this.getStreamLength() });
4969
- } catch (_) {
4970
- // Keep going; normal scheduled snapshot will land soon.
4971
- }
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.
4972
5003
  }
4973
5004
  }
5005
+ }
4974
5006
 
4975
- if (this.codeStream.open) {
4976
- this.codeStream.lines += nlCount; this.codeStream.chars += s.length;
5007
+ if (this.codeStream.open) {
5008
+ this.codeStream.lines += nlCount; this.codeStream.chars += s.length;
4977
5009
 
4978
- if (this.activeCode && this.activeCode.codeEl && this.activeCode.codeEl.isConnected) {
4979
- let partForCode = s; let remainder = '';
5010
+ if (this.activeCode && this.activeCode.codeEl && this.activeCode.codeEl.isConnected) {
5011
+ let partForCode = s; let remainder = '';
4980
5012
 
4981
- if (didImmediateOpenSnap) {
4982
- partForCode = '';
4983
- } else {
4984
- if (change.closed && change.splitAt >= 0 && change.splitAt <= s.length) {
4985
- partForCode = s.slice(0, change.splitAt); remainder = s.slice(change.splitAt);
4986
- }
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);
4987
5018
  }
5019
+ }
4988
5020
 
4989
- if (partForCode) {
4990
- this.appendToActiveTail(partForCode);
4991
- this.activeCode.lines += Utils.countNewlines(partForCode);
5021
+ if (partForCode) {
5022
+ this.appendToActiveTail(partForCode);
5023
+ this.activeCode.lines += Utils.countNewlines(partForCode);
4992
5024
 
4993
- this.maybePromoteLanguageFromDirective();
4994
- this.enforceHLStopBudget();
5025
+ this.maybePromoteLanguageFromDirective();
5026
+ this.enforceHLStopBudget();
4995
5027
 
4996
- if (!this.activeCode.haltHL) {
4997
- if (partForCode.indexOf('\n') >= 0 || (this.activeCode.tailEl.textContent || '').length >= this.cfg.PROFILE_CODE.minCharsForHL) {
4998
- this.schedulePromoteTail(false);
4999
- }
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);
5000
5031
  }
5001
5032
  }
5002
- this.scrollMgr.scrollFabUpdateScheduled = false;
5003
- this.scrollMgr.scheduleScroll(true);
5004
- this.scrollMgr.fabFreezeUntil = Utils.now() + this.cfg.FAB.TOGGLE_DEBOUNCE_MS;
5005
- this.scrollMgr.scheduleScrollFabUpdate();
5006
-
5007
- if (change.closed) {
5008
- this.finalizeActiveCode();
5009
- this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
5010
- this._d('CODE_STREAM_CLOSE_FINALIZED', { remainderLen: remainder.length });
5011
- if (remainder && remainder.length) { this.applyStream(name_header, remainder, true); }
5012
- }
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);
5013
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 });
5014
5054
  } else {
5015
- if (!this.activeCode && (this.codeStream.lines >= 2 || this.codeStream.chars >= 80)) {
5016
- this.scheduleSnapshot(msg, true);
5017
- return;
5018
- }
5019
- if (change.closed) {
5020
- this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
5021
- this._d('CODE_CLOSED_WITHOUT_ACTIVE', { sinceLastSnapMs: Math.round(Utils.now() - this.lastSnapshotTs), snapshotScheduled: this.snapshotScheduled });
5022
- } else {
5023
- const boundary = this.hasStructuralBoundary(s);
5024
- if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) this.scheduleSnapshot(msg);
5025
- else this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
5026
- }
5027
- return;
5055
+ const boundary = this.hasStructuralBoundary(s);
5056
+ if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) this.scheduleSnapshot(msg);
5057
+ else this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
5028
5058
  }
5059
+ return;
5029
5060
  }
5061
+ }
5030
5062
 
5031
- if (change.closed) {
5032
- this.codeStream.open = false; this.resetBudget(); this.scheduleSnapshot(msg);
5033
- 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 });
5034
5071
  } else {
5035
- const boundary = this.hasStructuralBoundary(s);
5036
- if (this.shouldSnapshotOnChunk(s, chunkHasNL, boundary)) {
5037
- this.scheduleSnapshot(msg);
5038
- this._d('SCHEDULE_SNAPSHOT_BOUNDARY', { boundary });
5039
- } else {
5040
- this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
5041
- }
5072
+ this.maybeScheduleSoftSnapshot(msg, chunkHasNL);
5042
5073
  }
5043
5074
  }
5044
5075
  }
5076
+ }
5045
5077
 
5046
5078
  // ==========================================================================
5047
5079
  // 12) Stream queue