mrmd-editor 0.6.0 → 0.7.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.
@@ -2423,6 +2423,60 @@ ${ansiStyles}
2423
2423
  .cm-stdin-widget[data-password="true"] .cm-stdin-content {
2424
2424
  letter-spacing: 0.2em;
2425
2425
  }
2426
+
2427
+ /* ==========================================================================
2428
+ MOBILE RESPONSIVE
2429
+
2430
+ Output blocks need to be readable on narrow screens.
2431
+ Horizontal scroll for wide output, larger text for readability.
2432
+ ========================================================================== */
2433
+
2434
+ @media (max-width: 768px) {
2435
+ .cm-output-widget {
2436
+ font-size: max(var(--output-font-size, 0.7em), 12px);
2437
+ left: 0; /* No inset on mobile — use full width */
2438
+ right: 0;
2439
+ padding: 8px 12px;
2440
+ }
2441
+
2442
+ /* Output content lines: ensure they don't overflow */
2443
+ .cm-output-content-line {
2444
+ font-size: max(var(--output-font-size, 0.8em), 12px);
2445
+ }
2446
+
2447
+ /* Rich output (HTML renders, plots): full width */
2448
+ .cm-output-rich-widget {
2449
+ max-width: 100%;
2450
+ }
2451
+
2452
+ .cm-output-rich-widget iframe {
2453
+ max-width: 100%;
2454
+ }
2455
+
2456
+ /* Stdin widget: bigger input on mobile */
2457
+ .cm-stdin-widget .cm-stdin-input {
2458
+ font-size: 16px; /* Prevents iOS zoom */
2459
+ padding: 10px;
2460
+ }
2461
+
2462
+ .cm-stdin-widget .cm-stdin-prompt {
2463
+ font-size: 14px;
2464
+ }
2465
+ }
2466
+
2467
+ @media (pointer: coarse) {
2468
+ /* Output container: larger tap target for focus/interaction */
2469
+ .cm-output-widget {
2470
+ padding: 10px 14px;
2471
+ }
2472
+
2473
+ /* Collapsed output: easy to tap to expand */
2474
+ .cm-output-collapsed {
2475
+ min-height: 44px;
2476
+ display: flex;
2477
+ align-items: center;
2478
+ }
2479
+ }
2426
2480
  `;
2427
2481
 
2428
2482
  // #endregion STYLES
@@ -11,9 +11,9 @@
11
11
  * const extensions = createRuntimeCodeLensExtensions({
12
12
  * projectName: 'my-project',
13
13
  * getSessionStatus: (name) => shellState.get(`runtimes.sessions.${name}`),
14
- * onStart: async (runtime) => { await electronAPI.session.start(runtime); },
15
- * onStop: async (name) => { await electronAPI.session.stop(name); },
16
- * onRestart: async (name) => { await electronAPI.session.restart(name); },
14
+ * onStart: async (runtime) => { await electronAPI.runtime.start(runtime); },
15
+ * onStop: async (name) => { await electronAPI.runtime.stop(name); },
16
+ * onRestart: async (name) => { await electronAPI.runtime.restart(name); },
17
17
  * onRestartAll: async () => { ... },
18
18
  * });
19
19
  * ```
@@ -414,6 +414,76 @@ export const AI_MENU_STYLES = `
414
414
  color: #808080;
415
415
  font-style: italic;
416
416
  }
417
+
418
+ /* Mobile: AI menu becomes a bottom sheet */
419
+ @media (max-width: 768px) {
420
+ .ai-menu {
421
+ position: fixed !important;
422
+ left: 0 !important;
423
+ right: 0 !important;
424
+ bottom: 0 !important;
425
+ top: auto !important;
426
+ min-width: 100%;
427
+ max-width: 100%;
428
+ border-radius: 16px 16px 0 0;
429
+ border: none;
430
+ border-top: 1px solid #3c3c3c;
431
+ box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
432
+ animation: ai-menu-slide-up 0.25s cubic-bezier(0.4, 0, 0.2, 1);
433
+ }
434
+
435
+ @keyframes ai-menu-slide-up {
436
+ from { transform: translateY(100%); }
437
+ to { transform: translateY(0); }
438
+ }
439
+
440
+ .ai-menu-header {
441
+ padding: 16px 20px;
442
+ position: relative;
443
+ }
444
+
445
+ .ai-menu-header::before {
446
+ content: '';
447
+ position: absolute;
448
+ top: 8px;
449
+ left: 50%;
450
+ transform: translateX(-50%);
451
+ width: 36px;
452
+ height: 4px;
453
+ background: #666;
454
+ border-radius: 2px;
455
+ opacity: 0.4;
456
+ }
457
+
458
+ .ai-menu-title {
459
+ font-size: 16px;
460
+ padding-top: 4px;
461
+ }
462
+
463
+ .ai-menu-juice {
464
+ font-size: 14px;
465
+ padding: 6px 12px;
466
+ border-radius: 8px;
467
+ }
468
+
469
+ .ai-menu-commands {
470
+ max-height: 50vh;
471
+ padding: 8px 0;
472
+ padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
473
+ }
474
+
475
+ .ai-menu-item {
476
+ padding: 14px 20px;
477
+ min-height: 48px;
478
+ font-size: 16px;
479
+ }
480
+
481
+ .ai-menu-item-icon {
482
+ width: 28px;
483
+ font-size: 18px;
484
+ margin-right: 12px;
485
+ }
486
+ }
417
487
  `;
