mrmd-editor 0.7.1 → 0.8.1

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 +7 -3
  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 +567 -27
  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
@@ -33,6 +33,28 @@ import { terminalToHtml, hasAnsi, stripAnsi, ansiStyles, parseAnsiDecorations }
33
33
  // Regex to match ANSI escape sequences (same as in terminal.js)
34
34
  const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*[a-zA-Z]/g;
35
35
 
36
+ const LONG_OUTPUT_WIDGET_LINE_THRESHOLD = 15;
37
+ const JSON_OUTPUT_WIDGET_SETTING_KEY = 'mrmd-json-output-widget-enabled';
38
+ const LONG_OUTPUT_WIDGET_SETTING_KEY = 'mrmd-long-output-widget-enabled';
39
+
40
+ function readBooleanSetting(key, defaultValue = true) {
41
+ try {
42
+ if (typeof window === 'undefined' || !window.localStorage) return defaultValue;
43
+ const raw = window.localStorage.getItem(key);
44
+ if (raw == null) return defaultValue;
45
+ return !['0', 'false', 'off', 'no'].includes(String(raw).trim().toLowerCase());
46
+ } catch {
47
+ return defaultValue;
48
+ }
49
+ }
50
+
51
+ function getOutputWidgetSettings() {
52
+ return {
53
+ jsonEnabled: readBooleanSetting(JSON_OUTPUT_WIDGET_SETTING_KEY, true),
54
+ longOutputEnabled: readBooleanSetting(LONG_OUTPUT_WIDGET_SETTING_KEY, true),
55
+ };
56
+ }
57
+
36
58
  /**
37
59
  * Zero-width widget used to completely hide ANSI escape sequences.
38
60
  * Using Decoration.replace with this widget removes the escape codes
@@ -64,22 +86,303 @@ function escapeHtml(text) {
64
86
  .replace(/'/g, ''');
65
87
  }
66
88
 
89
+ function removeTrailingCommasOutsideStrings(input) {
90
+ let output = '';
91
+ let inDouble = false;
92
+
93
+ for (let i = 0; i < input.length; i++) {
94
+ const ch = input[i];
95
+
96
+ if (inDouble) {
97
+ output += ch;
98
+ if (ch === '\\' && i + 1 < input.length) {
99
+ output += input[i + 1];
100
+ i++;
101
+ } else if (ch === '"') {
102
+ inDouble = false;
103
+ }
104
+ continue;
105
+ }
106
+
107
+ if (ch === '"') {
108
+ inDouble = true;
109
+ output += ch;
110
+ continue;
111
+ }
112
+
113
+ if (ch === ',') {
114
+ let lookahead = i + 1;
115
+ while (lookahead < input.length && /\s/.test(input[lookahead])) lookahead++;
116
+ if (lookahead < input.length && (input[lookahead] === '}' || input[lookahead] === ']')) {
117
+ continue;
118
+ }
119
+ }
120
+
121
+ output += ch;
122
+ }
123
+
124
+ return output;
125
+ }
126
+
127
+ function replacePythonLiteralsOutsideStrings(input) {
128
+ const replacements = {
129
+ True: 'true',
130
+ False: 'false',
131
+ None: 'null',
132
+ };
133
+
134
+ let output = '';
135
+ let token = '';
136
+ let inDouble = false;
137
+
138
+ const flushToken = () => {
139
+ if (!token) return;
140
+ output += replacements[token] ?? token;
141
+ token = '';
142
+ };
143
+
144
+ for (let i = 0; i < input.length; i++) {
145
+ const ch = input[i];
146
+
147
+ if (inDouble) {
148
+ flushToken();
149
+ output += ch;
150
+ if (ch === '\\' && i + 1 < input.length) {
151
+ output += input[i + 1];
152
+ i++;
153
+ } else if (ch === '"') {
154
+ inDouble = false;
155
+ }
156
+ continue;
157
+ }
158
+
159
+ if (ch === '"') {
160
+ flushToken();
161
+ inDouble = true;
162
+ output += ch;
163
+ continue;
164
+ }
165
+
166
+ if (/[A-Za-z_]/.test(ch)) {
167
+ token += ch;
168
+ continue;
169
+ }
170
+
171
+ flushToken();
172
+ output += ch;
173
+ }
174
+
175
+ flushToken();
176
+ return output;
177
+ }
178
+
179
+ function normalizeJsonLikeOutput(input) {
180
+ let output = '';
181
+ let inSingle = false;
182
+ let inDouble = false;
183
+
184
+ for (let i = 0; i < input.length; i++) {
185
+ const ch = input[i];
186
+
187
+ if (inSingle) {
188
+ if (ch === '\\') {
189
+ const next = input[i + 1];
190
+ if (next === undefined) {
191
+ output += '\\\\';
192
+ continue;
193
+ }
194
+
195
+ if (next === "'") {
196
+ output += "'";
197
+ i++;
198
+ continue;
199
+ }
200
+ if (next === '"') {
201
+ output += '\\"';
202
+ i++;
203
+ continue;
204
+ }
205
+ if (next === '\\') {
206
+ output += '\\\\';
207
+ i++;
208
+ continue;
209
+ }
210
+ if (next === 'x' && /^[0-9A-Fa-f]{2}$/.test(input.slice(i + 2, i + 4))) {
211
+ output += `\\u00${input.slice(i + 2, i + 4)}`;
212
+ i += 3;
213
+ continue;
214
+ }
215
+ if (next === 'u' && /^[0-9A-Fa-f]{4}$/.test(input.slice(i + 2, i + 6))) {
216
+ output += `\\u${input.slice(i + 2, i + 6)}`;
217
+ i += 5;
218
+ continue;
219
+ }
220
+ if ('bfnrt/'.includes(next)) {
221
+ output += `\\${next}`;
222
+ i++;
223
+ continue;
224
+ }
225
+
226
+ output += `\\${next}`;
227
+ i++;
228
+ continue;
229
+ }
230
+
231
+ if (ch === "'") {
232
+ inSingle = false;
233
+ output += '"';
234
+ continue;
235
+ }
236
+
237
+ if (ch === '"') {
238
+ output += '\\"';
239
+ } else if (ch === '\n') {
240
+ output += '\\n';
241
+ } else if (ch === '\r') {
242
+ output += '\\r';
243
+ } else {
244
+ output += ch;
245
+ }
246
+ continue;
247
+ }
248
+
249
+ if (inDouble) {
250
+ output += ch;
251
+ if (ch === '\\' && i + 1 < input.length) {
252
+ output += input[i + 1];
253
+ i++;
254
+ } else if (ch === '"') {
255
+ inDouble = false;
256
+ }
257
+ continue;
258
+ }
259
+
260
+ if (ch === "'") {
261
+ inSingle = true;
262
+ output += '"';
263
+ continue;
264
+ }
265
+
266
+ if (ch === '"') {
267
+ inDouble = true;
268
+ output += ch;
269
+ continue;
270
+ }
271
+
272
+ output += ch;
273
+ }
274
+
275
+ if (inSingle || inDouble) return null;
276
+
277
+ const withoutTrailingCommas = removeTrailingCommasOutsideStrings(output);
278
+ return replacePythonLiteralsOutsideStrings(withoutTrailingCommas);
279
+ }
280
+
281
+ function isJsonContainerString(value) {
282
+ return (
283
+ (value.startsWith('{') && value.endsWith('}')) ||
284
+ (value.startsWith('[') && value.endsWith(']'))
285
+ );
286
+ }
287
+
288
+ function isJsonContainerValue(value) {
289
+ return value !== null && (Array.isArray(value) || typeof value === 'object');
290
+ }
291
+
292
+ function tryParseJsonLikeContainer(value) {
293
+ const trimmed = String(value ?? '').trim();
294
+ if (!trimmed || !isJsonContainerString(trimmed)) return null;
295
+
296
+ try {
297
+ const parsed = JSON.parse(trimmed);
298
+ return isJsonContainerValue(parsed) ? parsed : null;
299
+ } catch {
300
+ const normalized = normalizeJsonLikeOutput(trimmed);
301
+ if (!normalized) return null;
302
+
303
+ try {
304
+ const parsed = JSON.parse(normalized);
305
+ return isJsonContainerValue(parsed) ? parsed : null;
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+ }
311
+
312
+ function parseOutLabelLine(line) {
313
+ const match = /^\s*out\[(\d+)\]:\s*(.*)$/i.exec(line);
314
+ if (!match) return null;
315
+ return {
316
+ label: `Out[${match[1]}]`,
317
+ remainder: match[2] ?? '',
318
+ };
319
+ }
320
+
321
+ function tryParseOutLabeledJsonOutput(input) {
322
+ const lines = String(input ?? '').split(/\r?\n/);
323
+ const labels = [];
324
+ const values = [];
325
+
326
+ let i = 0;
327
+ while (i < lines.length) {
328
+ while (i < lines.length && !lines[i].trim()) i++;
329
+ if (i >= lines.length) break;
330
+
331
+ const parsedLabel = parseOutLabelLine(lines[i]);
332
+ if (!parsedLabel) return null;
333
+
334
+ labels.push(parsedLabel.label);
335
+ i++;
336
+
337
+ const sectionLines = [];
338
+ if (parsedLabel.remainder.trim()) {
339
+ sectionLines.push(parsedLabel.remainder);
340
+ }
341
+
342
+ while (i < lines.length) {
343
+ if (parseOutLabelLine(lines[i])) break;
344
+ sectionLines.push(lines[i]);
345
+ i++;
346
+ }
347
+
348
+ const sectionText = sectionLines.join('\n').trim();
349
+ const parsedValue = tryParseJsonLikeContainer(sectionText);
350
+ if (parsedValue === null) return null;
351
+ values.push(parsedValue);
352
+ }
353
+
354
+ if (values.length === 0) return null;
355
+ return {
356
+ value: values.length === 1 ? values[0] : values,
357
+ labels,
358
+ };
359
+ }
360
+
67
361
  function tryParseJsonOutput(content) {
68
362
  if (!content || hasAnsi(content)) return null;
69
363
  if (content.length > 250_000) return null; // Guard large payloads
70
364
 
71
365
  const trimmed = content.trim();
72
366
  if (!trimmed) return null;
73
- const looksLikeObjectOrArray =
74
- (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
75
- (trimmed.startsWith('[') && trimmed.endsWith(']'));
76
- if (!looksLikeObjectOrArray) return null;
77
367
 
78
- try {
79
- return JSON.parse(trimmed);
80
- } catch {
81
- return null;
368
+ const direct = tryParseJsonLikeContainer(trimmed);
369
+ if (direct !== null) {
370
+ return {
371
+ value: direct,
372
+ labels: [],
373
+ };
82
374
  }
375
+
376
+ return tryParseOutLabeledJsonOutput(trimmed);
377
+ }
378
+
379
+ function countOutputLines(content) {
380
+ const normalized = String(content ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
381
+ const lines = normalized.split('\n');
382
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
383
+ lines.pop();
384
+ }
385
+ return lines.length;
83
386
  }
84
387
 
85
388
  function jsonType(value) {
@@ -381,7 +684,7 @@ class OutputWidget extends WidgetType {
381
684
  }
382
685
 
383
686
  ignoreEvent() {
384
- return false;
687
+ return true;
385
688
  }
386
689
  }
387
690
 
@@ -541,7 +844,7 @@ class HtmlOutputWidget extends WidgetType {
541
844
  }
542
845
 
543
846
  ignoreEvent() {
544
- return false;
847
+ return true;
545
848
  }
546
849
  }
547
850
 
@@ -734,7 +1037,98 @@ class CssOutputWidget extends WidgetType {
734
1037
  }
735
1038
 
736
1039
  ignoreEvent() {
737
- return false;
1040
+ return true;
1041
+ }
1042
+ }
1043
+
1044
+ /**
1045
+ * Widget for long plain output blocks (line-threshold based).
1046
+ * Keeps output scrollable so very long results don't expand notebook height.
1047
+ */
1048
+ class ScrollableOutputWidget extends WidgetType {
1049
+ /**
1050
+ * @param {string} content - Output content
1051
+ * @param {boolean} hidden - Whether widget should be hidden
1052
+ * @param {number} blockStart - Document position where this output block starts
1053
+ * @param {string|null} execId - Execution ID for this output block
1054
+ * @param {number} lineCount - Number of output lines
1055
+ */
1056
+ constructor(content, hidden = false, blockStart = 0, execId = null, lineCount = 0) {
1057
+ super();
1058
+ this.content = content;
1059
+ this.hidden = hidden;
1060
+ this.blockStart = blockStart;
1061
+ this.execId = execId;
1062
+ this.lineCount = lineCount;
1063
+ }
1064
+
1065
+ eq(other) {
1066
+ return other.content === this.content &&
1067
+ other.hidden === this.hidden &&
1068
+ other.blockStart === this.blockStart &&
1069
+ other.execId === this.execId &&
1070
+ other.lineCount === this.lineCount;
1071
+ }
1072
+
1073
+ toDOM() {
1074
+ const container = document.createElement('div');
1075
+ container.className = 'cm-scroll-output-widget' + (this.hidden ? ' cm-output-widget-hidden' : '');
1076
+ container.dataset.outputBlockStart = String(this.blockStart);
1077
+ if (this.execId) {
1078
+ container.dataset.execId = this.execId;
1079
+ }
1080
+
1081
+ const header = document.createElement('div');
1082
+ header.className = 'cm-scroll-output-header';
1083
+ header.innerHTML = `
1084
+ <span class="cm-scroll-output-badge">Output</span>
1085
+ <span class="cm-scroll-output-lines">${escapeHtml(String(this.lineCount))} lines</span>
1086
+ <div class="cm-scroll-output-actions">
1087
+ <button type="button" class="cm-scroll-output-action" data-action="expand">Expand</button>
1088
+ <button type="button" class="cm-scroll-output-action" data-action="collapse">Collapse</button>
1089
+ <button type="button" class="cm-scroll-output-action" data-action="copy">Copy</button>
1090
+ </div>
1091
+ `;
1092
+ container.appendChild(header);
1093
+
1094
+ const contentWrap = document.createElement('div');
1095
+ contentWrap.className = 'cm-scroll-output-body';
1096
+
1097
+ const pre = document.createElement('pre');
1098
+ pre.className = 'cm-scroll-output-content';
1099
+ pre.innerHTML = terminalToHtml(this.content);
1100
+
1101
+ contentWrap.appendChild(pre);
1102
+ container.appendChild(contentWrap);
1103
+
1104
+ const actionButtons = header.querySelectorAll('.cm-scroll-output-action');
1105
+ actionButtons.forEach((btn) => {
1106
+ btn.addEventListener('click', (e) => {
1107
+ e.preventDefault();
1108
+ e.stopPropagation();
1109
+
1110
+ const action = btn.getAttribute('data-action');
1111
+ if (action === 'expand') {
1112
+ contentWrap.classList.add('cm-scroll-output-body-expanded');
1113
+ } else if (action === 'collapse') {
1114
+ contentWrap.classList.remove('cm-scroll-output-body-expanded');
1115
+ } else if (action === 'copy') {
1116
+ navigator.clipboard.writeText(stripAnsi(this.content)).then(() => {
1117
+ const previous = btn.textContent;
1118
+ btn.textContent = 'Copied';
1119
+ setTimeout(() => {
1120
+ btn.textContent = previous;
1121
+ }, 1200);
1122
+ });
1123
+ }
1124
+ });
1125
+ });
1126
+
1127
+ return container;
1128
+ }
1129
+
1130
+ ignoreEvent() {
1131
+ return true;
738
1132
  }
739
1133
  }
