mrmd-editor 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +3 -1
  2. package/src/commands.js +112 -4
  3. package/src/comment-syntax.js +364 -39
  4. package/src/config/handlers.js +1 -2
  5. package/src/config/schema.js +46 -4
  6. package/src/document-template.js +2236 -0
  7. package/src/frontmatter-updater.js +204 -74
  8. package/src/grammar.js +758 -0
  9. package/src/index.js +1074 -55
  10. package/src/keymap.js +11 -2
  11. package/src/markdown/block-decorations.js +108 -5
  12. package/src/markdown/facets.js +37 -0
  13. package/src/markdown/html-inline.js +9 -5
  14. package/src/markdown/index.js +13 -3
  15. package/src/markdown/inline-commands.js +256 -0
  16. package/src/markdown/inline-model.js +578 -0
  17. package/src/markdown/inline-state.js +103 -0
  18. package/src/markdown/renderer.js +219 -12
  19. package/src/markdown/styles.js +290 -3
  20. package/src/markdown/widgets/alert-title.js +10 -8
  21. package/src/markdown/widgets/frontmatter.js +0 -6
  22. package/src/markdown/widgets/index.js +1 -0
  23. package/src/markdown/widgets/list-marker.js +29 -0
  24. package/src/markdown/wysiwyg.js +1158 -0
  25. package/src/mrp-types.js +2 -0
  26. package/src/output-widget.js +532 -18
  27. package/src/page-view-pagination.js +127 -0
  28. package/src/runtime-lsp.js +1757 -150
  29. package/src/section-controls/commands.js +617 -0
  30. package/src/section-controls/index.js +63 -0
  31. package/src/section-controls/plugin.js +165 -0
  32. package/src/section-controls/widgets.js +936 -0
  33. package/src/shell/ai-menu.js +11 -0
  34. package/src/shell/components/context-panel.js +572 -0
  35. package/src/shell/components/status-bar.js +10 -2
  36. package/src/shell/layouts/studio.js +206 -14
  37. package/src/shell/orchestrator-client.js +69 -0
  38. package/src/spellcheck.js +166 -0
  39. package/src/tables/README.md +97 -0
  40. package/src/tables/commands/insert-linked-table.js +122 -0
  41. package/src/tables/commands/open-table-workspace.js +43 -0
  42. package/src/tables/index.js +24 -0
  43. package/src/tables/jobs/client.js +158 -0
  44. package/src/tables/parsing/anchors.js +82 -0
  45. package/src/tables/parsing/linked-table-blocks.js +61 -0
  46. package/src/tables/state/linked-table-state.js +68 -0
  47. package/src/tables/widgets/linked-table-source-banner.js +77 -0
  48. package/src/tables/widgets/linked-table-widget.js +256 -0
  49. package/src/tables/workspace/controller.js +616 -0
  50. package/src/term-pty-client.js +51 -2
  51. package/src/term-widget.js +43 -3
  52. package/src/widgets/theme-utils.js +24 -16
  53. package/src/widgets/theme.js +1015 -1
  54. package/src/runtime-codelens/detector.js +0 -279
  55. package/src/runtime-codelens/index.js +0 -76
  56. package/src/runtime-codelens/plugin.js +0 -142
  57. package/src/runtime-codelens/styles.js +0 -184
  58. package/src/runtime-codelens/widgets.js +0 -216
@@ -16,8 +16,9 @@
16
16
  * @module runtime-lsp
17
17
  */
18
18
 
19
- import { hoverTooltip } from '@codemirror/view';
20
- import { autocompletion, CompletionContext, startCompletion } from '@codemirror/autocomplete';
19
+ import { hoverTooltip, closeHoverTooltips, showTooltip, ViewPlugin } from '@codemirror/view';
20
+ import { StateField, StateEffect } from '@codemirror/state';
21
+ import { autocompletion, startCompletion } from '@codemirror/autocomplete';
21
22
  import { getCellAtCursor, findCells } from './cells.js';
22
23
 
23
24
  // #region INTERFACES
@@ -42,6 +43,7 @@ import { getCellAtCursor, findCells } from './cells.js';
42
43
  * @property {string} [value]
43
44
  * @property {string} [signature]
44
45
  * @property {string} [documentation]
46
+ * @property {string} [docstring]
45
47
  */
46
48
 
47
49
  /**
@@ -61,6 +63,9 @@ import { getCellAtCursor, findCells } from './cells.js';
61
63
  * @property {string} [valuePreview]
62
64
  * @property {string} [documentation]
63
65
  * @property {string} [insertText]
66
+ * @property {number} [boost]
67
+ * @property {string} [sortText]
68
+ * @property {string|{name: string, rank?: number|'dynamic'}} [section]
64
69
  */
65
70
 