418
488
 
419
489
  /**
@@ -55,8 +55,21 @@ export function createMenu(options) {
55
55
  }
56
56
  }
57
57
 
58
- // Position menu relative to anchor
58
+ // Detect mobile/narrow viewport
59
+ const isMobile = () => window.matchMedia('(max-width: 768px)').matches;
60
+
61
+ // Position menu relative to anchor (desktop) or as bottom sheet (mobile)
59
62
  function updatePosition() {
63
+ // On mobile, CSS handles the positioning (bottom sheet via @media queries).
64
+ // We just need to not set inline position styles that would conflict.
65
+ if (isMobile()) {
66
+ menu.style.top = '';
67
+ menu.style.left = '';
68
+ menu.style.right = '';
69
+ menu.style.bottom = '';
70
+ return;
71
+ }
72
+
60
73
  const anchorRect = anchor.getBoundingClientRect();
61
74
  const menuRect = menu.getBoundingClientRect();
62
75
 
@@ -96,10 +109,18 @@ export function createMenu(options) {
96
109
  menu.style.left = `${left}px`;
97
110
  }
98
111
 
112
+ // Mobile scrim backdrop (for bottom sheet pattern)
113
+ let scrim = null;
114
+
99
115
  // Close menu
100
116
  function close() {
101
117
  menu.remove();
118
+ if (scrim) {
119
+ scrim.remove();
120
+ scrim = null;
121
+ }
102
122
  document.removeEventListener('mousedown', handleOutsideClick);
123
+ document.removeEventListener('touchstart', handleOutsideClick);
103
124
  document.removeEventListener('keydown', handleKeydown);
104
125
  onClose?.();
105
126
  }
@@ -120,11 +141,28 @@ export function createMenu(options) {
120
141
 
121
142
  // Initialize
122
143
  render(items);
144
+
145
+ // On mobile, add a scrim backdrop behind the bottom sheet
146
+ if (isMobile()) {
147
+ scrim = document.createElement('div');
148
+ scrim.style.cssText = `
149
+ position: fixed;
150
+ inset: 0;
151
+ background: rgba(0, 0, 0, 0.4);
152
+ z-index: 999;
153
+ animation: mrmd-fade-in 0.15s ease;
154
+ `;
155
+ scrim.addEventListener('click', close);
156
+ scrim.addEventListener('touchstart', close);
157
+ document.body.appendChild(scrim);
158
+ }
159
+
123
160
  document.body.appendChild(menu);
124
161
  updatePosition();
125
162
 
126
163
  // Add event listeners
127
164
  document.addEventListener('mousedown', handleOutsideClick);
165
+ document.addEventListener('touchstart', handleOutsideClick);
128
166
  document.addEventListener('keydown', handleKeydown);
129
167
 
130
168
  return {
@@ -861,6 +861,80 @@ function createRuntimeSegment({ shellState, orchestratorClient, handlers, onClea
861
861
  });
862
862
  }
863
863
 
864
+ // Runtime machine selection
865
+ if (machineState.supported) {
866
+ items.push({ type: 'divider' });
867
+ items.push({ type: 'header', label: 'Runtime Machine' });
868
+
869
+ items.push({
870
+ icon: activeMachineId ? '○' : '✓',
871
+ label: 'Auto-select',
872
+ active: !activeMachineId,
873
+ onClick: async () => {
874
+ try {
875
+ await orchestratorClient.setActiveMachine?.(null);
876
+ await refreshMachineState({ doRender: true });
877
+ } catch (err) {
878
+ console.error('Failed to set auto machine:', err);
879
+ }
880
+ },
881
+ });
882
+
883
+ for (const m of machineList) {
884
+ const online = m.status === 'online' || m.connected === true;
885
+ const isActiveMachine = activeMachineId === m.machineId;
886
+ items.push({
887
+ icon: isActiveMachine ? '✓' : (online ? '●' : '○'),
888
+ label: `${m.machineName || m.machineId}${online ? '' : ' (offline)'}`,
889
+ active: isActiveMachine,
890
+ onClick: async () => {
891
+ try {
892
+ await orchestratorClient.setActiveMachine?.(m.machineId);
893
+ await refreshMachineState({ doRender: true });
894
+ } catch (err) {
895
+ console.error('Failed to switch machine:', err);
896
+ }
897
+ },
898
+ });
899
+ }
900
+
901
+ if (projectRoot) {
902
+ const pref = getProjectMachinePreference(projectRoot);
903
+ items.push({ type: 'divider' });
904
+ items.push({ type: 'header', label: 'Project Machine Preference' });
905
+
906
+ items.push({
907
+ icon: pref === undefined ? '✓' : '○',
908
+ label: 'No preference (manual/global)',
909
+ active: pref === undefined,
910
+ onClick: () => {
911
+ setProjectMachinePreference(projectRoot, undefined);
912
+ },
913
+ });
914
+
915
+ items.push({
916
+ icon: pref === null ? '✓' : '○',
917
+ label: 'Prefer auto-select',
918
+ active: pref === null,
919
+ onClick: () => {
920
+ setProjectMachinePreference(projectRoot, null);
921
+ },
922
+ });
923
+
924
+ for (const m of machineList) {
925
+ const isPref = pref === m.machineId;
926
+ items.push({
927
+ icon: isPref ? '✓' : '📌',
928
+ label: `Prefer ${m.machineName || m.machineId}`,
929
+ active: isPref,
930
+ onClick: () => {
931
+ setProjectMachinePreference(projectRoot, m.machineId);
932
+ },
933
+ });
934
+ }
935
+ }
936
+ }
937
+
864
938
  // Refresh action
865
939
  items.push({ type: 'divider' });
866
940
  items.push({
@@ -870,6 +944,7 @@ function createRuntimeSegment({ shellState, orchestratorClient, handlers, onClea
870
944
  cachedRuntimes = null;
871
945
  cachedVenvs = null;
872
946
  await fetchRuntimes();
947
+ await refreshMachineState({ doRender: false });
873
948
  render();
874
949
  },
875
950
  });
@@ -885,13 +960,30 @@ function createRuntimeSegment({ shellState, orchestratorClient, handlers, onClea
885
960
  segment.addEventListener('click', openMenu);
886
961
 
887
962
  // Initial fetch
888
- fetchRuntimes().then(render);
963
+ Promise.all([
964
+ fetchRuntimes(),
965
+ refreshMachineState({ doRender: false }),
966
+ ]).then(async () => {
967
+ render();
968
+ await applyProjectPreference();
969
+ });
889
970
 
890
971
  // Subscribe to state changes
891
972
  const unsubscribe1 = shellState.onPath('runtimes', render);
892
973
  const unsubscribe2 = shellState.onPath('file', render);
974
+ const unsubscribe3 = shellState.onPath('projectRoot', () => {
975
+ applyProjectPreference();
976
+ render();
977
+ });
978
+
979
+ const machinePoll = setInterval(() => {
980
+ refreshMachineState({ doRender: true });
981
+ }, 15000);
982
+
893
983
  onCleanup(unsubscribe1);
894
984
  onCleanup(unsubscribe2);
985
+ onCleanup(unsubscribe3);
986
+ onCleanup(() => clearInterval(machinePoll));
895
987
  onCleanup(() => currentMenu?.close());
896
988
 
897
989
  render();
@@ -1402,7 +1494,7 @@ function createRuntimesSegment({ shellState, orchestratorClient, handlers, onCle
1402
1494
  let runtimeCount = 0;
1403
1495
  if (python?.running || python?.status === 'ready') runtimeCount++;
1404
1496
 
1405
- const sessions = shellState.getSessions();
1497
+ const sessions = shellState.getRuntimes();
1406
1498
  const dedicatedCount = sessions.filter(s => s.info?.dedicated).length;
1407
1499
  runtimeCount += dedicatedCount;
1408
1500
 
@@ -1650,7 +1742,7 @@ function createRuntimesSegment({ shellState, orchestratorClient, handlers, onCle
1650
1742
  items.push({ type: 'divider' });
1651
1743
  items.push({
1652
1744
  type: 'header',
1653
- label: `Active Sessions (${runtimes.sessions.length})`,
1745
+ label: `Active Runtime Attachments (${runtimes.sessions.length})`,
1654
1746
  });
1655
1747
 
1656
1748
  for (const session of runtimes.sessions) {
@@ -1671,16 +1763,16 @@ function createRuntimesSegment({ shellState, orchestratorClient, handlers, onCle
1671
1763
  });
1672
1764
  items.push({
1673
1765
  icon: '✖',
1674
- label: `Close "${session.doc}" session`,
1766
+ label: `Detach runtime from "${session.doc}"`,
1675
1767
  description: 'Stops monitor',
1676
1768
  onClick: async () => {
1677
1769
  try {
1678
- await orchestratorClient.destroySession(session.doc);
1770
+ await orchestratorClient.destroyRuntimeAttachment(session.doc);
1679
1771
  cachedRuntimes = null;
1680
1772
  lastFetchTime = 0;
1681
1773
  render();
1682
1774
  } catch (err) {
1683
- console.error('Failed to close session:', err);
1775
+ console.error('Failed to detach runtime attachment:', err);
1684
1776
  }
1685
1777
  },
1686
1778
  });
@@ -1746,6 +1838,101 @@ function createSimpleSegment({ shellState, orchestratorClient, handlers, onClean
1746
1838
 
1747
1839
  let currentMenu = null;
1748
1840
  let runtimeState = 'disconnected'; // 'connected', 'disconnected', 'error'
1841
+ let machineState = {
1842
+ supported: false,
1843
+ activeMachineId: null,
1844
+ activeMachineName: null,
1845
+ machines: [],
1846
+ };
1847
+
1848
+ const PROJECT_MACHINE_PREF_KEY = 'mrmd:project-machine-pref:v1';
1849
+ let projectMachinePrefs = loadProjectMachinePrefs();
1850
+ let lastAppliedProjectPrefKey = null;
1851
+
1852
+ function loadProjectMachinePrefs() {
1853
+ try {
1854
+ const raw = window.localStorage?.getItem(PROJECT_MACHINE_PREF_KEY);
1855
+ const parsed = raw ? JSON.parse(raw) : {};
1856
+ return parsed && typeof parsed === 'object' ? parsed : {};
1857
+ } catch {
1858
+ return {};
1859
+ }
1860
+ }
1861
+
1862
+ function saveProjectMachinePrefs() {
1863
+ try {
1864
+ window.localStorage?.setItem(PROJECT_MACHINE_PREF_KEY, JSON.stringify(projectMachinePrefs));
1865
+ } catch {
1866
+ // ignore
1867
+ }
1868
+ }
1869
+
1870
+ function getProjectMachinePreference(projectRoot) {
1871
+ if (!projectRoot || !(projectRoot in projectMachinePrefs)) return undefined;
1872
+ const value = projectMachinePrefs[projectRoot];
1873
+ return value === '__auto__' ? null : value;
1874
+ }
1875
+
1876
+ function setProjectMachinePreference(projectRoot, machineIdOrNullOrUndefined) {
1877
+ if (!projectRoot) return;
1878
+ if (machineIdOrNullOrUndefined === undefined) {
1879
+ delete projectMachinePrefs[projectRoot];
1880
+ } else if (machineIdOrNullOrUndefined === null) {
1881
+ projectMachinePrefs[projectRoot] = '__auto__';
1882
+ } else {
1883
+ projectMachinePrefs[projectRoot] = machineIdOrNullOrUndefined;
1884
+ }
1885
+ saveProjectMachinePrefs();
1886
+ }
1887
+
1888
+ async function refreshMachineState({ doRender = true } = {}) {
1889
+ try {
1890
+ const [machinesRes, activeRes] = await Promise.all([
1891
+ orchestratorClient.getMachines?.(),
1892
+ orchestratorClient.getActiveMachine?.(),
1893
+ ]);
1894
+
1895
+ const machines = machinesRes?.machines || [];
1896
+ const activeMachineId = activeRes?.activeMachineId || null;
1897
+ const activeMachine = machines.find(m => m.machineId === activeMachineId) || null;
1898
+
1899
+ machineState = {
1900
+ supported: true,
1901
+ activeMachineId,
1902
+ activeMachineName: activeMachine?.machineName || activeMachine?.machineId || null,
1903
+ machines,
1904
+ };
1905
+ } catch {
1906
+ machineState = {
1907
+ supported: false,
1908
+ activeMachineId: null,
1909
+ activeMachineName: null,
1910
+ machines: [],
1911
+ };
1912
+ }
1913
+
1914
+ if (doRender) render();
1915
+ return machineState;
1916
+ }
1917
+
1918
+ async function applyProjectPreference() {
1919
+ const projectRoot = shellState.get('projectRoot');
1920
+ if (!projectRoot || !machineState.supported) return;
1921
+
1922
+ const pref = getProjectMachinePreference(projectRoot);
1923
+ if (pref === undefined) return; // no preference set
1924
+
1925
+ const key = `${projectRoot}|${pref === null ? '__auto__' : pref}`;
1926
+ if (key === lastAppliedProjectPrefKey) return;
1927
+ lastAppliedProjectPrefKey = key;
1928
+
1929
+ try {
1930
+ await orchestratorClient.setActiveMachine?.(pref);
1931
+ await refreshMachineState({ doRender: true });
1932
+ } catch {
1933
+ // ignore
1934
+ }
1935
+ }
1749
1936
 
1750
1937
  function render() {
1751
1938
  const file = shellState.get('file');
@@ -1777,11 +1964,18 @@ function createSimpleSegment({ shellState, orchestratorClient, handlers, onClean
1777
1964
  error: 'No venv selected - click to pick one',
1778
1965
  }[runtimeState];
1779
1966
 
1967
+ const machinePill = machineState.supported
1968
+ ? `<span class="mrmd-statusbar__machine-pill" title="Active runtime machine — click to change">⚡ ${machineState.activeMachineName || 'Auto'}</span>`
1969
+ : '';
1970
+
1780
1971
  segment.innerHTML = `
1781
1972
  <span class="mrmd-statusbar__filename" style="font-weight: 500;">${filename}</span>
1782
- <span class="mrmd-statusbar__runtime-dot"
1783
- style="width: 10px; height: 10px; border-radius: 50%; background: ${dotColor}; cursor: pointer; ${runtimeState === 'error' ? 'animation: blink 1s infinite;' : ''}"
1784
- title="${dotTitle}"></span>
1973
+ <span style="display:inline-flex; align-items:center; gap:8px;">
1974
+ ${machinePill}
1975
+ <span class="mrmd-statusbar__runtime-dot"
1976
+ style="width: 10px; height: 10px; border-radius: 50%; background: ${dotColor}; cursor: pointer; ${runtimeState === 'error' ? 'animation: blink 1s infinite;' : ''}"
1977
+ title="${dotTitle}"></span>
1978
+ </span>
1785
1979
  `;
1786
1980
 
1787
1981
  // Add blink animation if needed
@@ -1794,8 +1988,10 @@ function createSimpleSegment({ shellState, orchestratorClient, handlers, onClean
1794
1988
  }
1795
1989
 
1796
1990
  async function openMenu(e) {
1797
- // Only open menu when clicking the dot
1798
- if (!e.target.classList.contains('mrmd-statusbar__runtime-dot')) {
1991
+ // Only open menu when clicking the dot or machine pill
1992
+ const isRuntimeDot = e.target.classList.contains('mrmd-statusbar__runtime-dot');
1993
+ const isMachinePill = e.target.classList.contains('mrmd-statusbar__machine-pill');
1994
+ if (!isRuntimeDot && !isMachinePill) {
1799
1995
  return;
1800
1996
  }
1801
1997
 
@@ -1832,6 +2028,12 @@ function createSimpleSegment({ shellState, orchestratorClient, handlers, onClean
1832
2028
  console.error('Failed to fetch runtimes/venvs:', err);
1833
2029
  }
1834
2030
 
2031
+ // Fetch machine state (if available in cloud mode)
2032
+ await refreshMachineState({ doRender: false });
2033
+ const machineList = machineState.machines || [];
2034
+ const activeMachineId = machineState.activeMachineId || null;
2035
+ const projectRoot = shellState.get('projectRoot');
2036
+
1835
2037
  // Section 1: Running Runtimes
1836
2038
  items.push({
1837
2039
  type: 'header',