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
@@ -0,0 +1,617 @@
1
+ /**
2
+ * Section Controls Commands
3
+ *
4
+ * Formatting commands + AI shortcuts for the focused section.
5
+ */
6
+
7
+ import { syntaxTree } from '@codemirror/language';
8
+ import { findCodeBlockAtPosition } from '../cells.js';
9
+ import { toggleInlineMark, toggleInlineMarkFromSyntax } from '../markdown/inline-commands.js';
10
+ import { executeAiOperation, getAiContext } from '../ai-integration.js';
11
+ import { ctrlKConfigFacet } from '../ctrl-k-modal.js';
12
+ import { applyFrontmatterTemplate } from '../frontmatter-updater.js';
13
+ import {
14
+ insertComment,
15
+ addressAllComments,
16
+ addressNearbyComment,
17
+ } from '../comment-syntax.js';
18
+
19
+ // ===========================================================================
20
+ // Formatting Commands
21
+ // ===========================================================================
22
+
23
+ /**
24
+ * Toggle markdown formatting around selection.
25
+ *
26
+ * @param {import('@codemirror/view').EditorView} view
27
+ * @param {string} marker
28
+ * @param {string} [endMarker]
29
+ * @returns {boolean}
30
+ */
31
+ export function toggleMarkdownFormat(view, marker, endMarker = marker) {
32
+ if (toggleInlineMarkFromSyntax(view, marker, endMarker)) {
33
+ return true;
34
+ }
35
+
36
+ const sel = view.state.selection.main;
37
+ const { from, to } = sel;
38
+ const selected = view.state.doc.sliceString(from, to);
39
+
40
+ if (sel.empty) {
41
+ view.dispatch({
42
+ changes: { from, insert: marker + endMarker },
43
+ selection: { anchor: from + marker.length },
44
+ userEvent: 'input.format.add',
45
+ });
46
+ return true;
47
+ }
48
+
49
+ const hasWrapper = selected.startsWith(marker) && selected.endsWith(endMarker);
50
+
51
+ if (hasWrapper) {
52
+ const unwrapped = selected.slice(marker.length, selected.length - endMarker.length);
53
+ view.dispatch({
54
+ changes: { from, to, insert: unwrapped },
55
+ selection: { anchor: from, head: from + unwrapped.length },
56
+ userEvent: 'input.format.remove',
57
+ });
58
+ return true;
59
+ }
60
+
61
+ view.dispatch({
62
+ changes: { from, to, insert: marker + selected + endMarker },
63
+ selection: {
64
+ anchor: from + marker.length,
65
+ head: from + marker.length + selected.length,
66
+ },
67
+ userEvent: 'input.format.add',
68
+ });
69
+ return true;
70
+ }
71
+
72
+ export const toggleBold = (view) => toggleInlineMark(view, 'bold');
73
+ export const toggleItalic = (view) => toggleInlineMark(view, 'italic');
74
+ export const toggleUnderline = (view) => toggleInlineMark(view, 'underline');
75
+ export const toggleStrikethrough = (view) => toggleInlineMark(view, 'strike');
76
+ export const toggleInlineCode = (view) => toggleInlineMark(view, 'code');
77
+
78
+ function toTitleCase(text) {
79
+ return String(text || '').replace(/\p{L}[\p{L}\p{M}'’\-]*/gu, (word) => {
80
+ const [first = '', ...rest] = Array.from(word);
81
+ return first.toLocaleUpperCase() + rest.join('').toLocaleLowerCase();
82
+ });
83
+ }
84
+
85
+ export function transformSelectionCase(view, mode) {
86
+ const sel = view?.state?.selection?.main;
87
+ if (!sel || sel.empty) return false;
88
+
89
+ const from = Math.min(sel.from, sel.to);
90
+ const to = Math.max(sel.from, sel.to);
91
+ const selected = view.state.doc.sliceString(from, to);
92
+
93
+ let next = selected;
94
+ if (mode === 'uppercase') next = selected.toLocaleUpperCase();
95
+ else if (mode === 'lowercase') next = selected.toLocaleLowerCase();
96
+ else if (mode === 'titlecase') next = toTitleCase(selected);
97
+ else return false;
98
+
99
+ const forward = sel.anchor <= sel.head;
100
+ view.dispatch({
101
+ changes: { from, to, insert: next },
102
+ selection: forward
103
+ ? { anchor: from, head: from + next.length }
104
+ : { anchor: from + next.length, head: from },
105
+ userEvent: `input.case.${mode}`,
106
+ scrollIntoView: true,
107
+ });
108
+ return true;
109
+ }
110
+
111
+ export const transformSelectionUppercase = (view) => transformSelectionCase(view, 'uppercase');
112
+ export const transformSelectionLowercase = (view) => transformSelectionCase(view, 'lowercase');
113
+ export const transformSelectionTitlecase = (view) => transformSelectionCase(view, 'titlecase');
114
+
115
+ function getLineRangeForSelection(view) {
116
+ const sel = view.state.selection.main;
117
+ const fromLine = view.state.doc.lineAt(sel.from);
118
+ const toLine = view.state.doc.lineAt(sel.to);
119
+ return {
120
+ from: fromLine.from,
121
+ to: toLine.to,
122
+ text: view.state.doc.sliceString(fromLine.from, toLine.to),
123
+ selection: sel,
124
+ };
125
+ }
126
+
127
+ function prefixSelectedLines(view, prefix) {
128
+ const range = getLineRangeForSelection(view);
129
+ const lines = range.text.split('\n');
130
+ const prefixed = lines.map((line) => `${prefix}${line}`).join('\n');
131
+
132
+ const { selection } = range;
133
+ const anchor = selection.anchor + prefix.length;
134
+ const head = selection.head + prefix.length;
135
+
136
+ view.dispatch({
137
+ changes: { from: range.from, to: range.to, insert: prefixed },
138
+ selection: { anchor, head },
139
+ userEvent: 'input.format.add',
140
+ });
141
+ return true;
142
+ }
143
+
144
+ function insertTemplate(view, template) {
145
+ const sel = view.state.selection.main;
146
+ const marker = '{{cursor}}';
147
+ const selectionMarker = '{{selection}}';
148
+ const selectedText = view.state.doc.sliceString(sel.from, sel.to);
149
+
150
+ let text = template.includes(selectionMarker)
151
+ ? template.replace(selectionMarker, selectedText)
152
+ : template;
153
+
154
+ const markerPos = text.indexOf(marker);
155
+ if (markerPos >= 0) {
156
+ text = text.replace(marker, '');
157
+ }
158
+
159
+ const cursorPos = markerPos >= 0 ? sel.from + markerPos : sel.from + text.length;
160
+
161
+ view.dispatch({
162
+ changes: { from: sel.from, to: sel.to, insert: text },
163
+ selection: { anchor: cursorPos },
164
+ userEvent: 'input.format.add',
165
+ });
166
+
167
+ return true;
168
+ }
169
+
170
+ export function insertBlockQuote(view) {
171
+ if (!view.state.selection.main.empty) {
172
+ return prefixSelectedLines(view, '> ');
173
+ }
174
+ return insertTemplate(view, '> {{cursor}}');
175
+ }
176
+
177
+ export function insertTableTemplate(view) {
178
+ return insertTemplate(
179
+ view,
180
+ '| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| {{cursor}} | | |\n| | | |'
181
+ );
182
+ }
183
+
184
+ export function insertCodeCellTemplate(view, language = 'python') {
185
+ const line = view.state.doc.lineAt(view.state.selection.main.from);
186
+ const prefixNewline = line.text.trim().length > 0 ? '\n' : '';
187
+ return insertTemplate(view, `${prefixNewline}\`\`\`${language}\n{{cursor}}\n\`\`\``);
188
+ }
189
+
190
+ export function insertBulletList(view) {
191
+ if (!view.state.selection.main.empty) {
192
+ return prefixSelectedLines(view, '- ');
193
+ }
194
+ return insertTemplate(view, '- {{cursor}}');
195
+ }
196
+
197
+ export function insertNumberedList(view) {
198
+ if (!view.state.selection.main.empty) {
199
+ return prefixSelectedLines(view, '1. ');
200
+ }
201
+ return insertTemplate(view, '1. {{cursor}}');
202
+ }
203
+
204
+ export function insertTaskList(view) {
205
+ if (!view.state.selection.main.empty) {
206
+ return prefixSelectedLines(view, '- [ ] ');
207
+ }
208
+ return insertTemplate(view, '- [ ] {{cursor}}');
209
+ }
210
+
211
+ export function insertHeading(view, level = 2) {
212
+ const prefix = '#'.repeat(Math.max(1, Math.min(level, 6))) + ' ';
213
+ return insertTemplate(view, `${prefix}{{cursor}}`);
214
+ }
215
+
216
+ export function insertHorizontalRule(view) {
217
+ return insertTemplate(view, '---\n{{cursor}}');
218
+ }
219
+
220
+ export function insertPageBreak(view) {
221
+ const sel = view.state.selection.main;
222
+ const doc = view.state.doc;
223
+ const before = doc.sliceString(Math.max(0, sel.from - 2), sel.from);
224
+ const after = doc.sliceString(sel.to, Math.min(doc.length, sel.to + 2));
225
+
226
+ const prefix = sel.from === 0
227
+ ? ''
228
+ : before.endsWith('\n\n')
229
+ ? ''
230
+ : before.endsWith('\n')
231
+ ? '\n'
232
+ : '\n\n';
233
+
234
+ const suffix = sel.to === doc.length
235
+ ? '\n\n'
236
+ : after.startsWith('\n\n')
237
+ ? ''
238
+ : after.startsWith('\n')
239
+ ? '\n'
240
+ : '\n\n';
241
+
242
+ const trailingGap = after.startsWith('\n\n')
243
+ ? 2
244
+ : after.startsWith('\n')
245
+ ? 1
246
+ : 0;
247
+
248
+ const insert = `${prefix}\\pagebreak${suffix}`;
249
+
250
+ view.dispatch({
251
+ changes: { from: sel.from, to: sel.to, insert },
252
+ selection: { anchor: sel.from + insert.length + trailingGap },
253
+ userEvent: 'input.format.add',
254
+ });
255
+
256
+ return true;
257
+ }
258
+
259
+ export function insertFrontmatterTemplate(view) {
260
+ const result = applyFrontmatterTemplate(view.state.doc.toString());
261
+
262
+ if (!result) {
263
+ console.warn('[frontmatter] Cannot apply template to invalid frontmatter. Fix YAML first.');
264
+ return false;
265
+ }
266
+
267
+ const spec = {
268
+ changes: result.changes,
269
+ userEvent: 'input.format.add',
270
+ scrollIntoView: true,
271
+ };
272
+
273
+ if (result.selection) {
274
+ spec.selection = {
275
+ anchor: result.selection.from,
276
+ head: result.selection.to,
277
+ };
278
+ }
279
+
280
+ view.dispatch(spec);
281
+ return true;
282
+ }
283
+
284
+ export function insertCommentCommand(view) {
285
+ return insertComment(view);
286
+ }
287
+
288
+ export const FORMATTING_COMMAND_DEFINITIONS = [
289
+ { id: 'bold', label: 'Bold', shortcut: 'Mod-B', icon: 'format' },
290
+ { id: 'italic', label: 'Italic', shortcut: 'Mod-I', icon: 'format' },
291
+ { id: 'underline', label: 'Underline', shortcut: 'Mod-U', icon: 'format' },
292
+ { id: 'strikethrough', label: 'Strikethrough', shortcut: '', icon: 'format' },
293
+ { id: 'inline-code', label: 'Inline Code', shortcut: 'Mod-`', icon: 'code' },
294
+ { id: 'uppercase', label: 'Make Uppercase', shortcut: '', icon: 'type' },
295
+ { id: 'lowercase', label: 'Make Lowercase', shortcut: '', icon: 'type' },
296
+ { id: 'titlecase', label: 'Make Title Case', shortcut: '', icon: 'type' },
297
+ { id: 'comment', label: 'Insert Comment', shortcut: 'Mod-Shift-M', icon: 'comment' },
298
+ { id: 'frontmatter-template', label: 'Insert Frontmatter Template', shortcut: '', icon: 'doc' },
299
+ { id: 'blockquote', label: 'Block Quote', shortcut: '', icon: 'quote' },
300
+ { id: 'table', label: 'Insert Table Template', shortcut: '', icon: 'table' },
301
+ { id: 'code-cell', label: 'Insert Code Cell', shortcut: '', icon: 'code' },
302
+ { id: 'bullet-list', label: 'Bullet List', shortcut: '', icon: 'list' },
303
+ { id: 'numbered-list', label: 'Numbered List', shortcut: '', icon: 'list-number' },
304
+ { id: 'task-list', label: 'Task List', shortcut: '', icon: 'checklist' },
305
+ { id: 'heading-2', label: 'Heading (H2)', shortcut: '', icon: 'heading' },
306
+ { id: 'horizontal-rule', label: 'Horizontal Rule', shortcut: '', icon: 'minus' },
307
+ { id: 'page-break', label: 'Page Break', shortcut: 'Mod-Enter', icon: 'minus' },
308
+ ];
309
+
310
+ export function executeFormattingDefinition(view, def) {
311
+ switch (def.id) {
312
+ case 'bold':
313
+ return toggleBold(view);
314
+ case 'italic':
315
+ return toggleItalic(view);
316
+ case 'underline':
317
+ return toggleUnderline(view);
318
+ case 'strikethrough':
319
+ return toggleStrikethrough(view);
320
+ case 'inline-code':
321
+ return toggleInlineCode(view);
322
+ case 'uppercase':
323
+ return transformSelectionUppercase(view);
324
+ case 'lowercase':
325
+ return transformSelectionLowercase(view);
326
+ case 'titlecase':
327
+ return transformSelectionTitlecase(view);
328
+ case 'comment':
329
+ return insertCommentCommand(view);
330
+ case 'frontmatter-template':
331
+ return insertFrontmatterTemplate(view);
332
+ case 'blockquote':
333
+ return insertBlockQuote(view);
334
+ case 'table':
335
+ return insertTableTemplate(view);
336
+ case 'code-cell':
337
+ return insertCodeCellTemplate(view, 'python');
338
+ case 'bullet-list':
339
+ return insertBulletList(view);
340
+ case 'numbered-list':
341
+ return insertNumberedList(view);
342
+ case 'task-list':
343
+ return insertTaskList(view);
344
+ case 'heading-2':
345
+ return insertHeading(view, 2);
346
+ case 'horizontal-rule':
347
+ return insertHorizontalRule(view);
348
+ case 'page-break':
349
+ return insertPageBreak(view);
350
+ default:
351
+ return false;
352
+ }
353
+ }
354
+
355
+ // ===========================================================================
356
+ // AI Helpers
357
+ // ===========================================================================
358
+
359
+ function getAiClient(view) {
360
+ const cfg = view.state.facet(ctrlKConfigFacet);
361
+ return cfg?.aiClient || null;
362
+ }
363
+
364
+ function getFocusedSectionRange(view) {
365
+ const sel = view.state.selection.main;
366
+ if (!sel.empty) {
367
+ return {
368
+ from: sel.from,
369
+ to: sel.to,
370
+ text: view.state.doc.sliceString(sel.from, sel.to),
371
+ };
372
+ }
373
+
374
+ const tree = syntaxTree(view.state);
375
+ let node = tree.resolveInner(sel.head, 1);
376
+
377
+ while (node?.parent && node.parent.name !== 'Document') {
378
+ node = node.parent;
379
+ }
380
+
381
+ if (!node || node.name === 'Document') {
382
+ const line = view.state.doc.lineAt(sel.head);
383
+ return {
384
+ from: line.from,
385
+ to: line.to,
386
+ text: line.text,
387
+ };
388
+ }
389
+
390
+ return {
391
+ from: node.from,
392
+ to: node.to,
393
+ text: view.state.doc.sliceString(node.from, node.to),
394
+ };
395
+ }
396
+
397
+ function getCodeContextAtCursor(view) {
398
+ const cursor = view.state.selection.main.head;
399
+ const sel = view.state.selection.main;
400
+ const content = view.state.doc.toString();
401
+ const block = findCodeBlockAtPosition(content, cursor);
402
+ if (!block) return null;
403
+
404
+ const hasCodeSelection = !sel.empty && sel.from >= block.codeStart && sel.to <= block.codeEnd;
405
+
406
+ const selectedCode = hasCodeSelection
407
+ ? view.state.doc.sliceString(sel.from, sel.to)
408
+ : block.code;
409
+
410
+ return {
411
+ language: block.baseLanguage || block.language || 'text',
412
+ localContext: block.code,
413
+ codeBeforeCursor: view.state.doc.sliceString(block.codeStart, Math.min(cursor, block.codeEnd)),
414
+ replaceFrom: hasCodeSelection ? sel.from : block.codeStart,
415
+ replaceTo: hasCodeSelection ? sel.to : block.codeEnd,
416
+ selectedCode,
417
+ };
418
+ }
419
+
420
+ async function runAi(view, program, params, operation) {
421
+ const aiClient = getAiClient(view);
422
+ if (!aiClient) {
423
+ console.warn('[SectionControls] AI client not available. Ensure Ctrl-K AI extension is configured.');
424
+ return;
425
+ }
426
+
427
+ await executeAiOperation(view, aiClient, {
428
+ program,
429
+ params,
430
+ type: operation.type,
431
+ from: operation.from,
432
+ to: operation.to,
433
+ resultField: operation.resultField,
434
+ juiceLevel: aiClient.juiceLevel,
435
+ });
436
+ }
437
+
438
+ // ===========================================================================
439
+ // AI Command Definitions (for expanded menu)
440
+ // ===========================================================================
441
+
442
+ export const AI_COMMAND_DEFINITIONS = [
443
+ { id: 'finish-sentence', label: 'Complete Sentence', shortcut: 'Mod-L', icon: 'line', program: 'FinishSentencePredict', type: 'insert', resultField: 'completion' },
444
+ { id: 'finish-paragraph', label: 'Complete Paragraph', shortcut: 'Mod-O', icon: 'section', program: 'FinishParagraphPredict', type: 'insert', resultField: 'completion' },
445
+ { id: 'continue-document', label: 'Continue Document', shortcut: '', icon: 'doc', action: 'continue-document' },
446
+ { id: 'fix-grammar', label: 'Fix Grammar', shortcut: 'Mod-G', icon: 'grammar', program: 'FixGrammarPredict', type: 'replace', resultField: 'fixed_text' },
447
+ { id: 'fix-transcription', label: 'Fix Transcription', shortcut: '', icon: 'wand', program: 'FixTranscriptionPredict', type: 'replace', resultField: 'fixed_text' },
448
+ { id: 'correct-finish-line', label: 'Correct + Finish Line', shortcut: '', icon: 'line', program: 'CorrectAndFinishLinePredict', type: 'replace', resultField: 'corrected_completion' },
449
+ { id: 'correct-finish-section', label: 'Correct + Finish Section', shortcut: '', icon: 'section', program: 'CorrectAndFinishSectionPredict', type: 'replace', resultField: 'corrected_completion' },
450
+ { id: 'reformat-markdown', label: 'Reformat Markdown', shortcut: '', icon: 'format', program: 'ReformatMarkdownPredict', type: 'replace', resultField: 'reformatted_text' },
451
+ { id: 'address-nearby-comment', label: 'Address Nearby Comment', shortcut: '', icon: 'comment', action: 'address-nearby-comment', requiresNearbyComment: true },
452
+ { id: 'address-all-comments', label: 'Address All Comments', shortcut: '', icon: 'comment', action: 'address-all-comments', requiresComments: true },
453
+
454
+ // Code-focused
455
+ { id: 'document-code', label: 'Add Documentation to Code', shortcut: '', icon: 'doc', program: 'DocumentCodePredict', type: 'replace', resultField: 'documented_code', codeOnly: true },
456
+ { id: 'complete-code', label: 'Complete Code', shortcut: '', icon: 'code', program: 'CompleteCodePredict', type: 'replace', resultField: 'completion', codeOnly: true },
457
+ { id: 'add-type-hints', label: 'Add Type Hints', shortcut: '', icon: 'type', program: 'AddTypeHintsPredict', type: 'replace', resultField: 'typed_code', codeOnly: true },
458
+ { id: 'improve-names', label: 'Improve Names', shortcut: '', icon: 'rename', program: 'ImproveNamesPredict', type: 'replace', resultField: 'improved_code', codeOnly: true },
459
+ { id: 'explain-code', label: 'Explain Code', shortcut: '', icon: 'comment', program: 'ExplainCodePredict', type: 'replace', resultField: 'explained_code', codeOnly: true },
460
+ { id: 'refactor-code', label: 'Refactor Code', shortcut: '', icon: 'refactor', program: 'RefactorCodePredict', type: 'replace', resultField: 'refactored_code', codeOnly: true },
461
+ { id: 'format-code', label: 'Format Code', shortcut: '', icon: 'format', program: 'FormatCodePredict', type: 'replace', resultField: 'formatted_code', codeOnly: true },
462
+ ];
463
+
464
+ /**
465
+ * Execute one menu AI definition.
466
+ * @param {import('@codemirror/view').EditorView} view
467
+ * @param {Object} editor
468
+ * @param {Object} def
469
+ */
470
+ export async function executeAiDefinition(view, editor, def) {
471
+ if (def.action === 'continue-document') {
472
+ const aiClient = getAiClient(view);
473
+ if (!aiClient) {
474
+ console.warn('[SectionControls] AI client not available. Ensure Ctrl-K AI extension is configured.');
475
+ return;
476
+ }
477
+
478
+ const content = view.state.doc.toString();
479
+ const docEnd = view.state.doc.length;
480
+
481
+ await executeAiOperation(view, aiClient, {
482
+ program: 'DocumentResponsePredict',
483
+ params: { document: content },
484
+ type: 'insert',
485
+ from: docEnd,
486
+ to: docEnd,
487
+ resultField: 'response',
488
+ juiceLevel: aiClient.juiceLevel,
489
+ });
490
+ return;
491
+ }
492
+
493
+ if (def.action === 'address-nearby-comment') {
494
+ await addressNearbyComment(view);
495
+ return;
496
+ }
497
+
498
+ if (def.action === 'address-all-comments') {
499
+ await addressAllComments(view);
500
+ return;
501
+ }
502
+
503
+ const ctx = getAiContext(view);
504
+ const section = getFocusedSectionRange(view);
505
+ const code = getCodeContextAtCursor(view);
506
+ const sel = view.state.selection.main;
507
+
508
+ if (def.codeOnly && !code) {
509
+ console.warn(`[SectionControls] ${def.label} requires cursor in a code block.`);
510
+ return;
511
+ }
512
+
513
+ const isCodeFinish = def.program === 'FinishCodeLinePredict' || def.program === 'FinishCodeSectionPredict';
514
+
515
+ // Operation target
516
+ let from;
517
+ let to;
518
+ if (def.type === 'insert') {
519
+ from = ctx.cursorPos;
520
+ to = ctx.cursorPos;
521
+ } else if (def.codeOnly && code) {
522
+ from = code.replaceFrom;
523
+ to = code.replaceTo;
524
+ } else {
525
+ from = sel.empty ? section.from : sel.from;
526
+ to = sel.empty ? section.to : sel.to;
527
+ }
528
+
529
+ // Build params by command family
530
+ let params = {};
531
+
532
+ if (def.program.startsWith('Finish')) {
533
+ if (isCodeFinish || (def.codeOnly && code)) {
534
+ params = {
535
+ code_before_cursor: code?.codeBeforeCursor || '',
536
+ language: code?.language || 'text',
537
+ local_context: code?.localContext || '',
538
+ document_context: ctx.documentContext,
539
+ };
540
+ } else {
541
+ params = {
542
+ text_before_cursor: ctx.textBeforeCursor,
543
+ local_context: ctx.localContext,
544
+ document_context: ctx.documentContext,
545
+ };
546
+ }
547
+ } else if (def.program.startsWith('Fix')) {
548
+ params = {
549
+ text_to_fix: view.state.doc.sliceString(from, to),
550
+ local_context: ctx.localContext,
551
+ document_context: ctx.documentContext,
552
+ };
553
+ } else if (def.program.startsWith('CorrectAndFinish')) {
554
+ params = {
555
+ text_to_fix: view.state.doc.sliceString(from, to),
556
+ content_type: code ? 'code' : 'text',
557
+ local_context: code ? code.localContext : ctx.localContext,
558
+ document_context: ctx.documentContext,
559
+ };
560
+ } else if (def.program === 'ReformatMarkdownPredict') {
561
+ params = {
562
+ text: view.state.doc.sliceString(from, to),
563
+ local_context: ctx.localContext,
564
+ document_context: ctx.documentContext,
565
+ };
566
+ } else if (def.program.endsWith('CodePredict')) {
567
+ params = {
568
+ code: code?.selectedCode || view.state.doc.sliceString(from, to),
569
+ language: code?.language || 'text',
570
+ local_context: code?.localContext || ctx.localContext,
571
+ document_context: ctx.documentContext,
572
+ };
573
+ } else {
574
+ params = {
575
+ text_to_fix: view.state.doc.sliceString(from, to),
576
+ local_context: ctx.localContext,
577
+ document_context: ctx.documentContext,
578
+ };
579
+ }
580
+
581
+ await runAi(view, def.program, params, {
582
+ type: def.type,
583
+ from,
584
+ to,
585
+ resultField: def.resultField,
586
+ });
587
+ }
588
+
589
+ // ===========================================================================
590
+ // AI Quick Commands
591
+ // ===========================================================================
592
+
593
+ export const fixGrammar = (editor) => (view) => {
594
+ const def = AI_COMMAND_DEFINITIONS.find(d => d.id === 'fix-grammar');
595
+ if (def) void executeAiDefinition(view, editor, def);
596
+ return true;
597
+ };
598
+
599
+ export const finishLine = (editor) => (view) => {
600
+ const code = getCodeContextAtCursor(view);
601
+ const def = code
602
+ ? { id: 'finish-code-line', label: 'Complete Code Line', program: 'FinishCodeLinePredict', type: 'insert', resultField: 'completion', codeOnly: true }
603
+ : AI_COMMAND_DEFINITIONS.find(d => d.id === 'finish-sentence');
604
+
605
+ if (def) void executeAiDefinition(view, editor, def);
606
+ return true;
607
+ };
608
+
609
+ export const finishSection = (editor) => (view) => {
610
+ const code = getCodeContextAtCursor(view);
611
+ const def = code
612
+ ? { id: 'finish-code-section', label: 'Complete Code Section', program: 'FinishCodeSectionPredict', type: 'insert', resultField: 'completion', codeOnly: true }
613
+ : AI_COMMAND_DEFINITIONS.find(d => d.id === 'finish-paragraph');
614
+
615
+ if (def) void executeAiDefinition(view, editor, def);
616
+ return true;
617
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Section Controls Module
3
+ *
4
+ * Provides AI and formatting controls that appear next to the focused section.
5
+ */
6
+
7
+ import { sectionControlsFacet, createSectionControlsPlugin } from './plugin.js';
8
+ import { keymap, EditorView } from '@codemirror/view';
9
+ import { Prec } from '@codemirror/state';
10
+ import * as commands from './commands.js';
11
+ import { openSectionControlsMenu } from './widgets.js';
12
+
13
+ /**
14
+ * Create section controls extensions
15
+ *
16
+ * @param {Object} editor - Editor API instance
17
+ * @param {Object} [options] - Configuration
18
+ * @returns {Array} CodeMirror extensions
19
+ */
20
+ export function sectionControls(editor, options = {}) {
21
+ const config = {
22
+ enabled: options.enabled !== false,
23
+ showAi: options.showAi !== false,
24
+ showFormatting: options.showFormatting !== false,
25
+ ...options,
26
+ };
27
+
28
+ if (!config.enabled) return [];
29
+
30
+ const keybindings = [
31
+ { key: 'Mod-b', run: (view) => commands.toggleBold(view) },
32
+ { key: 'Mod-i', run: (view) => commands.toggleItalic(view) },
33
+ { key: 'Mod-u', run: (view) => commands.toggleUnderline(view) },
34
+ { key: 'Mod-`', run: (view) => commands.toggleInlineCode(view) },
35
+ { key: 'Mod-g', run: (view) => commands.fixGrammar(editor)(view) },
36
+ { key: 'Mod-l', run: (view) => commands.finishLine(editor)(view) },
37
+ { key: 'Mod-o', run: (view) => commands.finishSection(editor)(view) },
38
+ { key: "Mod-'", run: (view) => openSectionControlsMenu(view, editor) },
39
+ ];
40
+
41
+ const shortcutFallback = EditorView.domEventHandlers({
42
+ keydown(event, view) {
43
+ // Robust fallback for international keyboard layouts:
44
+ // use physical Quote key (preferred) and keep Period as secondary.
45
+ const isPrimary = event.ctrlKey || event.metaKey;
46
+ if (isPrimary && !event.altKey && (event.code === 'Quote' || event.code === 'Period')) {
47
+ event.preventDefault();
48
+ event.stopPropagation();
49
+ return openSectionControlsMenu(view, editor);
50
+ }
51
+ return false;
52
+ },
53
+ });
54
+
55
+ return [
56
+ sectionControlsFacet.of(config),
57
+ createSectionControlsPlugin(editor),
58
+ keybindings.length > 0 ? Prec.high(keymap.of(keybindings)) : [],
59
+ Prec.high(shortcutFallback),
60
+ ];
61
+ }
62
+
63
+ export * from './commands.js';