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
@@ -82,9 +82,225 @@ export const commentBubbleState = StateField.define({
82
82
  });
83
83
 
84
84
  // ===========================================================================
85
- // Comment Extraction
85
+ // Comment Extraction / Thread Grouping
86
86
  // ===========================================================================
87
87
 
88
+ /**
89
+ * Parse a comment body into a simple multi-turn thread.
90
+ *
91
+ * Thread format:
92
+ *
93
+ * @comment
94
+ * First message
95
+ *
96
+ * @reply
97
+ * Follow-up message
98
+ *
99
+ * If no @role headers are present, the whole body is treated as a single
100
+ * `comment` message for backward compatibility.
101
+ *
102
+ * @param {string} content
103
+ * @returns {Array<{role: string, text: string}>}
104
+ */
105
+ export function parseCommentThread(content) {
106
+ const text = String(content || '').trim();
107
+ if (!text) return [];
108
+
109
+ const lines = text.split('\n');
110
+ const messages = [];
111
+ let current = null;
112
+
113
+ const pushCurrent = () => {
114
+ if (!current) return;
115
+ const body = current.lines.join('\n').trim();
116
+ if (!body) return;
117
+ messages.push({
118
+ role: sanitizeCommentRole(current.role),
119
+ text: body,
120
+ });
121
+ };
122
+
123
+ for (const line of lines) {
124
+ const header = line.trim().match(/^@([a-zA-Z][\w-]*)\s*$/);
125
+ if (header) {
126
+ pushCurrent();
127
+ current = { role: header[1].toLowerCase(), lines: [] };
128
+ continue;
129
+ }
130
+
131
+ if (!current) {
132
+ current = { role: 'comment', lines: [] };
133
+ }
134
+ current.lines.push(line);
135
+ }
136
+
137
+ pushCurrent();
138
+
139
+ if (messages.length === 0 && text) {
140
+ return [{ role: 'comment', text }];
141
+ }
142
+
143
+ return messages;
144
+ }
145
+
146
+ function sanitizeCommentRole(role) {
147
+ const normalized = String(role || 'comment').toLowerCase().trim();
148
+ return normalized.match(/^[a-z][\w-]*$/) ? normalized : 'comment';
149
+ }
150
+
151
+ function normalizeCommentContent(content) {
152
+ return String(content || '').replace(/[\r\n]+/g, ' ').replace(/[ \t]+/g, ' ').trim();
153
+ }
154
+
155
+ /**
156
+ * Serialize a comment thread back to comment body text.
157
+ *
158
+ * Keeps legacy single-message comments as plain text. Multi-turn threads use
159
+ * the @role stanza format described above.
160
+ *
161
+ * @param {Array<{role: string, text: string}>} messages
162
+ * @returns {string}
163
+ */
164
+ export function serializeCommentThread(messages) {
165
+ const cleaned = (messages || [])
166
+ .map((message) => ({
167
+ role: sanitizeCommentRole(message?.role),
168
+ text: String(message?.text || '').trim(),
169
+ }))
170
+ .filter((message) => message.text.length > 0);
171
+
172
+ if (cleaned.length === 0) return '';
173
+ if (cleaned.length === 1 && cleaned[0].role === 'comment') {
174
+ return cleaned[0].text;
175
+ }
176
+
177
+ return cleaned
178
+ .map((message) => `@${message.role}\n${message.text}`)
179
+ .join('\n\n');
180
+ }
181
+
182
+ /**
183
+ * Build raw <!--! !--> syntax for a comment body.
184
+ *
185
+ * @param {string} content
186
+ * @returns {string}
187
+ */
188
+ export function buildCommentRaw(content) {
189
+ const text = normalizeCommentContent(content);
190
+ return `<!--! ${text} !-->`;
191
+ }
192
+
193
+ /**
194
+ * Append a reply to an existing comment body.
195
+ *
196
+ * @param {string} content
197
+ * @param {string} replyText
198
+ * @param {string} [role='reply']
199
+ * @returns {string}
200
+ */
201
+ export function appendCommentReply(content, replyText, role = 'reply') {
202
+ const reply = String(replyText || '').trim();
203
+ if (!reply) return String(content || '').trim();
204
+
205
+ const thread = parseCommentThread(content);
206
+ thread.push({ role: sanitizeCommentRole(role), text: reply });
207
+ return serializeCommentThread(thread);
208
+ }
209
+
210
+ /**
211
+ * Get a human-friendly one-line preview for a comment/thread.
212
+ *
213
+ * @param {string} content
214
+ * @returns {string}
215
+ */
216
+ export function getCommentPreview(content) {
217
+ const messages = parseCommentThread(content);
218
+ const text = messages[0]?.text || String(content || '').trim();
219
+ return text.replace(/\s+/g, ' ').trim();
220
+ }
221
+
222
+ /**
223
+ * Group adjacent one-line comment markers into visual threads.
224
+ *
225
+ * A thread is a run of comment markers separated only by spaces/tabs on the
226
+ * same physical line. This keeps the document representation single-line and
227
+ * lets the UI treat immediately-adjacent markers as replies.
228
+ *
229
+ * @param {string} text
230
+ * @param {Array<{start:number,end:number,content:string,raw:string,thread?:Array<{role:string,text:string}>}>} [comments]
231
+ * @returns {Array<{start:number,end:number,raw:string,preview:string,count:number,comments:Array, messages:Array<{role:string,text:string}>}>}
232
+ */
233
+ export function groupAdjacentComments(text, comments = null) {
234
+ const sourceText = String(text || '');
235
+ const items = Array.isArray(comments) ? comments : extractComments(sourceText);
236
+ if (items.length === 0) return [];
237
+
238
+ const threads = [];
239
+ let current = null;
240
+
241
+ const startThread = (comment) => {
242
+ current = {
243
+ start: comment.start,
244
+ end: comment.end,
245
+ raw: '',
246
+ preview: '',
247
+ count: 0,
248
+ comments: [comment],
249
+ messages: [],
250
+ };
251
+ appendCommentMessages(comment);
252
+ };
253
+
254
+ const appendCommentMessages = (comment) => {
255
+ const parsed = Array.isArray(comment.thread) && comment.thread.length > 0
256
+ ? comment.thread
257
+ : [{ role: 'comment', text: comment.content }];
258
+ const preserveRoles = parsed.length > 1;
259
+
260
+ for (const message of parsed) {
261
+ const text = normalizeCommentContent(message?.text || '');
262
+ if (!text) continue;
263
+ current.messages.push({
264
+ role: preserveRoles
265
+ ? sanitizeCommentRole(message?.role)
266
+ : (current.messages.length === 0 ? 'comment' : 'reply'),
267
+ text,
268
+ });
269
+ }
270
+ };
271
+
272
+ const finishThread = () => {
273
+ if (!current) return;
274
+ current.raw = sourceText.slice(current.start, current.end);
275
+ current.count = current.messages.length;
276
+ current.preview = current.messages[0]?.text || current.comments[0]?.preview || '';
277
+ threads.push(current);
278
+ current = null;
279
+ };
280
+
281
+ for (const comment of items) {
282
+ if (!current) {
283
+ startThread(comment);
284
+ continue;
285
+ }
286
+
287
+ const separator = sourceText.slice(current.end, comment.start);
288
+ const isAdjacent = /^[ \t]*$/.test(separator);
289
+
290
+ if (isAdjacent) {
291
+ current.end = comment.end;
292
+ current.comments.push(comment);
293
+ appendCommentMessages(comment);
294
+ } else {
295
+ finishThread();
296
+ startThread(comment);
297
+ }
298
+ }
299
+
300
+ finishThread();
301
+ return threads;
302
+ }
303
+
88
304
  /**
89
305
  * Extract all comments from a document
90
306
  *
@@ -97,11 +313,14 @@ export function extractComments(text) {
97
313
  const regex = new RegExp(COMMENT_REGEX.source, 'g');
98
314
 
99
315
  while ((match = regex.exec(text)) !== null) {
316
+ const content = match[1].trim();
100
317
  comments.push({
101
318
  start: match.index,
102
319
  end: match.index + match[0].length,
103
- content: match[1].trim(),
320
+ content,
104
321
  raw: match[0],
322
+ preview: getCommentPreview(content),
323
+ thread: parseCommentThread(content),
105
324
  });
106
325
  }
107
326
 
@@ -146,48 +365,80 @@ class CommentMarkerWidget extends WidgetType {
146
365
  * @param {number} from - Start position
147
366
  * @param {number} to - End position
148
367
  * @param {string} raw - Raw comment text
368
+ * @param {{count?: number, preview?: string, threadStart?: number, threadEnd?: number, threadRaw?: string}} [options]
149
369
  */