66
71
  /**
@@ -132,6 +137,8 @@ export function adaptMrmdJsSession(session) {
132
137
  type: result.type,
133
138
  value: result.value,
134
139
  signature: result.signature,
140
+ documentation: result.documentation || result.docstring,
141
+ docstring: result.docstring,
135
142
  };
136
143
  } catch (e) {
137
144
  console.warn('mrmd-js hover error:', e);
@@ -229,7 +236,10 @@ export function adaptMRPClient(client, languages) {
229
236
  try {
230
237
  const result = await client.hover({ code, cursor });
231
238
  if (!result || !result.found) return null;
232
- return result;
239
+ return {
240
+ ...result,
241
+ documentation: result.documentation || result.docstring,
242
+ };
233
243
  } catch (e) {
234
244
  console.warn('MRP hover error:', e);
235
245
  return null;
@@ -316,151 +326,1346 @@ function getLanguageAtPosition(content, pos) {
316
326
  }
317
327
 
318
328
  /**
319
- * Get code within a cell for a given position.
320
- * Returns the code and the offset of the cursor within that code.
329
+ * Get code within a cell for a given position.
330
+ * Returns the code and the offset of the cursor within that code.
331
+ *
332
+ * @param {string} content - Document content
333
+ * @param {number} pos - Document position
334
+ * @returns {{code: string, offset: number, language: string, cell: Object}|null}
335
+ */
336
+ function getCodeAtPosition(content, pos) {
337
+ const cell = getCellAtCursor(content, pos);
338
+ if (!cell) return null;
339
+
340
+ // Calculate offset within the cell's code
341
+ const offset = pos - cell.codeStart;
342
+ if (offset < 0 || offset > cell.code.length) return null;
343
+
344
+ return {
345
+ code: cell.code,
346
+ offset,
347
+ language: cell.language,
348
+ cell,
349
+ };
350
+ }
351
+
352
+ // #endregion LANGUAGE_DETECTION
353
+
354
+ // #region HOVER_EXTENSION
355
+
356
+ const setPinnedHoverTooltip = StateEffect.define();
357
+ const clearPinnedHoverTooltip = StateEffect.define();
358
+
359
+ const pinnedHoverTooltipField = StateField.define({
360
+ create() {
361
+ return null;
362
+ },
363
+ update(value, tr) {
364
+ if (tr.docChanged) return null;
365
+
366
+ for (const effect of tr.effects) {
367
+ if (effect.is(setPinnedHoverTooltip)) return effect.value;
368
+ if (effect.is(clearPinnedHoverTooltip)) return null;
369
+ }
370
+
371
+ return value;
372
+ },
373
+ provide: (f) => showTooltip.from(f),
374
+ });
375
+
376
+ function looksLikeFunctionRepr(value) {
377
+ return typeof value === 'string' && /^<function\s+[^>]+\s+at\s+0x[0-9a-f]+>$/i.test(value.trim());
378
+ }
379
+
380
+ function cleanDocsText(text) {
381
+ if (!text) return '';
382
+ return String(text)
383
+ .replace(/(?:<function\s+[^>]+\s+at\s+0x[0-9a-f]+>)+\s*$/ig, '')
384
+ .trim();
385
+ }
386
+
387
+ function formatHoverText(result) {
388
+ if (!result) return '';
389
+ const parts = [];
390
+
391
+ const nameType = [result.name, result.type].filter(Boolean).join(' : ');
392
+ if (nameType) parts.push(nameType);
393
+ if (result.signature) parts.push(result.signature);
394
+
395
+ const suppressValue = !!result.signature && looksLikeFunctionRepr(result.value);
396
+ if (result.value && !suppressValue) parts.push(result.value);
397
+
398
+ const docsText = cleanDocsText(result.documentation || result.docstring);
399
+ if (docsText) parts.push(docsText);
400
+
401
+ if (result.file) {
402
+ parts.push(`Source: ${result.file}${result.line ? `:${result.line}` : ''}`);
403
+ }
404
+
405
+ return parts.join('\n\n');
406
+ }
407
+
408
+ function createHoverTooltipDescriptor(view, hoverResult, pos, end, { sticky = false } = {}) {
409
+ return {
410
+ pos,
411
+ end,
412
+ above: false,
413
+ arrow: true,
414
+ create() {
415
+ const dom = document.createElement('div');
416
+ dom.className = `mrmd-runtime-hover${sticky ? ' mrmd-runtime-hover-sticky' : ''}`;
417
+ dom.innerHTML = formatHoverContent(hoverResult);
418
+
419
+ const copyBtn = dom.querySelector('.mrmd-hover-copy');
420
+ if (copyBtn) {
421
+ copyBtn.addEventListener('mousedown', (event) => {
422
+ event.stopPropagation();
423
+ });
424
+
425
+ copyBtn.addEventListener('click', async (event) => {
426
+ event.preventDefault();
427
+ event.stopPropagation();
428
+
429
+ const text = formatHoverText(hoverResult);
430
+ if (!text) return;
431
+
432
+ try {
433
+ await navigator.clipboard?.writeText(text);
434
+ const original = copyBtn.textContent;
435
+ copyBtn.textContent = 'Copied';
436
+ setTimeout(() => {
437
+ copyBtn.textContent = original || 'Copy';
438
+ }, 900);
439
+ } catch {
440
+ // ignore
441
+ }
442
+ });
443
+ }
444
+
445
+ const sourceLink = dom.querySelector('.mrmd-hover-source-link');
446
+ if (sourceLink) {
447
+ sourceLink.addEventListener('mousedown', (event) => {
448
+ event.stopPropagation();
449
+ });
450
+ }
451
+
452
+ if (!sticky) {
453
+ dom.addEventListener('mousedown', (event) => {
454
+ if (event.button !== 0) return;
455
+ if (event.target instanceof Element && event.target.closest('.mrmd-hover-copy')) return;
456
+
457
+ const stickyTooltip = createHoverTooltipDescriptor(view, hoverResult, pos, end, { sticky: true });
458
+ view.dispatch({
459
+ effects: [
460
+ setPinnedHoverTooltip.of(stickyTooltip),
461
+ closeHoverTooltips,
462
+ ],
463
+ });
464
+ });
465
+ }
466
+
467
+ return {
468
+ dom,
469
+ offset: { x: 0, y: -8 },
470
+ overlap: true,
471
+ };
472
+ },
473
+ };
474
+ }
475
+
476
+ const pinnedHoverClosePlugin = ViewPlugin.fromClass(
477
+ class {
478
+ constructor(view) {
479
+ this.view = view;
480
+ this.onMouseDownCapture = (event) => {
481
+ const pinned = view.state.field(pinnedHoverTooltipField, false);
482
+ if (!pinned) return;
483
+
484
+ const target = event.target;
485
+ if (target instanceof Element && target.closest('.mrmd-runtime-hover')) return;
486
+
487
+ view.dispatch({ effects: clearPinnedHoverTooltip.of(null) });
488
+ };
489
+
490
+ view.dom.ownerDocument.addEventListener('mousedown', this.onMouseDownCapture, true);
491
+ }
492
+
493
+ destroy() {
494
+ this.view.dom.ownerDocument.removeEventListener('mousedown', this.onMouseDownCapture, true);
495
+ }
496
+ }
497
+ );
498
+
499
+ /**
500
+ * Create a CodeMirror hover tooltip extension powered by runtime LSP.
501
+ *
502
+ * Shows actual runtime values when hovering over variables/symbols.
503
+ *
504
+ * @param {Object} options
505
+ * @param {Map<string, RuntimeLSPProvider>} options.providers - Language → provider map
506
+ * @param {function(): string} options.getContent - Get document content
507
+ * @param {import('./awareness/state.js').AwarenessStateManager} [options.stateManager] - For awareness broadcast
508
+ * @param {import('yjs').Text} [options.yText] - For RelativePosition tracking
509
+ * @returns {import('@codemirror/state').Extension}
510
+ */
511
+ export function createRuntimeHoverExtension({ providers, getContent, stateManager, yText }) {
512
+ const hoverExtension = hoverTooltip(
513
+ async (view, pos, side) => {
514
+ const content = getContent();
515
+ const codeInfo = getCodeAtPosition(content, pos);
516
+
517
+ if (!codeInfo) return null;
518
+
519
+ // Find provider for this language
520
+ const provider = findProviderForLanguage(providers, codeInfo.language);
521
+ if (!provider) return null;
522
+
523
+ // Get hover info from runtime
524
+ let hoverResult = await provider.hover(codeInfo.code, codeInfo.offset, codeInfo.language);
525
+ if (!hoverResult || !hoverResult.found) return null;
526
+
527
+ // If hover payload is minimal, enrich with inspect docstring/signature/source metadata.
528
+ // This keeps hover snappy for runtimes that already return rich hover data,
529
+ // while still showing docs/location for runtimes that only return basic hover fields.
530
+ const needsInspectEnrichment =
531
+ (!hoverResult.documentation && !hoverResult.docstring) ||
532
+ (!hoverResult.file && !hoverResult.line);
533
+
534
+ if (needsInspectEnrichment && provider.inspect) {
535
+ try {
536
+ const inspectResult = await provider.inspect(
537
+ codeInfo.code,
538
+ codeInfo.offset,
539
+ codeInfo.language,
540
+ { detail: 1 }
541
+ );
542
+ if (inspectResult?.found) {
543
+ hoverResult = {
544
+ ...inspectResult,
545
+ ...hoverResult,
546
+ // Prefer explicit hover value/name if present, but fill missing docs/signature/location.
547
+ documentation:
548
+ hoverResult.documentation ||
549
+ hoverResult.docstring ||
550
+ inspectResult.documentation ||
551
+ inspectResult.docstring,
552
+ docstring: hoverResult.docstring || inspectResult.docstring,
553
+ signature: hoverResult.signature || inspectResult.signature,
554
+ file: hoverResult.file || inspectResult.file,
555
+ line: hoverResult.line || inspectResult.line,
556
+ };
557
+ }
558
+ } catch {
559
+ // Ignore enrichment errors; base hover still works.
560
+ }
561
+ }
562
+
563
+ // Broadcast to awareness if available
564
+ if (stateManager) {
565
+ const position = yText
566
+ ? await import('yjs').then(Y => Y.createRelativePositionFromTypeIndex(yText, pos))
567
+ : { line: view.state.doc.lineAt(pos).number, ch: pos - view.state.doc.lineAt(pos).from };
568
+
569
+ stateManager.setHover({
570
+ symbol: hoverResult.name,
571
+ type: hoverResult.type,
572
+ info: hoverResult.value || hoverResult.signature,
573
+ position,
574
+ cellIndex: getCellIndex(content, codeInfo.cell),
575
+ });
576
+ }
577
+
578
+ // Create tooltip DOM
579
+ return createHoverTooltipDescriptor(
580
+ view,
581
+ hoverResult,
582
+ pos,
583
+ pos + Math.max(1, hoverResult.name?.length || 0),
584
+ { sticky: false }
585
+ );
586
+ },
587
+ {
588
+ hoverTime: 300,
589
+ hideOnChange: false,
590
+ }
591
+ );
592
+
593
+ return [
594
+ hoverExtension,
595
+ pinnedHoverTooltipField,
596
+ pinnedHoverClosePlugin,
597
+ ];
598
+ }
599
+
600
+ /**
601
+ * Format hover content as HTML.
602
+ *
603
+ * @param {HoverResult} result
604
+ * @returns {string}
605
+ */
606
+ function formatHoverContent(result) {
607
+ let html = '<div class="mrmd-hover-content">';
608
+
609
+ // Header row
610
+ html += '<div class="mrmd-hover-header">';
611
+ html += '<div class="mrmd-hover-name">';
612
+
613
+ if (result.name) {
614
+ html += `<code>${escapeHtml(result.name)}</code>`;
615
+ }
616
+ if (result.type) {
617
+ html += ` <span class="mrmd-hover-type">${escapeHtml(result.type)}</span>`;
618
+ }
619
+
620
+ html += '</div>';
621
+ html += '<button class="mrmd-hover-copy" type="button" title="Copy hover details">Copy</button>';
622
+ html += '</div>';
623
+
624
+ // Signature (for functions)
625
+ if (result.signature) {
626
+ html += `<div class="mrmd-hover-signature"><code>${escapeHtml(result.signature)}</code></div>`;
627
+ }
628
+
629
+ // Value preview (skip noisy function repr when we already have signature)
630
+ const suppressValue = !!result.signature && looksLikeFunctionRepr(result.value);
631
+ if (result.value && !suppressValue) {
632
+ html += `<div class="mrmd-hover-value">${escapeHtml(result.value)}</div>`;
633
+ }
634
+
635
+ // Documentation / docstring
636
+ const docsText = cleanDocsText(result.documentation || result.docstring);
637
+ if (docsText) {
638
+ html += `<div class="mrmd-hover-docs">${escapeHtml(docsText)}</div>`;
639
+ }
640
+
641
+ // Source location (when provided by runtime inspect)
642
+ if (result.file) {
643
+ const locationText = `${result.file}${result.line ? `:${result.line}` : ''}`;
644
+ if (result.file.startsWith('/')) {
645
+ const fileHref = `file://${encodeURI(result.file)}`;
646
+ html += `<div class="mrmd-hover-source">Source: <a class="mrmd-hover-source-link" href="${escapeAttr(fileHref)}" target="_blank" rel="noopener noreferrer">${escapeHtml(locationText)}</a></div>`;
647
+ } else {
648
+ html += `<div class="mrmd-hover-source">Source: ${escapeHtml(locationText)}</div>`;
649
+ }
650
+ }
651
+
652
+ html += '</div>';
653
+ return html;
654
+ }
655
+
656
+ /**
657
+ * Escape HTML special characters.
658
+ *
659
+ * @param {string} str
660
+ * @returns {string}
661
+ */
662
+ function escapeHtml(str) {
663
+ if (!str) return '';
664
+ return str
665
+ .replace(/&/g, '&amp;')
666
+ .replace(/</g, '&lt;')
667
+ .replace(/>/g, '&gt;')
668
+ .replace(/"/g, '&quot;');
669
+ }
670
+
671
+ function escapeAttr(str) {
672
+ if (!str) return '';
673
+ return String(str)
674
+ .replace(/&/g, '&amp;')
675
+ .replace(/"/g, '&quot;')
676
+ .replace(/'/g, '&#39;')
677
+ .replace(/</g, '&lt;')
678
+ .replace(/>/g, '&gt;');
679
+ }
680
+
681
+ // #endregion HOVER_EXTENSION
682
+
683
+ // #region COMPLETION_EXTENSION
684
+
685
+ /**
686
+ * Check whether the character at a given index is escaped.
687
+ *
688
+ * @param {string} text
689
+ * @param {number} index
690
+ * @returns {boolean}
691
+ */
692
+ function isEscapedAt(text, index) {
693
+ let backslashes = 0;
694
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
695
+ backslashes++;
696
+ }
697
+ return backslashes % 2 === 1;
698
+ }
699
+
700
+ /**
701
+ * Split a source range on a delimiter, ignoring nested (), [], {}, strings, and comments.
702
+ *
703
+ * @param {string} code
704
+ * @param {number} start
705
+ * @param {number} end
706
+ * @param {string} [delimiter=',']
707
+ * @returns {Array<{start: number, end: number, text: string}>}
708
+ */
709
+ function splitTopLevelRange(code, start, end, delimiter = ',') {
710
+ /** @type {Array<{start: number, end: number, text: string}>} */
711
+ const segments = [];
712
+
713
+ let segmentStart = start;
714
+ const stack = [];
715
+ let stringQuote = null;
716
+ let lineComment = false;
717
+ let blockComment = false;
718
+
719
+ for (let i = start; i < end; i++) {
720
+ const ch = code[i];
721
+ const next = i + 1 < end ? code[i + 1] : '';
722
+
723
+ if (lineComment) {
724
+ if (ch === '\n') lineComment = false;
725
+ continue;
726
+ }
727
+
728
+ if (blockComment) {
729
+ if (ch === '*' && next === '/') {
730
+ blockComment = false;
731
+ i++;
732
+ }
733
+ continue;
734
+ }
735
+
736
+ if (stringQuote) {
737
+ if (ch === stringQuote && !isEscapedAt(code, i)) {
738
+ stringQuote = null;
739
+ }
740
+ continue;
741
+ }
742
+
743
+ if (ch === '#' || (ch === '/' && next === '/')) {
744
+ lineComment = true;
745
+ if (ch === '/') i++;
746
+ continue;
747
+ }
748
+
749
+ if (ch === '/' && next === '*') {
750
+ blockComment = true;
751
+ i++;
752
+ continue;
753
+ }
754
+
755
+ if (ch === '"' || ch === '\'' || ch === '`') {
756
+ stringQuote = ch;
757
+ continue;
758
+ }
759
+
760
+ if (ch === '(' || ch === '[' || ch === '{') {
761
+ stack.push(ch);
762
+ continue;
763
+ }
764
+
765
+ if (ch === ')' || ch === ']' || ch === '}') {
766
+ const expected = ch === ')' ? '(' : ch === ']' ? '[' : '{';
767
+ if (stack[stack.length - 1] === expected) {
768
+ stack.pop();
769
+ }
770
+ continue;
771
+ }
772
+
773
+ if (ch === delimiter && stack.length === 0) {
774
+ segments.push({
775
+ start: segmentStart,
776
+ end: i,
777
+ text: code.slice(segmentStart, i),
778
+ });
779
+ segmentStart = i + 1;
780
+ }
781
+ }
782
+
783
+ segments.push({
784
+ start: segmentStart,
785
+ end,
786
+ text: code.slice(segmentStart, end),
787
+ });
788
+
789
+ return segments;
790
+ }
791
+
792
+ /**
793
+ * Find the innermost unmatched `(` before the cursor.
794
+ * Only returns it when the cursor is at top level inside that call.
795
+ *
796
+ * @param {string} code
797
+ * @param {number} cursor
798
+ * @returns {number|null}
799
+ */
800
+ function findActiveCallOpenParen(code, cursor) {
801
+ const stack = [];
802
+ let stringQuote = null;
803
+ let lineComment = false;
804
+ let blockComment = false;
805
+
806
+ for (let i = 0; i < cursor; i++) {
807
+ const ch = code[i];
808
+ const next = i + 1 < cursor ? code[i + 1] : '';
809
+
810
+ if (lineComment) {
811
+ if (ch === '\n') lineComment = false;
812
+ continue;
813
+ }
814
+
815
+ if (blockComment) {
816
+ if (ch === '*' && next === '/') {
817
+ blockComment = false;
818
+ i++;
819
+ }
820
+ continue;
821
+ }
822
+
823
+ if (stringQuote) {
824
+ if (ch === stringQuote && !isEscapedAt(code, i)) {
825
+ stringQuote = null;
826
+ }
827
+ continue;
828
+ }
829
+
830
+ if (ch === '#' || (ch === '/' && next === '/')) {
831
+ lineComment = true;
832
+ if (ch === '/') i++;
833
+ continue;
834
+ }
835
+
836
+ if (ch === '/' && next === '*') {
837
+ blockComment = true;
838
+ i++;
839
+ continue;
840
+ }
841
+
842
+ if (ch === '"' || ch === '\'' || ch === '`') {
843
+ stringQuote = ch;
844
+ continue;
845
+ }
846
+
847
+ if (ch === '(' || ch === '[' || ch === '{') {
848
+ stack.push({ ch, index: i });
849
+ continue;
850
+ }
851
+
852
+ if (ch === ')' || ch === ']' || ch === '}') {
853
+ const expected = ch === ')' ? '(' : ch === ']' ? '[' : '{';
854
+ if (stack[stack.length - 1]?.ch === expected) {
855
+ stack.pop();
856
+ }
857
+ }
858
+ }
859
+
860
+ const top = stack[stack.length - 1];
861
+ return top?.ch === '(' ? top.index : null;
862
+ }
863
+
864
+ /**
865
+ * Extract a simple callable expression before an opening parenthesis.
866
+ * Supports names like `foo` and dotted paths like `obj.method`.
867
+ *
868
+ * @param {string} code
869
+ * @param {number} openParen
870
+ * @returns {string|null}
871
+ */
872
+ function extractCallableNameBeforeParen(code, openParen) {
873
+ const prefix = code.slice(0, openParen);
874
+ const match = prefix.match(/([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*$/);
875
+ return match?.[1] || null;
876
+ }
877
+
878
+ /**
879
+ * Extract a leading keyword argument name from an argument segment.
880
+ *
881
+ * @param {string} text
882
+ * @returns {string|null}
883
+ */
884
+ function extractAssignedKeywordName(text) {
885
+ const match = text.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
886
+ return match?.[1] || null;
887
+ }
888
+
889
+ /**
890
+ * Determine whether the current argument segment is empty or a simple identifier prefix.
891
+ *
892
+ * @param {{start: number, end: number, text: string}} segment
893
+ * @returns {{prefix: string, start: number, end: number}|null}
894
+ */
895
+ function getArgumentPrefixInfo(segment) {
896
+ const text = segment.text;
897
+
898
+ if (!text.trim()) {
899
+ return {
900
+ prefix: '',
901
+ start: segment.end,
902
+ end: segment.end,
903
+ };
904
+ }
905
+
906
+ const match = text.match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/);
907
+ if (!match) return null;
908
+
909
+ const before = text.slice(0, match.index);
910
+ if (before.trim()) return null;
911
+
912
+ return {
913
+ prefix: match[1],
914
+ start: segment.start + match.index,
915
+ end: segment.end,
916
+ };
917
+ }
918
+
919
+ /**
920
+ * Analyze the active call context at the cursor position.
921
+ *
922
+ * @param {string} code
923
+ * @param {number} cursor
924
+ * @returns {{callee: string, openParen: number, segments: Array<{start: number, end: number, text: string}>, currentSegment: {start: number, end: number, text: string}, usedKeywords: Set<string>, activeKeyword: string|null, positionalIndex: number}|null}
925
+ */
926
+ function getActiveCallContext(code, cursor) {
927
+ const openParen = findActiveCallOpenParen(code, cursor);
928
+ if (openParen == null) return null;
929
+
930
+ const callee = extractCallableNameBeforeParen(code, openParen);
931
+ if (!callee) return null;
932
+
933
+ const segments = splitTopLevelRange(code, openParen + 1, cursor);
934
+ const currentSegment = segments[segments.length - 1] || {
935
+ start: cursor,
936
+ end: cursor,
937
+ text: '',
938
+ };
939
+
940
+ const usedKeywords = new Set();
941
+ let positionalIndex = 0;
942
+
943
+ for (const segment of segments.slice(0, -1)) {
944
+ const text = segment.text.trim();
945
+ if (!text) continue;
946
+
947
+ const keywordName = extractAssignedKeywordName(segment.text);
948
+ if (keywordName) {
949
+ usedKeywords.add(keywordName);
950
+ continue;
951
+ }
952
+
953
+ if (/^\*{1,2}/.test(text)) continue;
954
+ positionalIndex++;
955
+ }
956
+
957
+ return {
958
+ callee,
959
+ openParen,
960
+ segments,
961
+ currentSegment,
962
+ usedKeywords,
963
+ activeKeyword: extractAssignedKeywordName(currentSegment.text),
964
+ positionalIndex,
965
+ };
966
+ }
967
+
968
+ /**
969
+ * Find call-argument completion context for the current cursor position.
970
+ *
971
+ * @param {string} code
972
+ * @param {number} cursor
973
+ * @returns {{callee: string, openParen: number, prefix: string, replaceStart: number, replaceEnd: number, usedKeywords: Set<string>}|null}
974
+ */
975
+ function getCallArgumentContext(code, cursor) {
976
+ const activeCall = getActiveCallContext(code, cursor);
977
+ if (!activeCall) return null;
978
+
979
+ if (/^\s*\*{1,2}/.test(activeCall.currentSegment.text)) return null;
980
+ if (activeCall.activeKeyword) return null;
981
+
982
+ const prefixInfo = getArgumentPrefixInfo(activeCall.currentSegment);
983
+ if (!prefixInfo) return null;
984
+
985
+ return {
986
+ callee: activeCall.callee,
987
+ openParen: activeCall.openParen,
988
+ prefix: prefixInfo.prefix,
989
+ replaceStart: prefixInfo.start,
990
+ replaceEnd: prefixInfo.end,
991
+ usedKeywords: activeCall.usedKeywords,
992
+ };
993
+ }
994
+
995
+ /**
996
+ * Parse a signature string into keyword-capable parameters.
997
+ *
998
+ * @param {string} signature
999
+ * @returns {Array<{name: string, declaration: string, required: boolean}>}
1000
+ */
1001
+ function parseSignatureParameters(signature) {
1002
+ if (!signature) return [];
1003
+
1004
+ const openParen = signature.indexOf('(');
1005
+ const closeParen = signature.lastIndexOf(')');
1006
+ if (openParen < 0 || closeParen <= openParen) return [];
1007
+
1008
+ const inner = signature.slice(openParen + 1, closeParen);
1009
+ const parts = splitTopLevelRange(inner, 0, inner.length).map(part => part.text.trim());
1010
+ if (parts.length === 0) return [];
1011
+
1012
+ const positionalOnlyEnd = parts.indexOf('/');
1013
+
1014
+ /** @type {Array<{name: string, declaration: string, required: boolean}>} */
1015
+ const parameters = [];
1016
+
1017
+ for (let index = 0; index < parts.length; index++) {
1018
+ const part = parts[index];
1019
+ if (!part || part === '/' || part === '*') continue;
1020
+ if (positionalOnlyEnd !== -1 && index < positionalOnlyEnd) continue;
1021
+ if (part.startsWith('**')) continue;
1022
+ if (part.startsWith('*')) continue;
1023
+
1024
+ const nameMatch = part.match(/^([A-Za-z_][A-Za-z0-9_]*)/);
1025
+ const name = nameMatch?.[1];
1026
+ if (!name || name === 'self' || name === 'cls') continue;
1027
+
1028
+ parameters.push({
1029
+ name,
1030
+ declaration: part,
1031
+ required: !part.includes('='),
1032
+ });
1033
+ }
1034
+
1035
+ return parameters;
1036
+ }
1037
+
1038
+ /**
1039
+ * Extract parameter docs from common docstring formats.
1040
+ *
1041
+ * @param {string} docs
1042
+ * @param {string} parameterName
1043
+ * @returns {string}
1044
+ */
1045
+ function extractParameterDocumentation(docs, parameterName) {
1046
+ const text = cleanDocsText(docs);
1047
+ if (!text) return '';
1048
+
1049
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
1050
+
1051
+ // NumPy-style docstrings
1052
+ for (let i = 0; i < lines.length - 1; i++) {
1053
+ if (!/^\s*Parameters\s*$/i.test(lines[i]) || !/^\s*-{3,}\s*$/.test(lines[i + 1])) continue;
1054
+
1055
+ let currentNames = [];
1056
+ /** @type {string[]} */
1057
+ let currentLines = [];
1058
+
1059
+ const flush = () => {
1060
+ if (currentNames.includes(parameterName)) {
1061
+ return currentLines.join('\n').trim();
1062
+ }
1063
+ return '';
1064
+ };
1065
+
1066
+ for (let j = i + 2; j < lines.length; j++) {
1067
+ const line = lines[j];
1068
+
1069
+ if (/^\S/.test(line) && j + 1 < lines.length && /^\s*-{3,}\s*$/.test(lines[j + 1])) {
1070
+ return flush();
1071
+ }
1072
+
1073
+ const headerMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)(\s*:.*)?\s*$/);
1074
+ if (headerMatch && !/^\s/.test(line)) {
1075
+ const existing = flush();
1076
+ if (existing) return existing;
1077
+
1078
+ currentNames = headerMatch[1].split(/\s*,\s*/);
1079
+ currentLines = [];
1080
+ const typeInfo = headerMatch[2]?.replace(/^\s*:\s*/, '').trim();
1081
+ if (typeInfo) currentLines.push(typeInfo);
1082
+ continue;
1083
+ }
1084
+
1085
+ if (currentNames.length > 0) {
1086
+ currentLines.push(line.trimEnd());
1087
+ }
1088
+ }
1089
+
1090
+ return flush();
1091
+ }
1092
+
1093
+ // Google-style docstrings
1094
+ for (let i = 0; i < lines.length; i++) {
1095
+ if (!/^\s*(Args|Arguments|Parameters)\s*:\s*$/i.test(lines[i])) continue;
1096
+
1097
+ let currentName = '';
1098
+ /** @type {string[]} */
1099
+ let currentLines = [];
1100
+
1101
+ const flush = () => {
1102
+ if (currentName === parameterName) {
1103
+ return currentLines.join('\n').trim();
1104
+ }
1105
+ return '';
1106
+ };
1107
+
1108
+ for (let j = i + 1; j < lines.length; j++) {
1109
+ const line = lines[j];
1110
+ if (!line.trim()) {
1111
+ if (currentName) currentLines.push('');
1112
+ continue;
1113
+ }
1114
+
1115
+ if (/^\S/.test(line) && !/^\s{2,}/.test(line)) {
1116
+ return flush();
1117
+ }
1118
+
1119
+ const headerMatch = line.match(/^\s{2,}([A-Za-z_][A-Za-z0-9_]*)(?:\s*\([^)]*\))?\s*:\s*(.*)$/);
1120
+ if (headerMatch) {
1121
+ const existing = flush();
1122
+ if (existing) return existing;
1123
+
1124
+ currentName = headerMatch[1];
1125
+ currentLines = headerMatch[2] ? [headerMatch[2].trim()] : [];
1126
+ continue;
1127
+ }
1128
+
1129
+ if (currentName) {
1130
+ currentLines.push(line.trim());
1131
+ }
1132
+ }
1133
+
1134
+ return flush();
1135
+ }
1136
+
1137
+ return '';
1138
+ }
1139
+
1140
+ /**
1141
+ * Create a short fallback summary from a docstring.
1142
+ *
1143
+ * @param {string} docs
1144
+ * @returns {string}
1145
+ */
1146
+ function summarizeDocs(docs) {
1147
+ const text = cleanDocsText(docs);
1148
+ if (!text) return '';
1149
+
1150
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
1151
+ const summary = [];
1152
+
1153
+ for (const line of lines) {
1154
+ if (!line.trim()) {
1155
+ if (summary.length > 0) break;
1156
+ continue;
1157
+ }
1158
+ summary.push(line.trim());
1159
+ if (summary.length >= 5) break;
1160
+ }
1161
+
1162
+ return summary.join('\n').trim();
1163
+ }
1164
+
1165
+ /**
1166
+ * Get the first non-empty line from a doc snippet.
1167
+ *
1168
+ * @param {string} docs
1169
+ * @param {number} [maxLength=88]
1170
+ * @returns {string}
1171
+ */
1172
+ function firstDocLine(docs, maxLength = 88) {
1173
+ const text = cleanDocsText(docs);
1174
+ if (!text) return '';
1175
+
1176
+ const line = text
1177
+ .replace(/\r\n?/g, '\n')
1178
+ .split('\n')
1179
+ .map(part => part.trim())
1180
+ .find(Boolean) || '';
1181
+
1182
+ if (!line) return '';
1183
+ return line.length > maxLength ? `${line.slice(0, maxLength - 1)}…` : line;
1184
+ }
1185
+
1186
+ /**
1187
+ * Compact a parameter declaration for use in the completion row.
1188
+ *
1189
+ * @param {string} declaration
1190
+ * @param {string} name
1191
+ * @param {number} [maxLength=48]
1192
+ * @returns {string}
1193
+ */
1194
+ function compactParameterDeclaration(declaration, name, maxLength = 48) {
1195
+ if (!declaration) return '';
1196
+
1197
+ let text = declaration
1198
+ .replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[:=]?\\s*`), '')
1199
+ .replace(/(['"])[^'"]{16,}\1/g, '$1…$1')
1200
+ .replace(/\s+/g, ' ')
1201
+ .trim();
1202
+
1203
+ if (!text) text = declaration.trim();
1204
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
1205
+ }
1206
+
1207
+ /**
1208
+ * Shorten doc text for completion info / compact signature help.
1209
+ *
1210
+ * @param {string} docs
1211
+ * @param {number} [maxLines=4]
1212
+ * @returns {string}
1213
+ */
1214
+ function compactDocs(docs, maxLines = 4) {
1215
+ const text = cleanDocsText(docs);
1216
+ if (!text) return '';
1217
+
1218
+ const lines = text
1219
+ .replace(/\r\n?/g, '\n')
1220
+ .split('\n')
1221
+ .map(line => line.trim())
1222
+ .filter((line, index, arr) => line || (index > 0 && arr[index - 1]));
1223
+
1224
+ const clipped = lines.slice(0, maxLines).join('\n').trim();
1225
+ return clipped;
1226
+ }
1227
+
1228
+ /**
1229
+ * Parse a signature for display and active-parameter highlighting.
1230
+ *
1231
+ * @param {string} signature
1232
+ * @returns {{head: string, tail: string, parts: Array<{text: string, name: string|null, kind: 'parameter'|'marker'|'varargs'|'kwargs', positionalCapable: boolean, keywordCapable: boolean}>}|null}
1233
+ */
1234
+ function parseSignatureDisplay(signature) {
1235
+ if (!signature) return null;
1236
+
1237
+ const openParen = signature.indexOf('(');
1238
+ const closeParen = signature.lastIndexOf(')');
1239
+ if (openParen < 0 || closeParen <= openParen) return null;
1240
+
1241
+ const head = signature.slice(0, openParen + 1);
1242
+ const inner = signature.slice(openParen + 1, closeParen);
1243
+ const tail = signature.slice(closeParen);
1244
+ const rawParts = splitTopLevelRange(inner, 0, inner.length).map(part => part.text.trim());
1245
+ const positionalOnlyEnd = rawParts.indexOf('/');
1246
+
1247
+ /** @type {Array<{text: string, name: string|null, kind: 'parameter'|'marker'|'varargs'|'kwargs', positionalCapable: boolean, keywordCapable: boolean}>} */
1248
+ const parts = [];
1249
+ let keywordOnlyMode = false;
1250
+
1251
+ for (let index = 0; index < rawParts.length; index++) {
1252
+ const part = rawParts[index];
1253
+ if (!part) continue;
1254
+
1255
+ if (part === '/') {
1256
+ parts.push({
1257
+ text: part,
1258
+ name: null,
1259
+ kind: 'marker',
1260
+ positionalCapable: false,
1261
+ keywordCapable: false,
1262
+ });
1263
+ continue;
1264
+ }
1265
+
1266
+ if (part === '*') {
1267
+ keywordOnlyMode = true;
1268
+ parts.push({
1269
+ text: part,
1270
+ name: null,
1271
+ kind: 'marker',
1272
+ positionalCapable: false,
1273
+ keywordCapable: false,
1274
+ });
1275
+ continue;
1276
+ }
1277
+
1278
+ const positionalOnly = positionalOnlyEnd !== -1 && index < positionalOnlyEnd;
1279
+ const isVarKeyword = part.startsWith('**');
1280
+ const isVarArgs = !isVarKeyword && part.startsWith('*');
1281
+ const nameMatch = part.match(/^\*{0,2}([A-Za-z_][A-Za-z0-9_]*)/);
1282
+ const name = nameMatch?.[1] || null;
1283
+
1284
+ parts.push({
1285
+ text: part,
1286
+ name,
1287
+ kind: isVarKeyword ? 'kwargs' : isVarArgs ? 'varargs' : 'parameter',
1288
+ positionalCapable: isVarArgs || (!keywordOnlyMode && !isVarKeyword),
1289
+ keywordCapable: !positionalOnly && !isVarArgs && !isVarKeyword,
1290
+ });
1291
+
1292
+ if (isVarArgs) {
1293
+ keywordOnlyMode = true;
1294
+ }
1295
+ }
1296
+
1297
+ return { head, tail, parts };
1298
+ }
1299
+
1300
+ /**
1301
+ * Resolve the active signature part for the current call context.
1302
+ *
1303
+ * @param {{parts: Array<{text: string, name: string|null, kind: 'parameter'|'marker'|'varargs'|'kwargs', positionalCapable: boolean, keywordCapable: boolean}>}} parsedSignature
1304
+ * @param {{currentSegment: {text: string}, usedKeywords: Set<string>, activeKeyword: string|null, positionalIndex: number}} callContext
1305
+ * @returns {{text: string, name: string|null, kind: 'parameter'|'marker'|'varargs'|'kwargs', positionalCapable: boolean, keywordCapable: boolean}|null}
1306
+ */
1307
+ function resolveActiveSignaturePart(parsedSignature, callContext) {
1308
+ const trimmedCurrent = callContext.currentSegment.text.trimStart();
1309
+
1310
+ if (trimmedCurrent.startsWith('**')) {
1311
+ return parsedSignature.parts.find(part => part.kind === 'kwargs') || null;
1312
+ }
1313
+
1314
+ if (trimmedCurrent.startsWith('*')) {
1315
+ return parsedSignature.parts.find(part => part.kind === 'varargs') || null;
1316
+ }
1317
+
1318
+ if (callContext.activeKeyword) {
1319
+ return parsedSignature.parts.find(part => part.name === callContext.activeKeyword) || null;
1320
+ }
1321
+
1322
+ const positionalParts = parsedSignature.parts.filter(part => part.positionalCapable);
1323
+ if (callContext.positionalIndex < positionalParts.length) {
1324
+ return positionalParts[callContext.positionalIndex] || null;
1325
+ }
1326
+
1327
+ const remainingKeywordPart = parsedSignature.parts.find(part =>
1328
+ part.keywordCapable && part.name && !callContext.usedKeywords.has(part.name)
1329
+ );
1330
+ if (remainingKeywordPart) return remainingKeywordPart;
1331
+
1332
+ return positionalParts[positionalParts.length - 1] || null;
1333
+ }
1334
+
1335
+ /**
1336
+ * Format a parsed signature to HTML with the active parameter highlighted.
1337
+ *
1338
+ * @param {{head: string, tail: string, parts: Array<{text: string, name: string|null, kind: 'parameter'|'marker'|'varargs'|'kwargs', positionalCapable: boolean, keywordCapable: boolean}>}} parsedSignature
1339
+ * @param {{text: string, name: string|null, kind: 'parameter'|'marker'|'varargs'|'kwargs', positionalCapable: boolean, keywordCapable: boolean}|null} activePart
1340
+ * @returns {string}
1341
+ */
1342
+ function formatSignatureMarkup(parsedSignature, activePart) {
1343
+ const pieces = [`<span class="mrmd-signature-help__head">${escapeHtml(parsedSignature.head)}</span>`];
1344
+
1345
+ parsedSignature.parts.forEach((part, index) => {
1346
+ if (index > 0) {
1347
+ pieces.push('<span class="mrmd-signature-help__comma">, </span>');
1348
+ }
1349
+
1350
+ const classes = ['mrmd-signature-help__part'];
1351
+ if (part.kind === 'marker') classes.push('mrmd-signature-help__part--marker');
1352
+ if (activePart === part) classes.push('mrmd-signature-help__part--active');
1353
+
1354
+ pieces.push(`<span class="${classes.join(' ')}">${escapeHtml(part.text)}</span>`);
1355
+ });
1356
+
1357
+ pieces.push(`<span class="mrmd-signature-help__tail">${escapeHtml(parsedSignature.tail)}</span>`);
1358
+ return pieces.join('');
1359
+ }
1360
+
1361
+ /**
1362
+ * Format signature help content as HTML.
1363
+ *
1364
+ * @param {InspectResult} inspectResult
1365
+ * @param {{currentSegment: {text: string}, usedKeywords: Set<string>, activeKeyword: string|null, positionalIndex: number}} callContext
1366
+ * @param {{compact?: boolean}} [options]
1367
+ * @returns {string}
1368
+ */
1369
+ function formatSignatureHelpContent(inspectResult, callContext, { compact = false } = {}) {
1370
+ const parsedSignature = parseSignatureDisplay(inspectResult.signature || '');
1371
+ if (!parsedSignature) return '';
1372
+
1373
+ const activePart = resolveActiveSignaturePart(parsedSignature, callContext);
1374
+ const docs = inspectResult.documentation || inspectResult.docstring || '';
1375
+ const parameterDocs = activePart?.name ? extractParameterDocumentation(docs, activePart.name) : '';
1376
+ const fallbackDocs = compactDocs(summarizeDocs(docs), compact ? 2 : 4);
1377
+
1378
+ let html = `<div class="mrmd-signature-help${compact ? ' mrmd-signature-help--compact' : ''}">`;
1379
+ html += `<div class="mrmd-signature-help__signature"><code>${formatSignatureMarkup(parsedSignature, activePart)}</code></div>`;
1380
+
1381
+ if (activePart && activePart.kind !== 'marker') {
1382
+ html += `<div class="mrmd-signature-help__active">${escapeHtml(activePart.text)}</div>`;
1383
+ }
1384
+
1385
+ if (!compact && inspectResult.name) {
1386
+ html += `<div class="mrmd-signature-help__name">${escapeHtml(inspectResult.name)}</div>`;
1387
+ }
1388
+
1389
+ const docsText = compact ? firstDocLine(parameterDocs || fallbackDocs, 72) : (parameterDocs || fallbackDocs);
1390
+ if (docsText) {
1391
+ html += `<div class="mrmd-signature-help__docs">${escapeHtml(docsText)}</div>`;
1392
+ }
1393
+
1394
+ html += '</div>';
1395
+ return html;
1396
+ }
1397
+
1398
+ /**
1399
+ * Retrieve signature-bearing inspection data, falling back to hover when inspect is unavailable
1400
+ * or returns too little information.
1401
+ *
1402
+ * @param {Object} options
1403
+ * @param {RuntimeLSPProvider} options.provider
1404
+ * @param {string} options.code
1405
+ * @param {number} options.cursor
1406
+ * @param {string} options.language
1407
+ * @returns {Promise<InspectResult|null>}
1408
+ */
1409
+ async function getSignatureInspectData({ provider, code, cursor, language }) {
1410
+ let inspectResult = null;
1411
+
1412
+ if (provider.inspect) {
1413
+ try {
1414
+ inspectResult = await provider.inspect(code, cursor, language, { detail: 1 });
1415
+ } catch {
1416
+ inspectResult = null;
1417
+ }
1418
+ }
1419
+
1420
+ let hoverResult = null;
1421
+ const needsHoverFallback = !inspectResult?.signature || (!inspectResult.documentation && !inspectResult.docstring);
1422
+ if (needsHoverFallback && provider.hover) {
1423
+ try {
1424
+ hoverResult = await provider.hover(code, cursor, language);
1425
+ } catch {
1426
+ hoverResult = null;
1427
+ }
1428
+ }
1429
+
1430
+ const signature = inspectResult?.signature || hoverResult?.signature;
1431
+ if (!signature) return null;
1432
+
1433
+ return {
1434
+ ...(inspectResult || {}),
1435
+ found: true,
1436
+ name: inspectResult?.name || hoverResult?.name,
1437
+ type: inspectResult?.type || hoverResult?.type,
1438
+ signature,
1439
+ documentation:
1440
+ inspectResult?.documentation ||
1441
+ inspectResult?.docstring ||
1442
+ hoverResult?.documentation ||
1443
+ hoverResult?.docstring,
1444
+ docstring: inspectResult?.docstring || hoverResult?.docstring,
1445
+ };
1446
+ }
1447
+
1448
+ /**
1449
+ * Get an anchor rect derived from the autocomplete popup, for VS Code-like stacked placement.
1450
+ *
1451
+ * @param {import('@codemirror/view').EditorView} view
1452
+ * @returns {import('@codemirror/view').Rect|null}
1453
+ */
1454
+ function getAutocompleteTooltipRect(view) {
1455
+ const popup = view.dom.querySelector('.cm-tooltip-autocomplete');
1456
+ if (!(popup instanceof HTMLElement)) return null;
1457
+
1458
+ const rect = popup.getBoundingClientRect();
1459
+ return {
1460
+ left: rect.left,
1461
+ right: rect.left + 1,
1462
+ top: rect.top,
1463
+ bottom: rect.top,
1464
+ };
1465
+ }
1466
+
1467
+ /**
1468
+ * Lazily inspect a completion candidate to provide docs in the side info panel.
321
1469
  *
322
- * @param {string} content - Document content
323
- * @param {number} pos - Document position
324
- * @returns {{code: string, offset: number, language: string, cell: Object}|null}
1470
+ * @param {Object} options
1471
+ * @param {RuntimeLSPProvider} options.provider
1472
+ * @param {string} options.code
1473
+ * @param {number} options.cursorStart
1474
+ * @param {number} options.cursorEnd
1475
+ * @param {string} options.insertText
1476
+ * @param {string} options.language
1477
+ * @returns {Promise<Partial<CompletionItem>|null>}
325
1478
  */
326
- function getCodeAtPosition(content, pos) {
327
- const cell = getCellAtCursor(content, pos);
328
- if (!cell) return null;
1479
+ async function loadCompletionItemInfo({ provider, code, cursorStart, cursorEnd, insertText, language }) {
1480
+ if (!provider.inspect && !provider.hover) return null;
1481
+
1482
+ const candidateCode = `${code.slice(0, cursorStart)}${insertText}${code.slice(cursorEnd)}`;
1483
+ const candidateCursor = cursorStart + insertText.length;
1484
+
1485
+ let inspectResult = null;
1486
+ if (provider.inspect) {
1487
+ try {
1488
+ inspectResult = await provider.inspect(candidateCode, candidateCursor, language, { detail: 1 });
1489
+ } catch {
1490
+ inspectResult = null;
1491
+ }
1492
+ }
329
1493
 
330
- // Calculate offset within the cell's code
331
- const offset = pos - cell.codeStart;
332
- if (offset < 0 || offset > cell.code.length) return null;
1494
+ let hoverResult = null;
1495
+ const needsHover = !inspectResult?.found || (!inspectResult.signature && !inspectResult.documentation && !inspectResult.docstring);
1496
+ if (needsHover && provider.hover) {
1497
+ try {
1498
+ hoverResult = await provider.hover(candidateCode, candidateCursor, language);
1499
+ } catch {
1500
+ hoverResult = null;
1501
+ }
1502
+ }
1503
+
1504
+ const signature = inspectResult?.signature || hoverResult?.signature || '';
1505
+ const type = inspectResult?.type || hoverResult?.type || '';
1506
+ const docs =
1507
+ inspectResult?.documentation ||
1508
+ inspectResult?.docstring ||
1509
+ hoverResult?.documentation ||
1510
+ hoverResult?.docstring ||
1511
+ hoverResult?.value || '';
1512
+
1513
+ const detail = signature || type || '';
1514
+ if (!detail && !docs) return null;
333
1515
 
334
1516
  return {
335
- code: cell.code,
336
- offset,
337
- language: cell.language,
338
- cell,
1517
+ label: inspectResult?.name || hoverResult?.name,
1518
+ detail,
1519
+ documentation: docs,
339
1520
  };
340
1521
  }
341
1522
 
342
- // #endregion LANGUAGE_DETECTION
343
-
344
- // #region HOVER_EXTENSION
345
-
346
1523
  /**
347
- * Create a CodeMirror hover tooltip extension powered by runtime LSP.
348
- *
349
- * Shows actual runtime values when hovering over variables/symbols.
1524
+ * Build high-priority keyword argument completions for the active call site.
350
1525
  *
351
1526
  * @param {Object} options
352
- * @param {Map<string, RuntimeLSPProvider>} options.providers - Language → provider map
353
- * @param {function(): string} options.getContent - Get document content
354
- * @param {import('./awareness/state.js').AwarenessStateManager} [options.stateManager] - For awareness broadcast
355
- * @param {import('yjs').Text} [options.yText] - For RelativePosition tracking
356
- * @returns {import('@codemirror/state').Extension}
1527
+ * @param {RuntimeLSPProvider} options.provider
1528
+ * @param {string} options.code
1529
+ * @param {string} options.language
1530
+ * @param {{callee: string, openParen: number, prefix: string, replaceStart: number, replaceEnd: number, usedKeywords: Set<string>}} options.callContext
1531
+ * @returns {Promise<{matches: CompletionItem[], cursorStart: number, cursorEnd: number}|null>}
357
1532
  */
358
- export function createRuntimeHoverExtension({ providers, getContent, stateManager, yText }) {
359
- return hoverTooltip(
360
- async (view, pos, side) => {
361
- const content = getContent();
362
- const codeInfo = getCodeAtPosition(content, pos);
363
-
364
- if (!codeInfo) return null;
365
-
366
- // Find provider for this language
367
- const provider = findProviderForLanguage(providers, codeInfo.language);
368
- if (!provider) return null;
1533
+ async function getCallKeywordCompletions({ provider, code, language, callContext }) {
1534
+ const inspectResult = await getSignatureInspectData({
1535
+ provider,
1536
+ code,
1537
+ cursor: callContext.openParen,
1538
+ language,
1539
+ });
1540
+ if (!inspectResult?.signature) return null;
369
1541
 
370
- // Get hover info from runtime
371
- const hoverResult = await provider.hover(codeInfo.code, codeInfo.offset, codeInfo.language);
372
- if (!hoverResult || !hoverResult.found) return null;
1542
+ const parameters = parseSignatureParameters(inspectResult.signature);
1543
+ if (parameters.length === 0) return null;
373
1544
 
374
- // Broadcast to awareness if available
375
- if (stateManager) {
376
- const position = yText
377
- ? await import('yjs').then(Y => Y.createRelativePositionFromTypeIndex(yText, pos))
378
- : { line: view.state.doc.lineAt(pos).number, ch: pos - view.state.doc.lineAt(pos).from };
1545
+ const lowerPrefix = callContext.prefix.toLowerCase();
1546
+ const docs = inspectResult.documentation || inspectResult.docstring || '';
379
1547
 
380
- stateManager.setHover({
381
- symbol: hoverResult.name,
382
- type: hoverResult.type,
383
- info: hoverResult.value || hoverResult.signature,
384
- position,
385
- cellIndex: getCellIndex(content, codeInfo.cell),
386
- });
387
- }
1548
+ const matches = parameters
1549
+ .filter(param => !callContext.usedKeywords.has(param.name))
1550
+ .filter(param => !lowerPrefix || param.name.toLowerCase().startsWith(lowerPrefix))
1551
+ .map((param, index) => {
1552
+ const parameterDocs = extractParameterDocumentation(docs, param.name);
1553
+ const summary = firstDocLine(parameterDocs || summarizeDocs(docs));
1554
+ const info = [
1555
+ `Parameter: ${param.declaration}`,
1556
+ compactDocs(parameterDocs || summarizeDocs(docs), 6),
1557
+ ].filter(Boolean).join('\n\n');
388
1558
 
389
- // Create tooltip DOM
390
1559
  return {
391
- pos,
392
- end: pos + (hoverResult.name?.length || 0),
393
- above: true,
394
- create() {
395
- const dom = document.createElement('div');
396
- dom.className = 'mrmd-runtime-hover';
397
- dom.innerHTML = formatHoverContent(hoverResult);
398
- return { dom };
399
- },
1560
+ label: `${param.name}=`,
1561
+ insertText: `${param.name}=`,
1562
+ kind: 'field',
1563
+ detail: compactParameterDeclaration(param.declaration, param.name),
1564
+ documentation: info,
1565
+ boost: 10000 - index,
1566
+ sortText: String(index).padStart(4, '0'),
1567
+ valuePreview: summary || undefined,
400
1568
  };
401
- },
402
- {
403
- hoverTime: 300,
404
- hideOnChange: true,
405
- }
406
- );
1569
+ });
1570
+
1571
+ if (matches.length === 0) return null;
1572
+
1573
+ return {
1574
+ matches,
1575
+ cursorStart: callContext.replaceStart,
1576
+ cursorEnd: callContext.replaceEnd,
1577
+ };
407
1578
  }
408
1579
 
409
1580
  /**
410
- * Format hover content as HTML.
1581
+ * Merge synthesized call-argument completions with runtime completions.
411
1582
  *
412
- * @param {HoverResult} result
413
- * @returns {string}
1583
+ * @param {CompletionResult|null} runtimeResult
1584
+ * @param {{matches: CompletionItem[], cursorStart: number, cursorEnd: number}|null} callResult
1585
+ * @returns {{matches: CompletionItem[], cursorStart: number, cursorEnd: number}|null}
414
1586
  */
415
- function formatHoverContent(result) {
416
- let html = '<div class="mrmd-hover-content">';
1587
+ function mergeCompletionResults(runtimeResult, callResult) {
1588
+ const runtimeMatches = runtimeResult?.matches || [];
1589
+ const callMatches = callResult?.matches || [];
417
1590
 
418
- // Name and type header
419
- if (result.name) {
420
- html += `<div class="mrmd-hover-name"><code>${escapeHtml(result.name)}</code>`;
421
- if (result.type) {
422
- html += ` <span class="mrmd-hover-type">${escapeHtml(result.type)}</span>`;
423
- }
424
- html += '</div>';
425
- }
1591
+ if (runtimeMatches.length === 0 && callMatches.length === 0) return null;
426
1592
 
427
- // Signature (for functions)
428
- if (result.signature) {
429
- html += `<div class="mrmd-hover-signature"><code>${escapeHtml(result.signature)}</code></div>`;
430
- }
1593
+ const seen = new Set();
1594
+ const matches = [];
431
1595
 
432
- // Value preview
433
- if (result.value) {
434
- html += `<div class="mrmd-hover-value">${escapeHtml(result.value)}</div>`;
1596
+ for (const match of callMatches) {
1597
+ const key = `${match.label}\u0000${match.insertText || ''}`;
1598
+ if (seen.has(key)) continue;
1599
+ seen.add(key);
1600
+ matches.push(match);
435
1601
  }
436
1602
 
437
- // Documentation
438
- if (result.documentation) {
439
- html += `<div class="mrmd-hover-docs">${escapeHtml(result.documentation)}</div>`;
1603
+ for (const match of runtimeMatches) {
1604
+ const key = `${match.label}\u0000${match.insertText || ''}`;
1605
+ if (seen.has(key)) continue;
1606
+ seen.add(key);
1607
+ matches.push(match);
440
1608
  }
441
1609
 
442
- html += '</div>';
443
- return html;
1610
+ return {
1611
+ matches,
1612
+ cursorStart: callResult?.cursorStart ?? runtimeResult?.cursorStart ?? 0,
1613
+ cursorEnd: callResult?.cursorEnd ?? runtimeResult?.cursorEnd ?? 0,
1614
+ };
444
1615
  }
445
1616
 
446
1617
  /**
447
- * Escape HTML special characters.
1618
+ * Build a compact DOM info panel for a completion item.
448
1619
  *
449
- * @param {string} str
450
- * @returns {string}
1620
+ * @param {CompletionItem} match
1621
+ * @returns {((completion: any) => import('@codemirror/autocomplete').CompletionInfo)|undefined}
451
1622
  */
452
- function escapeHtml(str) {
453
- if (!str) return '';
454
- return str
455
- .replace(/&/g, '&amp;')
456
- .replace(/</g, '&lt;')
457
- .replace(/>/g, '&gt;')
458
- .replace(/"/g, '&quot;');
459
- }
1623
+ function createCompletionInfoRenderer(match, loadInfo) {
1624
+ const render = (infoMatch) => {
1625
+ const docs = compactDocs(infoMatch.documentation || '', 6);
1626
+ const detail = infoMatch.detail || '';
1627
+
1628
+ if (!docs && !detail) return null;
1629
+
1630
+ const dom = document.createElement('div');
1631
+ dom.className = 'mrmd-completion-info';
1632
+
1633
+ const title = document.createElement('div');
1634
+ title.className = 'mrmd-completion-info__title';
1635
+ title.textContent = infoMatch.label;
1636
+ dom.appendChild(title);
1637
+
1638
+ if (detail) {
1639
+ const detailEl = document.createElement('div');
1640
+ detailEl.className = 'mrmd-completion-info__detail';
1641
+ detailEl.textContent = detail;
1642
+ dom.appendChild(detailEl);
1643
+ }
460
1644
 
461
- // #endregion HOVER_EXTENSION
1645
+ if (docs) {
1646
+ const docsEl = document.createElement('div');
1647
+ docsEl.className = 'mrmd-completion-info__docs';
1648
+ docsEl.textContent = docs;
1649
+ dom.appendChild(docsEl);
1650
+ }
462
1651
 
463
- // #region COMPLETION_EXTENSION
1652
+ return dom;
1653
+ };
1654
+
1655
+ const immediate = render(match);
1656
+ if (immediate && !loadInfo) {
1657
+ return () => immediate.cloneNode(true);
1658
+ }
1659
+
1660
+ if (!loadInfo) return undefined;
1661
+
1662
+ return async () => {
1663
+ const loaded = await loadInfo();
1664
+ if (!loaded) return immediate ? immediate.cloneNode(true) : null;
1665
+ const dom = render({ ...match, ...loaded });
1666
+ return dom || (immediate ? immediate.cloneNode(true) : null);
1667
+ };
1668
+ }
464
1669
 
465
1670
  /**
466
1671
  * Create a CodeMirror completion source powered by runtime LSP.
@@ -486,8 +1691,23 @@ export function createRuntimeCompletionSource({ providers, getContent, stateMana
486
1691
  const provider = findProviderForLanguage(providers, codeInfo.language);
487
1692
  if (!provider) return null;
488
1693
 
489
- // Get completions from runtime
490
- const result = await provider.complete(codeInfo.code, codeInfo.offset, codeInfo.language);
1694
+ const callContext = getCallArgumentContext(codeInfo.code, codeInfo.offset);
1695
+
1696
+ let result;
1697
+
1698
+ // Inside a callable's argument-name position, only show synthesized keyword arguments.
1699
+ // This avoids noisy IPython globals/magics when the user has already opened `(` and is
1700
+ // clearly trying to pick a kwarg.
1701
+ if (callContext) {
1702
+ result = await getCallKeywordCompletions({
1703
+ provider,
1704
+ code: codeInfo.code,
1705
+ language: codeInfo.language,
1706
+ callContext,
1707
+ });
1708
+ } else {
1709
+ result = await provider.complete(codeInfo.code, codeInfo.offset, codeInfo.language);
1710
+ }
491
1711
  if (!result || !result.matches || result.matches.length === 0) return null;
492
1712
 
493
1713
  // Broadcast to awareness if available
@@ -516,12 +1736,24 @@ export function createRuntimeCompletionSource({ providers, getContent, stateMana
516
1736
  options: result.matches.map(match => {
517
1737
  const insertText = match.insertText || match.label;
518
1738
  const shouldRetrigger = /[\/\.]$/.test(insertText);
1739
+ const infoLoader = (!match.documentation && (provider.inspect || provider.hover))
1740
+ ? () => loadCompletionItemInfo({
1741
+ provider,
1742
+ code: codeInfo.code,
1743
+ cursorStart: result.cursorStart,
1744
+ cursorEnd: result.cursorEnd,
1745
+ insertText,
1746
+ language: codeInfo.language,
1747
+ })
1748
+ : null;
519
1749
 
520
1750
  return {
521
1751
  label: match.label,
522
1752
  type: mapCompletionKind(match.kind),
523
- detail: match.valuePreview || match.detail,
524
- info: match.documentation,
1753
+ detail: match.detail || match.valuePreview,
1754
+ info: createCompletionInfoRenderer(match, infoLoader),
1755
+ section: match.section,
1756
+ sortText: match.sortText,
525
1757
  // Use custom apply to retrigger completions for paths and chained access
526
1758
  apply: shouldRetrigger
527
1759
  ? (view, completion, from, to) => {
@@ -533,7 +1765,9 @@ export function createRuntimeCompletionSource({ providers, getContent, stateMana
533
1765
  setTimeout(() => startCompletion(view), 0);
534
1766
  }
535
1767
  : insertText,
536
- boost: match.kind === 'property' || match.kind === 'method' ? 1 : 0,
1768
+ boost: typeof match.boost === 'number'
1769
+ ? match.boost
1770
+ : (match.kind === 'property' || match.kind === 'method' ? 1 : 0),
537
1771
  };
538
1772
  }),
539
1773
  };
@@ -590,6 +1824,225 @@ export function createRuntimeCompletionExtension({ providers, getContent, stateM
590
1824
 
591
1825
  // #endregion COMPLETION_EXTENSION
592
1826
 
1827
+ // #region SIGNATURE_HELP
1828
+
1829
+ const setSignatureHelpTooltip = StateEffect.define();
1830
+ const clearSignatureHelpTooltip = StateEffect.define();
1831
+
1832
+ const signatureHelpTooltipField = StateField.define({
1833
+ create() {
1834
+ return null;
1835
+ },
1836
+ update(value, tr) {
1837
+ for (const effect of tr.effects) {
1838
+ if (effect.is(setSignatureHelpTooltip)) return effect.value;
1839
+ if (effect.is(clearSignatureHelpTooltip)) return null;
1840
+ }
1841
+ return value;
1842
+ },
1843
+ provide: f => showTooltip.from(f),
1844
+ });
1845
+
1846
+ /**
1847
+ * Create a tooltip descriptor for signature help.
1848
+ *
1849
+ * @param {number} pos
1850
+ * @param {InspectResult} inspectResult
1851
+ * @param {{currentSegment: {text: string}, usedKeywords: Set<string>, activeKeyword: string|null, positionalIndex: number}} callContext
1852
+ * @param {{compact?: boolean}} [options]
1853
+ * @returns {import('@codemirror/view').Tooltip|null}
1854
+ */
1855
+ function createSignatureHelpTooltipDescriptor(pos, inspectResult, callContext, { compact = false } = {}) {
1856
+ const html = formatSignatureHelpContent(inspectResult, callContext, { compact });
1857
+ if (!html) return null;
1858
+
1859
+ return {
1860
+ pos,
1861
+ above: compact,
1862
+ strictSide: false,
1863
+ arrow: false,
1864
+ clip: false,
1865
+ create(view) {
1866
+ const dom = document.createElement('div');
1867
+ dom.className = 'mrmd-runtime-signature-help';
1868
+ dom.innerHTML = html;
1869
+
1870
+ return {
1871
+ dom,
1872
+ offset: { x: 0, y: compact ? 6 : 14 },
1873
+ overlap: compact,
1874
+ getCoords: compact
1875
+ ? () => getAutocompleteTooltipRect(view) || view.coordsAtPos(pos)
1876
+ : undefined,
1877
+ positioned: () => {
1878
+ if (!compact) {
1879
+ dom.style.width = '';
1880
+ return;
1881
+ }
1882
+
1883
+ const popup = view.dom.querySelector('.cm-tooltip-autocomplete');
1884
+ if (popup instanceof HTMLElement) {
1885
+ const width = Math.min(Math.max(popup.offsetWidth - 12, 220), 420);
1886
+ dom.style.width = `${width}px`;
1887
+ } else {
1888
+ dom.style.width = '';
1889
+ }
1890
+ },
1891
+ };
1892
+ },
1893
+ };
1894
+ }
1895
+
1896
+ /**
1897
+ * Create a CodeMirror signature-help extension powered by runtime LSP inspect.
1898
+ *
1899
+ * Shows the active callable signature while the cursor is inside its argument list.
1900
+ *
1901
+ * @param {Object} options
1902
+ * @param {Map<string, RuntimeLSPProvider>} options.providers
1903
+ * @param {function(): string} options.getContent
1904
+ * @param {Object} [options.config]
1905
+ * @param {number} [options.config.debounceMs=80]
1906
+ * @returns {import('@codemirror/state').Extension}
1907
+ */
1908
+ export function createRuntimeSignatureHelpExtension({ providers, getContent, config = {} }) {
1909
+ const debounceMs = config.debounceMs ?? 80;
1910
+
1911
+ const plugin = ViewPlugin.fromClass(class {
1912
+ constructor(view) {
1913
+ this.view = view;
1914
+ this.requestId = 0;
1915
+ this.timer = null;
1916
+ this.destroyed = false;
1917
+ this.cachedInspectKey = null;
1918
+ this.cachedInspectResult = null;
1919
+ this.autocompleteOpen = false;
1920
+ this.schedule();
1921
+ }
1922
+
1923
+ update(update) {
1924
+ const autocompleteOpen = !!update.view.dom.querySelector('.cm-tooltip-autocomplete');
1925
+ if (update.docChanged || update.selectionSet || update.focusChanged || autocompleteOpen !== this.autocompleteOpen) {
1926
+ this.autocompleteOpen = autocompleteOpen;
1927
+ this.schedule();
1928
+ }
1929
+ }
1930
+
1931
+ destroy() {
1932
+ this.destroyed = true;
1933
+ if (this.timer) clearTimeout(this.timer);
1934
+ }
1935
+
1936
+ schedule() {
1937
+ if (this.timer) clearTimeout(this.timer);
1938
+ this.timer = setTimeout(() => {
1939
+ this.timer = null;
1940
+ this.refresh();
1941
+ }, debounceMs);
1942
+ }
1943
+
1944
+ clear() {
1945
+ if (this.destroyed) return;
1946
+ if (!this.view.state.field(signatureHelpTooltipField, false)) return;
1947
+ this.view.dispatch({ effects: clearSignatureHelpTooltip.of(null) });
1948
+ }
1949
+
1950
+ async refresh() {
1951
+ if (this.destroyed) return;
1952
+
1953
+ const selection = this.view.state.selection.main;
1954
+ if (!selection.empty) {
1955
+ this.clear();
1956
+ return;
1957
+ }
1958
+
1959
+ const content = getContent();
1960
+ const pos = selection.head;
1961
+ const codeInfo = getCodeAtPosition(content, pos);
1962
+ if (!codeInfo) {
1963
+ this.clear();
1964
+ return;
1965
+ }
1966
+
1967
+ const provider = findProviderForLanguage(providers, codeInfo.language);
1968
+ if (!provider?.inspect && !provider?.hover) {
1969
+ this.clear();
1970
+ return;
1971
+ }
1972
+
1973
+ const callContext = getActiveCallContext(codeInfo.code, codeInfo.offset);
1974
+ if (!callContext) {
1975
+ this.clear();
1976
+ return;
1977
+ }
1978
+
1979
+ const inspectKey = `${codeInfo.language}:${codeInfo.cell.start}:${callContext.openParen}:${callContext.callee}`;
1980
+ let inspectResult = this.cachedInspectKey === inspectKey ? this.cachedInspectResult : null;
1981
+
1982
+ const requestId = ++this.requestId;
1983
+ if (!inspectResult) {
1984
+ inspectResult = await getSignatureInspectData({
1985
+ provider,
1986
+ code: codeInfo.code,
1987
+ cursor: callContext.openParen,
1988
+ language: codeInfo.language,
1989
+ });
1990
+
1991
+ if (this.destroyed || requestId !== this.requestId) return;
1992
+
1993
+ if (!inspectResult?.found || !inspectResult.signature) {
1994
+ this.cachedInspectKey = null;
1995
+ this.cachedInspectResult = null;
1996
+ this.clear();
1997
+ return;
1998
+ }
1999
+
2000
+ this.cachedInspectKey = inspectKey;
2001
+ this.cachedInspectResult = inspectResult;
2002
+ }
2003
+
2004
+ const latestSelection = this.view.state.selection.main;
2005
+ if (!latestSelection.empty) {
2006
+ this.clear();
2007
+ return;
2008
+ }
2009
+
2010
+ const latestContent = getContent();
2011
+ const latestPos = latestSelection.head;
2012
+ const latestCodeInfo = getCodeAtPosition(latestContent, latestPos);
2013
+ const latestCallContext = latestCodeInfo
2014
+ ? getActiveCallContext(latestCodeInfo.code, latestCodeInfo.offset)
2015
+ : null;
2016
+ const latestInspectKey = latestCodeInfo && latestCallContext
2017
+ ? `${latestCodeInfo.language}:${latestCodeInfo.cell.start}:${latestCallContext.openParen}:${latestCallContext.callee}`
2018
+ : null;
2019
+
2020
+ if (!latestCodeInfo || !latestCallContext || latestInspectKey !== inspectKey) {
2021
+ this.schedule();
2022
+ return;
2023
+ }
2024
+
2025
+ const autocompleteOpen = this.autocompleteOpen || !!this.view.dom.querySelector('.cm-tooltip-autocomplete');
2026
+ const tooltip = createSignatureHelpTooltipDescriptor(
2027
+ latestPos,
2028
+ inspectResult,
2029
+ latestCallContext,
2030
+ { compact: autocompleteOpen }
2031
+ );
2032
+ if (!tooltip) {
2033
+ this.clear();
2034
+ return;
2035
+ }
2036
+
2037
+ this.view.dispatch({ effects: setSignatureHelpTooltip.of(tooltip) });
2038
+ }
2039
+ });
2040
+
2041
+ return [signatureHelpTooltipField, plugin];
2042
+ }
2043
+
2044
+ // #endregion SIGNATURE_HELP
2045
+
593
2046
  // #region VARIABLE_EXPLORER
594
2047
 
595
2048
  /**
@@ -722,14 +2175,22 @@ function getCellIndex(content, cell) {
722
2175
  export const runtimeLspStyles = `
723
2176
  /* Runtime Hover Tooltip */
724
2177
  .mrmd-runtime-hover {
725
- background: var(--mrmd-bg, #1e1e1e);
726
- border: 1px solid var(--mrmd-border, #333);
727
- border-radius: 6px;
2178
+ background: var(--widget-surface-elevated, var(--editor-background, #1e1e1e));
2179
+ border: 1px solid var(--widget-border, #333);
2180
+ border-radius: var(--widget-border-radius, 6px);
728
2181
  padding: 8px 12px;
729
- max-width: 400px;
2182
+ max-width: 460px;
2183
+ max-height: min(52vh, 440px);
2184
+ overflow: auto;
730
2185
  font-size: 13px;
731
- line-height: 1.4;
732
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
2186
+ line-height: 1.45;
2187
+ color: var(--widget-text, var(--editor-foreground, #e1e1e1));
2188
+ box-shadow: var(--mrmd-shadow-md, 0 6px 18px rgba(0, 0, 0, 0.3));
2189
+ user-select: text;
2190
+ }
2191
+
2192
+ .mrmd-runtime-hover-sticky {
2193
+ border-color: var(--widget-border-focus, var(--mrmd-accent, #58a6ff));
733
2194
  }
734
2195
 
735
2196
  .mrmd-hover-content {
@@ -738,26 +2199,54 @@ export const runtimeLspStyles = `
738
2199
  gap: 6px;
739
2200
  }
740
2201
 
2202
+ .mrmd-hover-header {
2203
+ display: flex;
2204
+ align-items: center;
2205
+ justify-content: space-between;
2206
+ gap: 10px;
2207
+ }
2208
+
741
2209
  .mrmd-hover-name {
742
2210
  font-weight: 600;
743
2211
  }
744
2212
 
2213
+ .mrmd-hover-copy {
2214
+ border: 1px solid var(--widget-border, #333);
2215
+ background: var(--widget-surface, rgba(0, 0, 0, 0.2));
2216
+ color: var(--widget-text-muted, #9ca3af);
2217
+ border-radius: 4px;
2218
+ padding: 2px 8px;
2219
+ font-size: 11px;
2220
+ line-height: 1.2;
2221
+ cursor: pointer;
2222
+ }
2223
+
2224
+ .mrmd-hover-copy:hover {
2225
+ color: var(--widget-text, #e5e7eb);
2226
+ background: var(--widget-surface-hover, rgba(255, 255, 255, 0.08));
2227
+ }
2228
+
2229
+ .mrmd-hover-copy:active {
2230
+ transform: translateY(1px);
2231
+ }
2232
+
745
2233
  .mrmd-hover-name code {
746
- color: var(--mrmd-text, #e1e1e1);
2234
+ color: var(--syntax-variable, var(--widget-text, #e1e1e1));
747
2235
  background: none;
748
2236
  padding: 0;
2237
+ font-family: var(--widget-font-mono, monospace);
749
2238
  }
750
2239
 
751
2240
  .mrmd-hover-type {
752
- color: var(--mrmd-type-color, #4ec9b0);
2241
+ color: var(--syntax-type, #4ec9b0);
753
2242
  font-size: 12px;
754
2243
  font-weight: normal;
755
2244
  margin-left: 8px;
756
2245
  }
757
2246
 
758
2247
  .mrmd-hover-signature {
759
- color: var(--mrmd-signature-color, #dcdcaa);
760
- font-family: monospace;
2248
+ color: var(--syntax-function, #dcdcaa);
2249
+ font-family: var(--widget-font-mono, monospace);
761
2250
  font-size: 12px;
762
2251
  }
763
2252
 
@@ -767,53 +2256,170 @@ export const runtimeLspStyles = `
767
2256
  }
768
2257
 
769
2258
  .mrmd-hover-value {
770
- color: var(--mrmd-value-color, #ce9178);
771
- font-family: monospace;
2259
+ color: var(--syntax-string, var(--widget-text, #ce9178));
2260
+ font-family: var(--widget-font-mono, monospace);
772
2261
  font-size: 12px;
773
- max-height: 100px;
2262
+ max-height: 120px;
774
2263
  overflow: auto;
775
2264
  white-space: pre-wrap;
776
2265
  word-break: break-word;
777
2266
  }
778
2267
 
779
2268
  .mrmd-hover-docs {
780
- color: var(--mrmd-docs-color, #9cdcfe);
2269
+ color: var(--widget-text-muted, #9cdcfe);
781
2270
  font-size: 12px;
782
- border-top: 1px solid var(--mrmd-border, #333);
2271
+ border-top: 1px solid var(--widget-border, #333);
783
2272
  padding-top: 6px;
784
2273
  margin-top: 2px;
2274
+ white-space: pre-wrap;
2275
+ }
2276
+
2277
+ .mrmd-hover-source {
2278
+ color: var(--widget-text-muted, #9ca3af);
2279
+ font-size: 11px;
2280
+ border-top: 1px dashed var(--widget-border, #333);
2281
+ padding-top: 6px;
2282
+ margin-top: 2px;
2283
+ word-break: break-all;
2284
+ }
2285
+
2286
+ .mrmd-hover-source-link {
2287
+ color: var(--syntax-link, var(--widget-text-accent, #58a6ff));
2288
+ text-decoration: underline dotted;
2289
+ text-underline-offset: 2px;
2290
+ }
2291
+
2292
+ .mrmd-hover-source-link:hover {
2293
+ text-decoration-style: solid;
2294
+ }
2295
+
2296
+ /* Autocomplete / completion info */
2297
+ .cm-tooltip.cm-tooltip-autocomplete {
2298
+ margin-top: 8px;
2299
+ }
2300
+
2301
+ .cm-tooltip.cm-completionInfo {
2302
+ margin-left: 8px;
2303
+ max-width: 320px;
2304
+ max-height: 220px;
2305
+ overflow: auto;
2306
+ }
2307
+
2308
+ .mrmd-completion-info {
2309
+ display: flex;
2310
+ flex-direction: column;
2311
+ gap: 6px;
2312
+ max-width: 300px;
2313
+ font-size: 12px;
2314
+ line-height: 1.4;
2315
+ }
2316
+
2317
+ .mrmd-completion-info__title {
2318
+ color: var(--syntax-variable, var(--widget-text, #e1e1e1));
2319
+ font-family: var(--widget-font-mono, monospace);
2320
+ font-weight: 600;
2321
+ }
2322
+
2323
+ .mrmd-completion-info__detail {
2324
+ color: var(--syntax-parameter, #9cdcfe);
2325
+ font-family: var(--widget-font-mono, monospace);
2326
+ font-size: 11px;
2327
+ }
2328
+
2329
+ .mrmd-completion-info__docs {
2330
+ color: var(--widget-text-muted, #9ca3af);
2331
+ white-space: pre-wrap;
2332
+ }
2333
+
2334
+ /* Runtime Signature Help */
2335
+ .mrmd-runtime-signature-help {
2336
+ background: var(--widget-surface-elevated, var(--editor-background, #1e1e1e));
2337
+ border: 1px solid var(--widget-border, #333);
2338
+ border-radius: var(--widget-border-radius, 6px);
2339
+ padding: 5px 8px;
2340
+ max-width: 440px;
2341
+ color: var(--widget-text, var(--editor-foreground, #e1e1e1));
2342
+ box-shadow: var(--mrmd-shadow-md, 0 6px 18px rgba(0, 0, 0, 0.28));
2343
+ font-size: 11px;
2344
+ line-height: 1.35;
2345
+ }
2346
+
2347
+ .mrmd-signature-help {
2348
+ display: flex;
2349
+ flex-direction: column;
2350
+ gap: 4px;
2351
+ }
2352
+
2353
+ .mrmd-signature-help--compact {
2354
+ gap: 2px;
2355
+ }
2356
+
2357
+ .mrmd-signature-help__signature {
2358
+ font-family: var(--widget-font-mono, monospace);
2359
+ color: var(--widget-text, var(--editor-foreground, #e1e1e1));
2360
+ white-space: pre-wrap;
2361
+ word-break: break-word;
2362
+ }
2363
+
2364
+ .mrmd-signature-help--compact .mrmd-signature-help__signature {
2365
+ white-space: nowrap;
2366
+ overflow: hidden;
2367
+ text-overflow: ellipsis;
2368
+ max-width: 100%;
2369
+ }
2370
+
2371
+ .mrmd-signature-help__signature code {
2372
+ background: none;
2373
+ padding: 0;
785
2374
  }
786
2375
 
787
- /* Light theme adjustments */
788
- .cm-theme-light .mrmd-runtime-hover,
789
- :root:not(.dark) .mrmd-runtime-hover {
790
- background: #ffffff;
791
- border-color: #e0e0e0;
792
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
2376
+ .mrmd-signature-help__head,
2377
+ .mrmd-signature-help__tail {
2378
+ color: var(--syntax-function, #dcdcaa);
793
2379
  }
794
2380
 
795
- .cm-theme-light .mrmd-hover-name code,
796
- :root:not(.dark) .mrmd-hover-name code {
797
- color: #1e1e1e;
2381
+ .mrmd-signature-help__part {
2382
+ color: var(--widget-text, var(--editor-foreground, #e1e1e1));
798
2383
  }
799
2384
 
800
- .cm-theme-light .mrmd-hover-type,
801
- :root:not(.dark) .mrmd-hover-type {
802
- color: #267f99;
2385
+ .mrmd-signature-help__part--marker,
2386
+ .mrmd-signature-help__comma {
2387
+ color: var(--widget-text-muted, #9ca3af);
803
2388
  }
804
2389
 
805
- .cm-theme-light .mrmd-hover-value,
806
- :root:not(.dark) .mrmd-hover-value {
807
- color: #a31515;
2390
+ .mrmd-signature-help__part--active {
2391
+ color: var(--editor-background, #111827);
2392
+ background: var(--mrmd-accent, #58a6ff);
2393
+ border-radius: 4px;
2394
+ padding: 1px 4px;
808
2395
  }
809
2396
 
810
- .cm-theme-light .mrmd-hover-docs,
811
- :root:not(.dark) .mrmd-hover-docs {
812
- color: #0070c1;
813
- border-top-color: #e0e0e0;
2397
+ .mrmd-signature-help__active {
2398
+ color: var(--syntax-parameter, #9cdcfe);
2399
+ font-family: var(--widget-font-mono, monospace);
2400
+ font-size: 11px;
2401
+ }
2402
+
2403
+ .mrmd-signature-help__name {
2404
+ color: var(--widget-text-muted, #9ca3af);
2405
+ font-size: 11px;
2406
+ }
2407
+
2408
+ .mrmd-signature-help__docs {
2409
+ color: var(--widget-text-muted, #9cdcfe);
2410
+ border-top: 1px solid var(--widget-border, #333);
2411
+ padding-top: 4px;
2412
+ white-space: pre-wrap;
2413
+ }
2414
+
2415
+ .mrmd-signature-help--compact .mrmd-signature-help__docs {
2416
+ border-top: 0;
2417
+ padding-top: 0;
2418
+ color: var(--widget-text-muted, #9ca3af);
814
2419
  }
815
2420
  `;
816
2421
 
2422
+
817
2423
  let stylesInjected = false;
818
2424
 
819
2425
  /**
@@ -842,6 +2448,7 @@ export default {
842
2448
  createRuntimeHoverExtension,
843
2449
  createRuntimeCompletionSource,
844
2450
  createRuntimeCompletionExtension,
2451
+ createRuntimeSignatureHelpExtension,
845
2452
 
846
2453
  // Variable Explorer
847
2454
  createVariableExplorer,