mrmd-editor 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-editor",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Markdown editor with realtime collaboration - the core editor package",
5
5
  "type": "module",
6
6
  "main": "dist/mrmd.cjs",
package/src/execution.js CHANGED
@@ -737,6 +737,9 @@ export class ExecutionManager {
737
737
  // Emit start event
738
738
  this._emit('cellRun', index, cell, execId);
739
739
 
740
+ // Direct execution output write throttling (reduces CRDT churn for progress bars)
741
+ let clearChunkFlushTimer = null;
742
+
740
743
  try {
741
744
  // Prepare output position
742
745
  // Re-read content as it may have changed
@@ -815,22 +818,11 @@ export class ExecutionManager {
815
818
 
816
819
  // Track current output length in document for replacement
817
820
  let currentDocOutputLen = 0;
821
+ const chunkFlushMs = 100;
822
+ let chunkFlushTimer = null;
823
+ let latestProcessedOutput = '';
818
824
 
819
- const onChunk = (chunk, accumulatedRaw, done) => {
820
- if (controller.signal.aborted) return;
821
-
822
- // Process through terminal buffer (handles \r, cursor movement, ANSI)
823
- buffer.write(chunk);
824
-
825
- // Get processed output with ANSI codes preserved
826
- let processedOutput = buffer.toAnsi();
827
-
828
- // Ensure output ends with newline so closing ``` stays on its own line
829
- // This is critical for maintaining valid markdown structure
830
- if (processedOutput && !processedOutput.endsWith('\n')) {
831
- processedOutput += '\n';
832
- }
833
-
825
+ const applyProcessedOutput = (processedOutput) => {
834
826
  // Get current position - prefer finding by execId for robustness
835
827
  let currentOutputStart = outputContentStart;
836
828
 
@@ -865,6 +857,61 @@ export class ExecutionManager {
865
857
  });
866
858
 
867
859
  currentDocOutputLen = processedOutput.length;
860
+ };
861
+
862
+ const flushChunkOutputNow = () => {
863
+ if (chunkFlushTimer) {
864
+ clearTimeout(chunkFlushTimer);
865
+ chunkFlushTimer = null;
866
+ }
867
+ applyProcessedOutput(latestProcessedOutput);
868
+ };
869
+
870
+ clearChunkFlushTimer = () => {
871
+ if (chunkFlushTimer) {
872
+ clearTimeout(chunkFlushTimer);
873
+ chunkFlushTimer = null;
874
+ }
875
+ };
876
+
877
+ const scheduleChunkOutputFlush = () => {
878
+ if (chunkFlushMs === 0) {
879
+ flushChunkOutputNow();
880
+ return;
881
+ }
882
+ if (chunkFlushTimer) return;
883
+ chunkFlushTimer = setTimeout(() => {
884
+ chunkFlushTimer = null;
885
+ applyProcessedOutput(latestProcessedOutput);
886
+ }, chunkFlushMs);
887
+ };
888
+
889
+ controller.signal.addEventListener('abort', () => {
890
+ clearChunkFlushTimer?.();
891
+ }, { once: true });
892
+
893
+ const onChunk = (chunk, accumulatedRaw, done) => {
894
+ if (controller.signal.aborted) return;
895
+
896
+ // Process through terminal buffer (handles \r, cursor movement, ANSI)
897
+ buffer.write(chunk);
898
+
899
+ // Get processed output with ANSI codes preserved
900
+ let processedOutput = buffer.toAnsi();
901
+
902
+ // Ensure output ends with newline so closing ``` stays on its own line
903
+ // This is critical for maintaining valid markdown structure
904
+ if (processedOutput && !processedOutput.endsWith('\n')) {
905
+ processedOutput += '\n';
906
+ }
907
+
908
+ latestProcessedOutput = processedOutput;
909
+
910
+ if (done) {
911
+ flushChunkOutputNow();
912
+ } else {
913
+ scheduleChunkOutputFlush();
914
+ }
868
915
 
869
916
  this._emit('cellOutput', index, chunk, processedOutput, execId);
870
917
  };
@@ -883,6 +930,9 @@ export class ExecutionManager {
883
930
  return;
884
931
  }
885
932
 
933
+ // Flush pending output so prompt context is visible immediately
934
+ flushChunkOutputNow();
935
+
886
936
  const stdinExecId = execId;
887
937
 
888
938
  // Wait for any pending output to be written to the document
@@ -1009,6 +1059,9 @@ export class ExecutionManager {
1009
1059
  onAsset,
1010
1060
  });
1011
1061
 
1062
+ // Flush any pending throttled output before final normalization
1063
+ flushChunkOutputNow();
1064
+
1012
1065
  // Final update - find output block by execId for robustness
1013
1066
  content = this.editor.getContent();
1014
1067
  const finalOutputBlock = findOutputBlockByExecId(content, execId);
@@ -1127,6 +1180,7 @@ export class ExecutionManager {
1127
1180
  }
1128
1181
  return execId;
1129
1182
  } finally {
1183
+ clearChunkFlushTimer?.();
1130
1184
  this.running.delete(execId);
1131
1185
  this.buffers.delete(execId);
1132
1186
 
package/src/index.js CHANGED
@@ -791,6 +791,50 @@ class Writer {
791
791
  }
792
792
  // #endregion WRITER
793
793
 
794
+ // #region INITIAL_CURSOR
795
+ /**
796
+ * Find the ideal initial cursor position for a markdown document.
797
+ *
798
+ * When opening a file, placing the cursor at position 0 shows raw frontmatter
799
+ * YAML which looks ugly. Instead, we find the first empty line after any
800
+ * frontmatter block — this causes the frontmatter to render as a nice widget
801
+ * and gives a clean first impression.
802
+ *
803
+ * @param {string} content - Document content
804
+ * @returns {number} Character position for the cursor
805
+ */
806
+ function findInitialCursorPosition(content) {
807
+ if (!content) return 0;
808
+
809
+ const lines = content.split('\n');
810
+ let i = 0;
811
+
812
+ // Skip YAML frontmatter if present (--- ... ---)
813
+ if (lines[0]?.trim() === '---') {
814
+ i = 1;
815
+ while (i < lines.length && lines[i]?.trim() !== '---') {
816
+ i++;
817
+ }
818
+ if (i < lines.length) i++; // skip closing ---
819
+ }
820
+
821
+ // Find first empty line from current position
822
+ while (i < lines.length) {
823
+ if (lines[i]?.trim() === '') {
824
+ // Calculate character position (start of this empty line)
825
+ let pos = 0;
826
+ for (let j = 0; j < i; j++) {
827
+ pos += lines[j].length + 1; // +1 for \n
828
+ }
829
+ return pos;
830
+ }
831
+ i++;
832
+ }
833
+
834
+ return 0; // fallback to start
835
+ }
836
+ // #endregion INITIAL_CURSOR
837
+
794
838
  // #region CREATE
795
839
  /**
796
840
  * Create a standalone markdown editor
@@ -3076,6 +3120,7 @@ const mrmd = {
3076
3120
  create,
3077
3121
  drive,
3078
3122
  runtime,
3123
+ findInitialCursorPosition,
3079
3124
  yjs,
3080
3125
  codemirror,
3081
3126
  terminal,
@@ -3134,6 +3179,7 @@ export {
3134
3179
  create,
3135
3180
  drive,
3136
3181
  runtime,
3182
+ findInitialCursorPosition,
3137
3183
  yjs,
3138
3184
  codemirror,
3139
3185
  terminal,
@@ -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();
@@ -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',