150
- constructor(content, from, to, raw) {
370
+ constructor(content, from, to, raw, options = {}) {
151
371
  super();
152
372
  this.content = content;
153
373
  this.from = from;
154
374
  this.to = to;
155
375
  this.raw = raw;
376
+ this.count = Math.max(1, options.count || 1);
377
+ this.preview = options.preview || getCommentPreview(content);
378
+ this.threadStart = Number.isFinite(options.threadStart) ? options.threadStart : from;
379
+ this.threadEnd = Number.isFinite(options.threadEnd) ? options.threadEnd : to;
380
+ this.threadRaw = options.threadRaw || raw;
156
381
  }
157
382
 
158
383
  eq(other) {
159
384
  return (
160
385
  other instanceof CommentMarkerWidget &&
161
386
  other.content === this.content &&
162
- other.from === this.from
387
+ other.from === this.from &&
388
+ other.to === this.to &&
389
+ other.count === this.count &&
390
+ other.preview === this.preview &&
391
+ other.threadStart === this.threadStart &&
392
+ other.threadEnd === this.threadEnd
163
393
  );
164
394
  }
165
395
 
166
396
  toDOM(view) {
167
397
  const marker = document.createElement('span');
398
+ const previewText = this.preview;
399
+ const label = this.count > 1
400
+ ? `Comment thread with ${this.count} messages: ${previewText.slice(0, 50)}`
401
+ : `Comment: ${previewText.slice(0, 50)}`;
402
+
168
403
  marker.className = 'cm-comment-marker';
169
- marker.setAttribute('aria-label', `Comment: ${this.content.slice(0, 50)}`);
404
+ marker.setAttribute('aria-label', label);
405
+ marker.setAttribute('title', label);
170
406
  marker.setAttribute('role', 'button');
171
407
  marker.setAttribute('tabindex', '0');
172
408
 
173
- // Icon
174
409
  const icon = document.createElement('span');
175
410
  icon.className = 'cm-comment-marker-icon';
176
- icon.textContent = '💭';
411
+ icon.textContent = '💬';
177
412
  marker.appendChild(icon);
178
413
 
179
- // Preview (truncated)
180
- if (this.content.length > 0) {
181
- const preview = document.createElement('span');
182
- preview.className = 'cm-comment-marker-preview';
183
- preview.textContent = this.content.slice(0, 20) + (this.content.length > 20 ? '…' : '');
184
- marker.appendChild(preview);
414
+ if (this.count > 1) {
415
+ const count = document.createElement('span');
416
+ count.className = 'cm-comment-marker-count';
417
+ count.textContent = String(this.count);
418
+ marker.appendChild(count);
185
419
  }
186
420
 
187
- // Click handler
188
421
  marker.addEventListener('click', (e) => {
189
422
  e.preventDefault();
190
423
  e.stopPropagation();
424
+
425
+ let handledBySidebar = false;
426
+ if (typeof window !== 'undefined') {
427
+ const event = new CustomEvent('mrmd-comment-thread-open', {
428
+ cancelable: true,
429
+ detail: {
430
+ from: this.threadStart,
431
+ to: this.threadEnd,
432
+ raw: this.threadRaw,
433
+ preview: this.preview,
434
+ count: this.count,
435
+ },
436
+ });
437
+ handledBySidebar = window.dispatchEvent(event) === false || event.defaultPrevented;
438
+ }
439
+
440
+ if (handledBySidebar) return;
441
+
191
442
  view.dispatch({
192
443
  effects: showCommentBubble.of({
193
444
  from: this.from,
@@ -198,7 +449,6 @@ class CommentMarkerWidget extends WidgetType {
198
449
  });
199
450
  });
200
451
 
201
- // Keyboard handler
202
452
  marker.addEventListener('keydown', (e) => {
203
453
  if (e.key === 'Enter' || e.key === ' ') {
204
454
  e.preventDefault();
@@ -214,6 +464,32 @@ class CommentMarkerWidget extends WidgetType {
214
464
  }
215
465
  }
216
466
 
467
+ /**
468
+ * Zero-width anchor used in sidebar mode so comment syntax disappears from the
469
+ * document flow while the discussion lives in the side panel.
470
+ */
471
+ class CommentAnchorWidget extends WidgetType {
472
+ constructor(from) {
473
+ super();
474
+ this.from = from;
475
+ }
476
+
477
+ eq(other) {
478
+ return other instanceof CommentAnchorWidget && other.from === this.from;
479
+ }
480
+
481
+ toDOM() {
482
+ const anchor = document.createElement('span');
483
+ anchor.className = 'cm-comment-anchor';
484
+ anchor.setAttribute('aria-hidden', 'true');
485
+ return anchor;
486
+ }
487
+
488
+ ignoreEvent() {
489
+ return true;
490
+ }
491
+ }
492
+
217
493
  // ===========================================================================
218
494
  // Decoration Builder
219
495
  // ===========================================================================
@@ -234,33 +510,48 @@ function buildDecorations(view) {
234
510
  // Get the line the cursor is on
235
511
  const cursorLine = doc.lineAt(cursorPos);
236
512
 
237
- // Extract all comments
513
+ // Extract and group comments into same-line threads
238
514
  const comments = extractComments(text);
515
+ const threads = groupAdjacentComments(text, comments);
239
516
 
240
- for (const comment of comments) {
241
- const commentLine = doc.lineAt(comment.start);
517
+ for (const thread of threads) {
518
+ const startLine = doc.lineAt(thread.start);
519
+ const endLine = doc.lineAt(Math.max(thread.start, thread.end - 1));
520
+ const isMultiline = thread.raw.includes('\n');
242
521
 
243
- // Check if cursor is on the same line as the comment
244
- const isActiveLine = cursorLine.number === commentLine.number;
522
+ // Show raw syntax whenever the cursor is on a line touched by the thread.
523
+ const isActiveLine = cursorLine.number >= startLine.number && cursorLine.number <= endLine.number;
524
+ const primaryComment = thread.comments[0];
245
525
 
246
526
  if (isActiveLine) {
247
- // Show raw syntax with styling
248
527
  decorations.push(
249
528
  Decoration.mark({
250
529
  class: 'cm-comment-syntax-active',
251
- }).range(comment.start, comment.end)
530
+ }).range(thread.start, thread.end)
531
+ );
532
+ } else if (isMultiline) {
533
+ decorations.push(
534
+ Decoration.mark({
535
+ class: 'cm-comment-syntax-thread',
536
+ }).range(thread.start, thread.end)
252
537
  );
253
538
  } else {
254
- // Replace with marker widget
255
539
  decorations.push(
256
540
  Decoration.replace({
257
541
  widget: new CommentMarkerWidget(
258
- comment.content,
259
- comment.start,
260
- comment.end,
261
- comment.raw
542
+ primaryComment.content,
543
+ primaryComment.start,
544
+ primaryComment.end,
545
+ primaryComment.raw,
546
+ {
547
+ count: thread.count,
548
+ preview: thread.preview,
549
+ threadStart: thread.start,
550
+ threadEnd: thread.end,
551
+ threadRaw: thread.raw,
552
+ }
262
553
  ),
263
- }).range(comment.start, comment.end)
554
+ }).range(thread.start, thread.end)
264
555
  );
265
556
  }
266
557
  }
@@ -414,7 +705,7 @@ function showBubble(view, comment) {
414
705
  const newContent = textarea.value.trim();
415
706
  if (newContent !== comment.content) {
416
707
  // Update the comment in the document
417
- const newRaw = `<!--! ${newContent} !-->`;
708
+ const newRaw = buildCommentRaw(newContent);
418
709
  view.dispatch({
419
710
  changes: { from: comment.from, to: comment.to, insert: newRaw },
420
711
  effects: hideCommentBubble.of(null),
@@ -457,7 +748,7 @@ function showBubble(view, comment) {
457
748
  // Save changes before closing
458
749
  const newContent = textarea.value.trim();
459
750
  if (newContent !== comment.content) {
460
- const newRaw = `<!--! ${newContent} !-->`;
751
+ const newRaw = buildCommentRaw(newContent);
461
752
  view.dispatch({
462
753
  changes: { from: comment.from, to: comment.to, insert: newRaw },
463
754
  effects: hideCommentBubble.of(null),
@@ -670,16 +961,17 @@ const commentStyles = EditorView.baseTheme({
670
961
  alignItems: 'center',
671
962
  gap: '4px',
672
963
  padding: '1px 6px',
673
- background: 'var(--bg-comment, rgba(255, 203, 107, 0.15))',
674
- border: '1px solid var(--border-comment, rgba(255, 203, 107, 0.3))',
675
- borderRadius: '4px',
964
+ background: 'var(--bg-comment, rgba(255, 203, 107, 0.12))',
965
+ border: '1px solid var(--border-comment, rgba(255, 203, 107, 0.24))',
966
+ borderRadius: '999px',
676
967
  cursor: 'pointer',
677
- fontSize: '12px',
968
+ fontSize: '11px',
969
+ lineHeight: '1.2',
678
970
  verticalAlign: 'baseline',
679
971
  transition: 'background 0.15s, border-color 0.15s',
680
972
  '&:hover': {
681
- background: 'var(--bg-comment-hover, rgba(255, 203, 107, 0.25))',
682
- borderColor: 'var(--border-comment-hover, rgba(255, 203, 107, 0.5))',
973
+ background: 'var(--bg-comment-hover, rgba(255, 203, 107, 0.2))',
974
+ borderColor: 'var(--border-comment-hover, rgba(255, 203, 107, 0.4))',
683
975
  },
684
976
  },
685
977
 
@@ -687,6 +979,20 @@ const commentStyles = EditorView.baseTheme({
687
979
  fontSize: '11px',
688
980
  },
689
981
 
982
+ '.cm-comment-marker-count': {
983
+ minWidth: '14px',
984
+ height: '14px',
985
+ padding: '0 4px',
986
+ borderRadius: '999px',
987
+ background: 'color-mix(in srgb, var(--accent, #58a6ff) 14%, transparent)',
988
+ color: 'var(--text, inherit)',
989
+ display: 'inline-flex',
990
+ alignItems: 'center',
991
+ justifyContent: 'center',
992
+ fontSize: '10px',
993
+ fontWeight: '600',
994
+ },
995
+
690
996
  '.cm-comment-marker-preview': {
691
997
  color: 'var(--text-comment, #ffcb6b)',
692
998
  maxWidth: '150px',
@@ -700,6 +1006,25 @@ const commentStyles = EditorView.baseTheme({
700
1006
  borderRadius: '2px',
701
1007
  },
702
1008
 
1009
+ '.cm-comment-syntax-thread': {
1010
+ background: 'var(--bg-comment-thread, rgba(255, 203, 107, 0.08))',
1011
+ borderRadius: '4px',
1012
+ boxDecorationBreak: 'clone',
1013
+ WebkitBoxDecorationBreak: 'clone',
1014
+ },
1015
+
1016
+ '.cm-comment-anchor': {
1017
+ display: 'inline-block',
1018
+ width: '0',
1019
+ height: '0',
1020
+ overflow: 'hidden',
1021
+ verticalAlign: 'baseline',
1022
+ },
1023
+
1024
+ '.cm-comment-sidebar-hidden': {
1025
+ display: 'none',
1026
+ },
1027
+
703
1028
  '.cm-comment-bubble': {
704
1029
  background: 'var(--bg-secondary, #1e1e1e)',
705
1030
  border: '1px solid var(--border, #3c3c3c)',
@@ -819,13 +1144,13 @@ function insertComment(view) {
819
1144
  let cursorPos;
820
1145
 
821
1146
  if (hasSelection) {
822
- // Wrap selection in comment
1147
+ // Wrap selection in a single-line comment token
823
1148
  const selectedText = state.sliceDoc(from, to);
824
- insert = `<!--! ${selectedText} !-->`;
1149
+ insert = buildCommentRaw(selectedText);
825
1150
  cursorPos = from + 6; // After "<!--! "
826
1151
  } else {
827
1152
  // Insert empty comment and place cursor inside
828
- insert = `<!--! !-->`;
1153
+ insert = buildCommentRaw('');
829
1154
  cursorPos = from + 6; // After "<!--! "
830
1155
  }
831
1156
 
@@ -112,8 +112,7 @@ function resolveThemeName(theme, isDark) {
112
112
  if (theme && getTheme(theme)) {
113
113
  return theme;
114
114
  }
115
- // Auto-select based on dark mode
116
- return isDark ? 'midnight' : 'daylight';
115
+ return 'plain-light';
117
116
  }
118
117
 
119
118
  /**
@@ -20,6 +20,7 @@
20
20
  * @property {ExecutionConfig} [execution] - Execution settings
21
21
  * @property {CellControlsConfig} [cellControls] - Cell controls (buttons, status, queue)
22
22
  * @property {AIConfig} [ai] - AI service endpoints
23
+ * @property {SectionControlsConfig} [sectionControls] - Section controls (AI/formatting)
23
24
  * @property {AwarenessConfig} [awareness] - Collaboration UI settings
24
25
  * @property {boolean | DevPanelConfig} [devPanel] - Developer panel
25
26
  */
@@ -50,18 +51,22 @@
50
51
  * Visual appearance configuration
51
52
  * @typedef {Object} AppearanceConfig
52
53
  * @property {boolean | null} [dark] - Dark mode: true=dark, false=light, null=system
53
- * @property {string | null} [theme] - Theme name: 'midnight', 'daylight', 'github', or custom.
54
- * If null, auto-selects based on dark mode (midnight for dark, daylight for light).
54
+ * @property {string | null} [theme] - Theme name: 'midnight', 'daylight', 'plain-light', or custom.
55
+ * If null, defaults to 'plain-light'.
55
56
  * @property {boolean} [readonly] - View-only mode
56
57
  * @property {string} [placeholder] - Placeholder text when empty
58
+ * @property {boolean} [spellcheck] - Enable browser-native spellcheck on prose
59
+ * (automatically disabled inside fenced code blocks).
60
+ * Works in browsers and Electron (Chromium's OS-level spellchecker).
57
61
  */
58
62
 
59
63
  /** @type {AppearanceConfig} */
60
64
  export const DEFAULT_APPEARANCE = {
61
65
  dark: null,
62
- theme: null, // Auto-select based on dark mode
66
+ theme: null, // Defaults to plain-light
63
67
  readonly: false,
64
- placeholder: 'Start typing...'
68
+ placeholder: 'Start typing...',
69
+ spellcheck: true,
65
70
  };
66
71
 
67
72
  // =============================================================================
@@ -270,6 +275,25 @@ export const DEFAULT_CELL_CONTROLS = {
270
275
  queue: { ...DEFAULT_CELL_QUEUE }
271
276
  };
272
277
 
278
+ /**
279
+ * Section controls configuration - AI and formatting buttons next to sections
280
+ * @typedef {Object} SectionControlsConfig
281
+ * @property {boolean} [enabled] - Master switch for section controls
282
+ * @property {boolean} [showAi] - Show AI command buttons
283
+ * @property {boolean} [showFormatting] - Show markdown formatting buttons
284
+ * @property {'full' | 'dots-hover' | 'dots-click'} [mode] - Display mode:
285
+ * 'full' = always show toolbar, 'dots-hover' = dots that expand on hover,
286
+ * 'dots-click' = dots that open command palette on click
287
+ */
288
+
289
+ /** @type {SectionControlsConfig} */
290
+ export const DEFAULT_SECTION_CONTROLS = {
291
+ enabled: true,
292
+ showAi: true,
293
+ showFormatting: true,
294
+ mode: 'dots-click'
295
+ };
296
+
273
297
  // =============================================================================
274
298
  // DEV PANEL
275
299
  // =============================================================================
@@ -321,6 +345,7 @@ export function getDefaultConfig() {
321
345
  endpoints: [],
322
346
  default: null
323
347
  },
348
+ sectionControls: { ...DEFAULT_SECTION_CONTROLS },
324
349
  awareness: { ...DEFAULT_AWARENESS },
325
350
  devPanel: { ...DEFAULT_DEV_PANEL }
326
351
  };
@@ -358,6 +383,9 @@ export function normalizeOptions(options = {}) {
358
383
  if (options.placeholder !== undefined) {
359
384
  config.appearance.placeholder = options.placeholder;
360
385
  }
386
+ if (options.spellcheck !== undefined) {
387
+ config.appearance.spellcheck = options.spellcheck;
388
+ }
361
389
 
362
390
  // User
363
391
  if (options.userName !== undefined) {
@@ -421,6 +449,20 @@ export function normalizeOptions(options = {}) {
421
449
  config.execution.autoRefreshVariables = options.autoRefreshVariables;
422
450
  }
423
451
 
452
+ // Section controls
453
+ if (options.sectionControls !== undefined) {
454
+ if (options.sectionControls === true) {
455
+ config.sectionControls.enabled = true;
456
+ } else if (options.sectionControls === false) {
457
+ config.sectionControls.enabled = false;
458
+ } else if (typeof options.sectionControls === 'object') {
459
+ config.sectionControls = {
460
+ ...config.sectionControls,
461
+ ...options.sectionControls
462
+ };
463
+ }
464
+ }
465
+
424
466
  // Cell controls
425
467
  if (options.cellControls !== undefined) {
426
468
  if (options.cellControls === true) {