mrmd-editor 0.3.4 → 0.3.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-editor",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Markdown editor with realtime collaboration - the core editor package",
5
5
  "type": "module",
6
6
  "main": "dist/mrmd.cjs",
@@ -109,6 +109,8 @@ function createSegment(type, context) {
109
109
  return createSyncSegment(context);
110
110
  case 'runtime':
111
111
  return createRuntimeSegment(context);
112
+ case 'runtimes':
113
+ return createRuntimesSegment(context);
112
114
  case 'ai':
113
115
  return createAiSegment(context);
114
116
  case 'theme':
@@ -1058,6 +1060,241 @@ function createThemeSegment({ editorRef, shellState, handlers, onCleanup }) {
1058
1060
  return segment;
1059
1061
  }
1060
1062
 
1063
+ // =============================================================================
1064
+ // RUNTIMES SEGMENT (Project-wide runtime management)
1065
+ // =============================================================================
1066
+
1067
+ function createRuntimesSegment({ shellState, orchestratorClient, handlers, onCleanup }) {
1068
+ const segment = document.createElement('div');
1069
+ segment.className = 'mrmd-statusbar__segment mrmd-statusbar__segment--runtimes';
1070
+ segment.setAttribute('data-segment', 'runtimes');
1071
+
1072
+ let currentMenu = null;
1073
+ let cachedRuntimes = null;
1074
+ let lastFetchTime = 0;
1075
+ const CACHE_TTL = 3000; // 3 seconds
1076
+
1077
+ async function fetchRuntimes() {
1078
+ const now = Date.now();
1079
+ if (now - lastFetchTime < CACHE_TTL && cachedRuntimes) {
1080
+ return cachedRuntimes;
1081
+ }
1082
+
1083
+ try {
1084
+ cachedRuntimes = await orchestratorClient.listRuntimes();
1085
+ lastFetchTime = now;
1086
+ return cachedRuntimes;
1087
+ } catch (error) {
1088
+ console.error('Failed to list runtimes:', error);
1089
+ return cachedRuntimes || { shared: null, dedicated: [], sessions: [], project: {} };
1090
+ }
1091
+ }
1092
+
1093
+ function render() {
1094
+ const python = shellState.get('runtimes.python');
1095
+ const project = shellState.get('project') || {};
1096
+
1097
+ // Count active runtimes
1098
+ let runtimeCount = 0;
1099
+ if (python?.running || python?.status === 'ready') runtimeCount++;
1100
+
1101
+ const sessions = shellState.getSessions();
1102
+ const dedicatedCount = sessions.filter(s => s.info?.dedicated).length;
1103
+ runtimeCount += dedicatedCount;
1104
+
1105
+ // Project name
1106
+ const projectName = project.name || 'mrmd';
1107
+
1108
+ // Status dot
1109
+ const hasActiveRuntime = runtimeCount > 0;
1110
+ const dotClass = hasActiveRuntime ? 'connected' : 'disconnected';
1111
+
1112
+ segment.innerHTML = `
1113
+ <span class="mrmd-statusbar__dot mrmd-statusbar__dot--${dotClass}"></span>
1114
+ <span class="mrmd-statusbar__icon">⚡</span>
1115
+ <span class="mrmd-statusbar__label">${projectName}</span>
1116
+ ${runtimeCount > 0 ? `<span class="mrmd-statusbar__badge">${runtimeCount} runtime${runtimeCount > 1 ? 's' : ''}</span>` : ''}
1117
+ <span class="mrmd-statusbar__chevron">▾</span>
1118
+ `;
1119
+ }
1120
+
1121
+ async function openMenu() {
1122
+ if (currentMenu) {
1123
+ currentMenu.close();
1124
+ return;
1125
+ }
1126
+
1127
+ const runtimes = await fetchRuntimes();
1128
+ const items = [];
1129
+
1130
+ // Project section
1131
+ const project = runtimes.project || {};
1132
+ items.push({
1133
+ type: 'header',
1134
+ label: 'Project',
1135
+ });
1136
+ items.push({
1137
+ type: 'info',
1138
+ label: 'Name',
1139
+ value: project.name || 'unknown',
1140
+ });
1141
+ items.push({
1142
+ type: 'info',
1143
+ label: 'Root',
1144
+ value: shortenPath(project.root, 35),
1145
+ });
1146
+ if (project.venv) {
1147
+ items.push({
1148
+ type: 'info',
1149
+ label: 'Venv',
1150
+ value: shortenPath(project.venv, 35),
1151
+ });
1152
+ }
1153
+
1154
+ // Shared Runtime section
1155
+ items.push({ type: 'divider' });
1156
+ items.push({
1157
+ type: 'header',
1158
+ label: 'Shared Runtime',
1159
+ });
1160
+
1161
+ if (runtimes.shared && runtimes.shared.alive) {
1162
+ items.push({
1163
+ type: 'info',
1164
+ label: 'Status',
1165
+ value: '● Running',
1166
+ });
1167
+ items.push({
1168
+ type: 'info',
1169
+ label: 'PID',
1170
+ value: String(runtimes.shared.pid || 'unknown'),
1171
+ });
1172
+ items.push({
1173
+ type: 'info',
1174
+ label: 'Port',
1175
+ value: String(runtimes.shared.port || 'unknown'),
1176
+ });
1177
+ if (runtimes.shared.venv) {
1178
+ items.push({
1179
+ type: 'info',
1180
+ label: 'Venv',
1181
+ value: shortenPath(runtimes.shared.venv, 30),
1182
+ });
1183
+ }
1184
+ items.push({
1185
+ icon: '💀',
1186
+ label: 'Kill shared runtime',
1187
+ description: 'Release memory, restarts on next exec',
1188
+ onClick: async () => {
1189
+ await handlers.onKillRuntime?.('shared');
1190
+ // Refresh after a moment
1191
+ setTimeout(render, 500);
1192
+ },
1193
+ });
1194
+ } else {
1195
+ items.push({
1196
+ type: 'info',
1197
+ label: 'Status',
1198
+ value: '○ Not running',
1199
+ });
1200
+ items.push({
1201
+ type: 'info',
1202
+ label: '',
1203
+ value: 'Starts on first code execution',
1204
+ });
1205
+ }
1206
+
1207
+ // Dedicated Runtimes section
1208
+ if (runtimes.dedicated && runtimes.dedicated.length > 0) {
1209
+ items.push({ type: 'divider' });
1210
+ items.push({
1211
+ type: 'header',
1212
+ label: `Dedicated Runtimes (${runtimes.dedicated.length})`,
1213
+ });
1214
+
1215
+ for (const rt of runtimes.dedicated) {
1216
+ const statusIcon = rt.alive ? '●' : '○';
1217
+ const statusText = rt.alive ? 'running' : 'stopped';
1218
+ items.push({
1219
+ type: 'info',
1220
+ label: rt.doc || rt.id,
1221
+ value: `${statusIcon} ${statusText} (port ${rt.port || '?'})`,
1222
+ });
1223
+ }
1224
+
1225
+ items.push({
1226
+ icon: '🗑️',
1227
+ label: 'Kill all dedicated runtimes',
1228
+ onClick: async () => {
1229
+ for (const rt of runtimes.dedicated) {
1230
+ await handlers.onKillRuntime?.(rt.doc || rt.id);
1231
+ }
1232
+ setTimeout(render, 500);
1233
+ },
1234
+ });
1235
+ }
1236
+
1237
+ // Sessions section
1238
+ if (runtimes.sessions && runtimes.sessions.length > 0) {
1239
+ items.push({ type: 'divider' });
1240
+ items.push({
1241
+ type: 'header',
1242
+ label: `Active Sessions (${runtimes.sessions.length})`,
1243
+ });
1244
+
1245
+ for (const session of runtimes.sessions) {
1246
+ if (!session) continue;
1247
+ const runtimeType = session.runtimes?.python?.dedicated ? 'dedicated' : 'shared';
1248
+ const port = session.runtimes?.python?.port;
1249
+ items.push({
1250
+ type: 'info',
1251
+ label: session.doc,
1252
+ value: `${runtimeType}${port ? ` (port ${port})` : ''}`,
1253
+ });
1254
+ }
1255
+ }
1256
+
1257
+ // Actions section
1258
+ items.push({ type: 'divider' });
1259
+ items.push({
1260
+ icon: '🔄',
1261
+ label: 'Refresh',
1262
+ onClick: async () => {
1263
+ cachedRuntimes = null;
1264
+ lastFetchTime = 0;
1265
+ await shellState.refresh();
1266
+ render();
1267
+ },
1268
+ });
1269
+
1270
+ currentMenu = createMenu({
1271
+ items,
1272
+ anchor: segment,
1273
+ position: 'bottom-right',
1274
+ onClose: () => { currentMenu = null; },
1275
+ });
1276
+ }
1277
+
1278
+ segment.addEventListener('click', openMenu);
1279
+
1280
+ // Subscribe to state changes
1281
+ const unsubscribe1 = shellState.onPath('runtimes', render);
1282
+ const unsubscribe2 = shellState.onPath('project', render);
1283
+ const unsubscribe3 = shellState.onPath('orchestrator.services', render);
1284
+ onCleanup(unsubscribe1);
1285
+ onCleanup(unsubscribe2);
1286
+ onCleanup(unsubscribe3);
1287
+ onCleanup(() => currentMenu?.close());
1288
+
1289
+ // Initial fetch of project info
1290
+ orchestratorClient.getProject().then(project => {
1291
+ shellState._set('project', project);
1292
+ }).catch(() => {});
1293
+
1294
+ render();
1295
+ return segment;
1296
+ }
1297
+
1061
1298
  // =============================================================================
1062
1299
  // HELPERS
1063
1300
  // =============================================================================
@@ -142,6 +142,62 @@ export async function createStudio(target, options = {}) {
142
142
  return () => eventHandlers.get(event).delete(handler);
143
143
  }
144
144
 
145
+ /**
146
+ * Detect if cursor is inside a code block and return block info
147
+ * @param {EditorView} view
148
+ * @returns {{language: string, code: string, start: number, end: number}|null}
149
+ */
150
+ function detectCodeBlockAtCursor(view) {
151
+ const content = view.state.doc.toString();
152
+ const pos = view.state.selection.main.head;
153
+
154
+ // Parse code blocks from content
155
+ const lines = content.split('\n');
156
+ let inBlock = false;
157
+ let blockStart = 0;
158
+ let blockLanguage = '';
159
+ let codeStart = 0;
160
+ let charOffset = 0;
161
+
162
+ for (let i = 0; i < lines.length; i++) {
163
+ const line = lines[i];
164
+ const lineStart = charOffset;
165
+
166
+ if (!inBlock) {
167
+ const match = line.match(/^(`{3,})(\w*)/);
168
+ if (match) {
169
+ inBlock = true;
170
+ blockStart = lineStart;
171
+ blockLanguage = match[2].toLowerCase();
172
+ codeStart = lineStart + line.length + 1;
173
+ }
174
+ } else {
175
+ if (line.match(/^`{3,}\s*$/)) {
176
+ const codeEnd = lineStart;
177
+ const blockEnd = lineStart + line.length;
178
+
179
+ // Check if cursor is within this block
180
+ if (pos >= blockStart && pos <= blockEnd) {
181
+ return {
182
+ language: blockLanguage || 'text',
183
+ code: content.slice(codeStart, codeEnd),
184
+ start: blockStart,
185
+ end: blockEnd,
186
+ codeStart,
187
+ codeEnd,
188
+ };
189
+ }
190
+
191
+ inBlock = false;
192
+ }
193
+ }
194
+
195
+ charOffset += line.length + 1;
196
+ }
197
+
198
+ return null;
199
+ }
200
+
145
201
  // Get service URLs from orchestrator
146
202
  let syncUrl;
147
203
  let runtimeUrls = {};
@@ -282,14 +338,16 @@ export async function createStudio(target, options = {}) {
282
338
  const aiExtensions = mrmd.default.ai.aiIntegration({
283
339
  onSparkClick: (e, view) => {
284
340
  const context = mrmd.default.ai.getAiContext(view);
341
+ const codeBlock = detectCodeBlockAtCursor(view);
285
342
  showAiMenu({
286
343
  x: e.clientX,
287
344
  y: e.clientY,
288
345
  context: {
289
346
  hasSelection: context.selectedText.length > 0,
290
- inCodeCell: false, // TODO: detect code cell
347
+ inCodeCell: codeBlock !== null,
348
+ codeLanguage: codeBlock?.language || null,
291
349
  },
292
- onCommand: (cmd) => executeAiCommand(cmd, newEditor),
350
+ onCommand: (cmd) => executeAiCommand(cmd, newEditor, codeBlock),
293
351
  juiceLevel: shellState.get('ai')?.juiceLevel || 0,
294
352
  onJuiceLevelChange: (level) => {
295
353
  shellState._set('ai.juiceLevel', level);
@@ -310,14 +368,20 @@ export async function createStudio(target, options = {}) {
310
368
 
311
369
  /**
312
370
  * Execute an AI command on the editor
371
+ * @param {Object} cmd - Command object
372
+ * @param {Object} targetEditor - Editor instance
373
+ * @param {Object|null} codeBlock - Code block info if cursor is in a code block
313
374
  */
314
- async function executeAiCommand(cmd, targetEditor) {
375
+ async function executeAiCommand(cmd, targetEditor, codeBlock = null) {
315
376
  if (!aiClient || !mrmd.default.ai) return;
316
377
 
317
378
  const view = targetEditor.view;
318
379
  const context = mrmd.default.ai.getAiContext(view);
319
380
  const juiceLevel = shellState.get('ai')?.juiceLevel || 0;
320
381
 
382
+ // Detect language from code block, or fall back to python
383
+ const detectedLanguage = codeBlock?.language || 'python';
384
+
321
385
  // Mark AI as active
322
386
  shellState._set('ai.active', true);
323
387
 
@@ -341,7 +405,7 @@ export async function createStudio(target, options = {}) {
341
405
  } else if (cmd.program.includes('Code')) {
342
406
  params = {
343
407
  code: context.selectedText,
344
- language: 'python', // TODO: detect
408
+ language: detectedLanguage,
345
409
  local_context: context.localContext,
346
410
  document_context: context.documentContext,
347
411
  };
@@ -594,9 +658,7 @@ export async function createStudio(target, options = {}) {
594
658
  },
595
659
 
596
660
  async onRestartRuntime(language) {
597
- console.log('[RestartRuntime] Starting restart for:', language);
598
-
599
- const docName = shellState.get('currentDoc');
661
+ const docName = currentDocName;
600
662
  if (!docName) {
601
663
  console.warn('[RestartRuntime] No current document');
602
664
  return;
@@ -606,19 +668,15 @@ export async function createStudio(target, options = {}) {
606
668
  // Get current session info to preserve venv
607
669
  const python = shellState.get('runtimes.python') || {};
608
670
  const currentVenv = python.venv;
609
- console.log('[RestartRuntime] Current venv:', currentVenv);
610
671
 
611
672
  // Destroy existing session (kills the daemon)
612
- console.log('[RestartRuntime] Destroying session for:', docName);
613
673
  await orchestratorClient.destroySession(docName);
614
674
 
615
675
  // Small delay to ensure cleanup
616
676
  await new Promise(r => setTimeout(r, 500));
617
677
 
618
678
  // Create new session with same venv
619
- console.log('[RestartRuntime] Creating new session with venv:', currentVenv);
620
679
  const sessionInfo = await shellState.createSession(docName, 'dedicated', currentVenv);
621
- console.log('[RestartRuntime] New session created:', sessionInfo);
622
680
 
623
681
  // Reconnect editor to new runtime
624
682
  if (editor?.connectRuntime && sessionInfo.url) {
@@ -636,6 +694,31 @@ export async function createStudio(target, options = {}) {
636
694
  }
637
695
  },
638
696
 
697
+ async onKillRuntime(runtimeId) {
698
+ try {
699
+ const result = await orchestratorClient.killRuntime(runtimeId);
700
+
701
+ // Refresh state after kill
702
+ await shellState.refresh();
703
+
704
+ // If we killed the current document's runtime, update the editor
705
+ if (runtimeId === currentDocName && editor?.execution) {
706
+ // Clear the runtime connection
707
+ editor.execution.setRuntimeUrl(null);
708
+ }
709
+
710
+ emit('runtimeKilled', { runtimeId, result });
711
+ } catch (error) {
712
+ console.error('[KillRuntime] Error:', error);
713
+ await confirm({
714
+ title: 'Error',
715
+ message: `Failed to kill runtime: ${error.message}`,
716
+ confirmLabel: 'OK',
717
+ cancelLabel: '',
718
+ });
719
+ }
720
+ },
721
+
639
722
  async onNewFile() {
640
723
  const newName = await prompt({
641
724
  title: 'New File',
@@ -819,10 +902,10 @@ export async function createStudio(target, options = {}) {
819
902
  // Create status bar
820
903
  let statusBarComponent = null;
821
904
  if (statusBarConfig.enabled !== false) {
822
- // Default segments - include 'ai' if AI is available
905
+ // Default segments - 'runtimes' for project-wide management, 'ai' if available
823
906
  const defaultSegments = aiClient
824
- ? ['files', 'sync', 'runtime', 'ai']
825
- : ['files', 'sync', 'runtime'];
907
+ ? ['files', 'runtimes', 'sync', 'ai']
908
+ : ['files', 'runtimes', 'sync'];
826
909
 
827
910
  statusBarComponent = createStatusBar({
828
911
  container: statusBarContainer,
@@ -321,6 +321,45 @@ export class OrchestratorClient {
321
321
  });
322
322
  }
323
323
 
324
+ /**
325
+ * List all active sessions
326
+ * @returns {Promise<{sessions: Array}>}
327
+ */
328
+ async listSessions() {
329
+ return this._fetch('/api/sessions');
330
+ }
331
+
332
+ // ===========================================================================
333
+ // Project & Runtime Management
334
+ // ===========================================================================
335
+
336
+ /**
337
+ * Get project information
338
+ * @returns {Promise<{root: string, name: string, type: string, venv: string|null}>}
339
+ */
340
+ async getProject() {
341
+ return this._fetch('/api/project');
342
+ }
343
+
344
+ /**
345
+ * List all runtimes (shared and dedicated)
346
+ * @returns {Promise<{shared: Object|null, dedicated: Array, sessions: Array, project: Object}>}
347
+ */
348
+ async listRuntimes() {
349
+ return this._fetch('/api/runtimes');
350
+ }
351
+
352
+ /**
353
+ * Kill a runtime
354
+ * @param {string} runtimeId - Runtime ID ('shared' or document name for dedicated)
355
+ * @returns {Promise<{id: string, killed: boolean, message: string}>}
356
+ */
357
+ async killRuntime(runtimeId) {
358
+ return this._fetch(`/api/runtimes/${encodeURIComponent(runtimeId)}`, {
359
+ method: 'DELETE',
360
+ });
361
+ }
362
+
324
363
  // ===========================================================================
325
364
  // Logs
326
365
  // ===========================================================================
@@ -59,6 +59,13 @@ function getInitialState() {
59
59
  projectRoot: '',
60
60
  file: null,
61
61
  theme: null, // null = auto, or theme name
62
+ // Project info from orchestrator
63
+ project: {
64
+ root: '',
65
+ name: '',
66
+ type: 'unknown',
67
+ venv: null,
68
+ },
62
69
  runtimes: {
63
70
  // Legacy: single Python runtime info (for backward compat)
64
71
  python: null,
@@ -279,15 +286,20 @@ export class ShellStateManager {
279
286
  */
280
287
  async refresh() {
281
288
  try {
282
- // Fetch status and environment in parallel
283
- const [status, env] = await Promise.all([
289
+ // Fetch status, environment, and project info in parallel
290
+ const [status, env, project] = await Promise.all([
284
291
  this._client.getStatus(),
285
292
  this._client.getEnvironment(),
293
+ this._client.getProject().catch(() => null),
286
294
  ]);
287
295
 
288
296
  this._updateFromOrchestratorStatus(status);
289
297
  this._updateFromEnvironment(env);
290
298
 
299
+ if (project) {
300
+ this._set('project', project);
301
+ }
302
+
291
303
  this._set('orchestrator.status', 'connected');
292
304
  this._set('orchestrator.error', null);
293
305
  } catch (error) {