740
1134
 
@@ -771,17 +1165,23 @@ class JsonOutputWidget extends WidgetType {
771
1165
  container.dataset.execId = this.execId;
772
1166
  }
773
1167
 
774
- const parsed = tryParseJsonOutput(this.content);
775
- if (parsed === null) {
1168
+ const parsedResult = tryParseJsonOutput(this.content);
1169
+ if (parsedResult === null) {
776
1170
  container.innerHTML = `<pre class="cm-json-fallback">${escapeHtml(this.content)}</pre>`;
777
1171
  return container;
778
1172
  }
779
1173
 
1174
+ const parsedValue = parsedResult.value;
1175
+ const originLabel = parsedResult.labels.length > 0
1176
+ ? (parsedResult.labels.length === 1 ? parsedResult.labels[0] : parsedResult.labels.join(', '))
1177
+ : null;
1178
+
780
1179
  const header = document.createElement('div');
781
1180
  header.className = 'cm-json-header';
782
1181
  header.innerHTML = `
783
1182
  <span class="cm-json-badge">JSON</span>
784
- <span class="cm-json-summary">${escapeHtml(summarizeJson(parsed))}</span>
1183
+ ${originLabel ? `<span class="cm-json-origin">${escapeHtml(originLabel)}</span>` : ''}
1184
+ <span class="cm-json-summary">${escapeHtml(summarizeJson(parsedValue))}</span>
785
1185
  <div class="cm-json-actions">
786
1186
  <button type="button" class="cm-json-action" data-action="expand">Expand</button>
787
1187
  <button type="button" class="cm-json-action" data-action="collapse">Collapse</button>
@@ -792,7 +1192,7 @@ class JsonOutputWidget extends WidgetType {
792
1192
 
793
1193
  const tree = document.createElement('div');
794
1194
  tree.className = 'cm-json-tree';
795
- tree.appendChild(buildJsonTreeNode(null, parsed));
1195
+ tree.appendChild(buildJsonTreeNode(null, parsedValue));
796
1196
  container.appendChild(tree);
797
1197
 
798
1198
  const actionButtons = header.querySelectorAll('.cm-json-action');
@@ -814,7 +1214,7 @@ class JsonOutputWidget extends WidgetType {
814
1214
  }
815
1215
  });
816
1216
  } else if (action === 'copy') {
817
- navigator.clipboard.writeText(JSON.stringify(parsed, null, 2)).then(() => {
1217
+ navigator.clipboard.writeText(JSON.stringify(parsedValue, null, 2)).then(() => {
818
1218
  const previous = btn.textContent;
819
1219
  btn.textContent = 'Copied';
820
1220
  setTimeout(() => {
@@ -829,7 +1229,7 @@ class JsonOutputWidget extends WidgetType {
829
1229
  }
830
1230
 
831
1231
  ignoreEvent() {
832
- return false;
1232
+ return true;
833
1233
  }
834
1234
  }
835
1235
 
@@ -978,6 +1378,7 @@ function buildDecorations(view, awarenessSystem) {
978
1378
  const cursorPos = view.state.selection.main.head;
979
1379
  const cursorLine = doc.lineAt(cursorPos).number;
980
1380
  const text = doc.toString();
1381
+ const outputWidgetSettings = getOutputWidgetSettings();
981
1382
 
982
1383
  // Find ```output or ```output:execId blocks (supports 3+ backticks for nesting)
983
1384
  // Group 1: backticks, Group 2: optional execId, Group 3: content
@@ -1046,10 +1447,17 @@ function buildDecorations(view, awarenessSystem) {
1046
1447
  // Check if output is empty (just whitespace)
1047
1448
  const trimmedContent = content.trim();
1048
1449
  const isEmpty = trimmedContent.length === 0;
1049
- const parsedJson = !isEmpty && (outputType === null || outputType === 'json')
1450
+ const parsedJson = !isEmpty && outputWidgetSettings.jsonEnabled && (outputType === null || outputType === 'json')
1050
1451
  ? tryParseJsonOutput(trimmedContent)
1051
1452
  : null;
1052
1453
  const shouldRenderJson = parsedJson !== null;
1454
+ const outputLineCount = isEmpty ? 0 : countOutputLines(content);
1455
+ const shouldRenderScrollableOutput =
1456
+ outputWidgetSettings.longOutputEnabled &&
1457
+ !isEmpty &&
1458
+ outputType === null &&
1459
+ !shouldRenderJson &&
1460
+ outputLineCount > LONG_OUTPUT_WIDGET_LINE_THRESHOLD;
1053
1461
 
1054
1462
  if (anyCollaboratorFocused) {
1055
1463
  // EDITING MODE: Keep ANSI colors rendered, but make escape sequences
@@ -1116,7 +1524,7 @@ function buildDecorations(view, awarenessSystem) {
1116
1524
  // Style the fence lines (opening and closing fences).
1117
1525
  // Rich output widgets (HTML/CSS, including Mermaid rendered as HTML) are
1118
1526
  // attached to the opening fence line, so that line must remain unclipped.
1119
- const richOutput = outputType === 'html' || outputType === 'css' || shouldRenderJson;
1527
+ const richOutput = outputType === 'html' || outputType === 'css' || shouldRenderJson || shouldRenderScrollableOutput;
1120
1528
  const startFenceClass = richOutput
1121
1529
  ? 'cm-output-fence-line cm-output-fence-start cm-output-fence-rich-start'
1122
1530
  : 'cm-output-fence-line cm-output-fence-start';
@@ -1189,6 +1597,23 @@ function buildDecorations(view, awarenessSystem) {
1189
1597
  side: 1,
1190
1598
  }).range(startLine.to)
1191
1599
  );
1600
+ } else if (shouldRenderScrollableOutput) {
1601
+ // LONG OUTPUT: Hide raw lines and show a scrollable output widget
1602
+ for (let i = startLine.number + 1; i < endLine.number; i++) {
1603
+ const line = doc.line(i);
1604
+ decorations.push(
1605
+ Decoration.line({
1606
+ class: 'cm-output-content-line cm-rich-output-hidden',
1607
+ }).range(line.from)
1608
+ );
1609
+ }
1610
+
1611
+ decorations.push(
1612
+ Decoration.widget({
1613
+ widget: new ScrollableOutputWidget(content, false, blockStart, execId, outputLineCount),
1614
+ side: 1,
1615
+ }).range(startLine.to)
1616
+ );
1192
1617
  } else {
1193
1618
  // REGULAR OUTPUT: Show with ANSI styling
1194
1619
  // Style content lines with output block background
@@ -1433,7 +1858,7 @@ export const outputWidgetStyles = `
1433
1858
  /* Widget is absolutely positioned - overlays on transparent text lines, doesn't add to flow */
1434
1859
  .cm-output-widget {
1435
1860
  position: absolute;
1436
- left: var(--widget-inset-left, 0);
1861
+ left: var(--widget-inset-left, 24px);
1437
1862
  right: 0;
1438
1863
  top: var(--widget-offset-top, 0); /* Can be negative to pull widget up closer to code block */
1439
1864
  z-index: 1;
@@ -1471,22 +1896,37 @@ export const outputWidgetStyles = `
1471
1896
  This approach works with CM6 viewport virtualization.
1472
1897
  ========================================================================== */
1473
1898
 
1474
- /* Fence lines (opening and closing) - hidden in viewing mode */
1899
+ /* Fence lines (opening and closing) - visually hidden in viewing mode.
1900
+ * Uses font-size:1px (not 0) so CodeMirror's posAtCoordsInline can
1901
+ * still find a child with a non-zero bounding rect. With font-size:0,
1902
+ * all text rects are zero-sized, point widgets are skipped, and CM
1903
+ * throws "Invalid child in posBefore". */
1475
1904
  .cm-output-fence-line {
1476
- font-size: 0 !important;
1905
+ font-size: 1px !important;
1477
1906
  line-height: 0 !important;
1478
1907
  height: 0 !important;
1479
1908
  overflow: hidden !important;
1480
1909
  padding: 0 !important;
1481
1910
  margin: 0 !important;
1911
+ color: transparent !important;
1482
1912
  }
1483
1913
 
1484
1914
  /* Rich output widgets (HTML/CSS/Mermaid->HTML) are mounted on the opening
1485
- * fence line. Keep that line unclipped so the inline widget can paint. */
1915
+ * fence line. Keep that line unclipped so the inline widget can paint.
1916
+ *
1917
+ * The text span children on this line must have non-zero bounding rects
1918
+ * so CodeMirror's posAtCoordsInline can find a measurable child.
1919
+ * Without this, clicking/hovering on the widget area crashes with
1920
+ * "Invalid child in posBefore" because CM skips point widgets during
1921
+ * coordinate mapping and finds no other child with height > 0. */
1922
+ /* Rich output widgets are mounted on the opening fence line.
1923
+ * The fence text stays at font-size:1px (inherited from .cm-output-fence-line)
1924
+ * so CodeMirror can measure it for position mapping.
1925
+ * The widget paints via overflow:visible beyond the line's layout height. */
1486
1926
  .cm-output-fence-rich-start {
1487
1927
  height: auto !important;
1488
1928
  overflow: visible !important;
1489
- line-height: 1 !important;
1929
+ line-height: 0 !important;
1490
1930
  }
1491
1931
 
1492
1932
  /* Hide CodeMirror's special character rendering (escape symbols) in output blocks */
@@ -1544,7 +1984,7 @@ export const outputWidgetStyles = `
1544
1984
  */
1545
1985
  .cm-output-content-line {
1546
1986
  background: color-mix(in srgb, var(--widget-surface, rgba(0, 0, 0, 0.35)) 85%, transparent);
1547
- margin-left: var(--widget-inset-left, 0);
1987
+ margin-left: var(--widget-inset-left, 24px);
1548
1988
  padding-left: var(--widget-padding-x, 16px);
1549
1989
  padding-right: var(--widget-padding-x, 16px);
1550
1990
  font-family: var(--widget-font-mono, 'Roboto Mono', 'SF Mono', Monaco, Consolas, monospace);
@@ -1599,7 +2039,7 @@ export const outputWidgetStyles = `
1599
2039
  font-size: 0.65em;
1600
2040
  color: var(--widget-text-muted, rgba(255, 255, 255, 0.3));
1601
2041
  padding-left: var(--widget-padding-x, 16px);
1602
- margin-left: var(--widget-inset-left, 0);
2042
+ margin-left: var(--widget-inset-left, 24px);
1603
2043
  opacity: 0.7;
1604
2044
  }
1605
2045
 
@@ -1805,8 +2245,11 @@ export const outputWidgetStyles = `
1805
2245
  ========================================================================== */
1806
2246
 
1807
2247
  /* Hide content lines for rich output (HTML/CSS) */
2248
+ /* Hidden content lines for rich output (HTML/CSS/JSON).
2249
+ * Use clip-path instead of height:0 so CodeMirror can still
2250
+ * resolve positions (prevents "Invalid child in posBefore"). */
1808
2251
  .cm-rich-output-hidden {
1809
- font-size: 0 !important;
2252
+ font-size: 1px !important;
1810
2253
  line-height: 0 !important;
1811
2254
  height: 0 !important;
1812
2255
  overflow: hidden !important;
@@ -1825,6 +2268,8 @@ export const outputWidgetStyles = `
1825
2268
  border-radius: var(--widget-border-radius, 6px);
1826
2269
  overflow: hidden;
1827
2270
  border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.1));
2271
+ line-height: normal; /* Override parent's collapsed line-height */
2272
+ font-size: var(--mrmd-ui-font-size, 13px);
1828
2273
  }
1829
2274
 
1830
2275
  .cm-html-output-widget::before {
@@ -1862,6 +2307,8 @@ export const outputWidgetStyles = `
1862
2307
  border-radius: var(--widget-border-radius, 6px);
1863
2308
  border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.08));
1864
2309
  border-left: 2px solid var(--widget-accent-css, #64b5f6);
2310
+ line-height: normal; /* Override parent's collapsed line-height */
2311
+ font-size: var(--mrmd-ui-font-size, 13px);
1865
2312
  }
1866
2313
 
1867
2314
  .cm-css-header {
@@ -1978,6 +2425,90 @@ export const outputWidgetStyles = `
1978
2425
  font-family: var(--widget-font-mono, monospace);
1979
2426
  }
1980
2427
 
2428
+ /* Scrollable plain output widget (for long outputs) */
2429
+ .cm-scroll-output-widget {
2430
+ position: relative;
2431
+ margin: 8px 0;
2432
+ background: var(--widget-surface, rgba(0, 0, 0, 0.35));
2433
+ border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.1));
2434
+ border-left: 3px solid var(--widget-border-accent, rgba(100, 149, 237, 0.6));
2435
+ border-radius: var(--widget-border-radius, 6px);
2436
+ overflow: hidden;
2437
+ line-height: normal; /* Override parent's collapsed line-height */
2438
+ font-size: var(--mrmd-ui-font-size, 13px);
2439
+ }
2440
+
2441
+ .cm-scroll-output-header {
2442
+ display: flex;
2443
+ align-items: center;
2444
+ gap: 8px;
2445
+ padding: 8px 10px;
2446
+ border-bottom: 1px solid var(--widget-border, rgba(255, 255, 255, 0.08));
2447
+ background: var(--widget-surface-elevated, rgba(255, 255, 255, 0.02));
2448
+ }
2449
+
2450
+ .cm-scroll-output-badge {
2451
+ font-size: 10px;
2452
+ color: var(--widget-text-accent, #8cc0ff);
2453
+ background: color-mix(in srgb, var(--widget-text-accent, #8cc0ff) 16%, transparent);
2454
+ border: 1px solid color-mix(in srgb, var(--widget-text-accent, #8cc0ff) 35%, transparent);
2455
+ border-radius: 3px;
2456
+ padding: 2px 6px;
2457
+ letter-spacing: 0.4px;
2458
+ text-transform: uppercase;
2459
+ font-family: var(--widget-font-mono, monospace);
2460
+ }
2461
+
2462
+ .cm-scroll-output-lines {
2463
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.65));
2464
+ font-size: 11px;
2465
+ }
2466
+
2467
+ .cm-scroll-output-actions {
2468
+ margin-left: auto;
2469
+ display: flex;
2470
+ gap: 6px;
2471
+ }
2472
+
2473
+ .cm-scroll-output-action {
2474
+ background: var(--widget-surface-inset, rgba(255, 255, 255, 0.04));
2475
+ border: 1px solid var(--widget-border, rgba(255, 255, 255, 0.14));
2476
+ color: var(--widget-text-muted, rgba(255, 255, 255, 0.75));
2477
+ border-radius: 4px;
2478
+ padding: 2px 7px;
2479
+ font-size: 11px;
2480
+ cursor: pointer;
2481
+ font-family: var(--widget-font-mono, monospace);
2482
+ }
2483
+
2484
+ .cm-scroll-output-action:hover {
2485
+ background: var(--widget-surface-hover, rgba(255, 255, 255, 0.08));
2486
+ color: var(--widget-text, #e0e0e0);
2487
+ }
2488
+
2489
+ .cm-scroll-output-body {
2490
+ max-height: 320px;
2491
+ overflow: auto;
2492
+ padding: 8px 10px 10px 10px;
2493
+ user-select: text;
2494
+ cursor: text;
2495
+ }
2496
+
2497
+ .cm-scroll-output-body.cm-scroll-output-body-expanded {
2498
+ max-height: none;
2499
+ overflow: visible;
2500
+ }
2501
+
2502
+ .cm-scroll-output-content {
2503
+ margin: 0;
2504
+ white-space: var(--widget-white-space, pre-wrap);
2505
+ word-break: var(--widget-word-break, break-word);
2506
+ color: var(--widget-text, #e0e0e0);
2507
+ font-size: 12px;
2508
+ line-height: 1.45;
2509
+ user-select: text;
2510
+ }
2511
+
1981
2512
  /* JSON Output Widget - expandable tree view */
1982
2513
  .cm-json-output-widget {
1983
2514
  position: relative;
@@ -1987,6 +2518,8 @@ export const outputWidgetStyles = `
1987
2518
  border-left: 3px solid var(--widget-accent-json, #8cc0ff);
1988
2519
  border-radius: var(--widget-border-radius, 6px);
1989
2520
  overflow: hidden;
2521
+ line-height: normal; /* Override parent's collapsed line-height */
2522
+ font-size: var(--mrmd-ui-font-size, 13px);
1990
2523
  }
1991
2524
 
1992
2525
  .cm-json-header {
@@ -2015,6 +2548,13 @@ export const outputWidgetStyles = `
2015
2548
  font-size: 11px;
2016
2549
  }
2017
2550
 
2551
+ .cm-json-origin {
2552
+ color: var(--widget-text, #e0e0e0);
2553
+ font-size: 11px;
2554
+ font-family: var(--widget-font-mono, monospace);
2555
+ opacity: 0.9;
2556
+ }
2557
+
2018
2558
  .cm-json-actions {
2019
2559
  margin-left: auto;
2020
2560
  display: flex;