Repository: marimo-team/marimo
Pull request: Hotkey for splitting cells
Fixes: https://github.com/marimo-team/marimo/issues/1372

The issue mentioned using Ctrl+Shift+- as the hotkey, but that seemed to have some interaction with zooming in the browser.

I have the hotkey set to Mod+Shift+' for now; if you'd like to change that, I'm happy to do so!

Also, tested in the browser but could not make unit tests for the reducer due to the dependency on the editor view. I have added a TODO to find a solution to this.

Solved with the help of Glide. Check out the full solution here!
https://glide.agenticlabs.com/sharing?userId=65bae76b7fbfb1c20ac1f3b8&taskId=miIvJdg

Problem statement:
Split Cell Hotkey
### Description

Many of our users have asked for the ability to split cells using a hotkey. This would be a great first issue.

### Suggested solution

A hotkey to split a cell at the current line into two cells, similar to Jupyter. Maybe `Ctrl+Shift+-`.

### Alternative

_No response_

### Additional context

_No response_

Hints from issue discussion:

Commit message: Hotkey for splitting cells

Patch to review:
diff --git a/frontend/src/components/editor/cell/code/cell-editor.tsx b/frontend/src/components/editor/cell/code/cell-editor.tsx
index b04c53d8763..e38f226df17 100644
--- a/frontend/src/components/editor/cell/code/cell-editor.tsx
+++ b/frontend/src/components/editor/cell/code/cell-editor.tsx
@@ -91,7 +91,7 @@ const CellEditorInternal = ({
   const editorViewParentRef = useRef<HTMLDivElement>(null);
 
   const loading = status === "running" || status === "queued";
-  const { sendToTop, sendToBottom } = useCellActions();
+  const { splitCell, sendToTop, sendToBottom } = useCellActions();
 
   const handleDelete = useEvent(() => {
     // Cannot delete running cells, since we're waiting for their output.
@@ -154,6 +154,7 @@ const CellEditorInternal = ({
         focusDown,
         sendToTop,
         sendToBottom,
+        splitCell,
         moveToNextCell,
         toggleHideCode,
         aiCellCompletion: () => {
@@ -214,6 +215,7 @@ const CellEditorInternal = ({
     moveToNextCell,
     sendToTop,
     sendToBottom,
+    splitCell,
     toggleHideCode,
     updateCellCode,
     handleDelete,
diff --git a/frontend/src/core/cells/cells.ts b/frontend/src/core/cells/cells.ts
index 396d58ce81b..232a23df3fd 100644
--- a/frontend/src/core/cells/cells.ts
+++ b/frontend/src/core/cells/cells.ts
@@ -33,6 +33,10 @@ import { historyField } from "@codemirror/commands";
 import { clamp } from "@/utils/math";
 import { LayoutState } from "../layout/layout";
 import { notebookIsRunning } from "./utils";
+import {
+  getEditorCodeAsPython,
+  updateEditorCodeFromPython,
+} from "../codemirror/language/utils";
 
 /**
  * The state of the notebook.
@@ -603,6 +607,70 @@ const {
       cellLogs: [],
     };
   },
+  splitCell: (state, action: { cellId: CellId; cursorPos: number }) => {
+    const { cellId, cursorPos } = action;
+    const index = state.cellIds.indexOf(cellId);
+    const cell = state.cellData[cellId];
+    const cellHandle = state.cellHandles[cellId].current;
+
+    if (cellHandle?.editorView == null) {
+      // TODO: Because of this we can't do  the reducer tests like for the other functions
+      return state;
+    }
+
+    // Figure out if we're at the start or end of a line to adjust the cursor positions
+    const isCursorAtLineStart = cell.code.length > 0 && cell.code[cursorPos-1] === "\n";
+    const isCursorAtLineEnd = cell.code.length > 0 && cell.code[cursorPos] === "\n";
+
+    const beforeAdjustedCursorPos = isCursorAtLineStart ? cursorPos - 1 : cursorPos
+    const afterAdjustedCursorPos = isCursorAtLineEnd ? cursorPos + 1 : cursorPos;
+
+    const beforeCursorCode = getEditorCodeAsPython(
+      cellHandle.editorView,
+      0,
+      beforeAdjustedCursorPos,
+    );
+    const afterCursorCode = getEditorCodeAsPython(
+      cellHandle.editorView,
+      afterAdjustedCursorPos,
+    );
+
+    updateEditorCodeFromPython(cellHandle.editorView, beforeCursorCode);
+
+    const newCellId = CellId.create();
+
+    return {
+      ...state,
+      cellIds: arrayInsert(state.cellIds, index + 1, newCellId),
+      cellData: {
+        ...state.cellData,
+        [cellId]: {
+          ...cell,
+          code: beforeCursorCode,
+          edited: beforeCursorCode.trim() !== cell.lastCodeRun,
+        },
+        [newCellId]: createCell({
+          id: newCellId,
+          code: afterCursorCode,
+          edited: Boolean(afterCursorCode)
+        }),
+      },
+      cellRuntime: {
+        ...state.cellRuntime,
+        [cellId]: {
+          ...state.cellRuntime[cellId],
+          output: null,
+          consoleOutputs: [],
+        },
+        [newCellId]: createCellRuntimeState(),
+      },
+      cellHandles: {
+        ...state.cellHandles,
+        [newCellId]: createRef(),
+      },
+      scrollKey: newCellId,
+    };
+  },
 });
 
 // Helper function to update a cell in the array
diff --git a/frontend/src/core/codemirror/__tests__/setup.test.ts b/frontend/src/core/codemirror/__tests__/setup.test.ts
index e4047fa955f..c03433c8354 100644
--- a/frontend/src/core/codemirror/__tests__/setup.test.ts
+++ b/frontend/src/core/codemirror/__tests__/setup.test.ts
@@ -34,6 +34,7 @@ function setup(config: Partial<CodeMirrorSetupOpts> = {}): Extension[] {
       focusDown: namedFunction("focusDown"),
       sendToTop: namedFunction("sendToTop"),
       sendToBottom: namedFunction("sendToBottom"),
+      splitCell: namedFunction("splitCell"),
       moveToNextCell: namedFunction("moveToNextCell"),
       toggleHideCode: namedFunction("toggleHideCode"),
     },
diff --git a/frontend/src/core/codemirror/cells/extensions.ts b/frontend/src/core/codemirror/cells/extensions.ts
index 59f152bc9fc..8c9b230ad88 100644
--- a/frontend/src/core/codemirror/cells/extensions.ts
+++ b/frontend/src/core/codemirror/cells/extensions.ts
@@ -11,7 +11,10 @@ import { closeCompletion, completionStatus } from "@codemirror/autocomplete";
 import { isAtEndOfEditor, isAtStartOfEditor } from "../utils";
 
 export interface MovementCallbacks
-  extends Pick<CellActions, "sendToTop" | "sendToBottom" | "moveToNextCell"> {
+  extends Pick<
+    CellActions,
+    "splitCell" | "sendToTop" | "sendToBottom" | "moveToNextCell"
+  > {
   onRun: () => void;
   deleteCell: () => void;
   createAbove: () => void;
@@ -42,6 +45,7 @@ export function cellMovementBundle(
     focusDown,
     sendToTop,
     sendToBottom,
+    splitCell,
     moveToNextCell,
     toggleHideCode,
     aiCellCompletion,
@@ -236,6 +240,21 @@ export function cellMovementBundle(
         return true;
       },
     },
+    {
+      key: HOTKEYS.getHotkey("cell.splitCell").key,
+      preventDefault: true,
+      stopPropagation: true,
+      run: (ev) => {
+        const cursorPos = ev.state.selection.main.head;
+        splitCell({ cellId, cursorPos }),
+          requestAnimationFrame(() => {
+            ev.contentDOM.blur();
+            moveToNextCell({ cellId, before: false }); // focus new cell
+          });
+
+        return true;
+      },
+    },
   ];
 
   // Highest priority so that we can override the default keymap
diff --git a/frontend/src/core/codemirror/language/utils.ts b/frontend/src/core/codemirror/language/utils.ts
index ca9366f23ad..72d79b6446b 100644
--- a/frontend/src/core/codemirror/language/utils.ts
+++ b/frontend/src/core/codemirror/language/utils.ts
@@ -6,9 +6,17 @@ import { languageAdapterState } from "./extension";
  * Get the editor code as Python
  * Handles when the editor has a different language adapter
  */
-export function getEditorCodeAsPython(editor: EditorView): string {
+export function getEditorCodeAsPython(
+  editor: EditorView,
+  fromPos?: number,
+  toPos?: number,
+): string {
   const languageAdapter = editor.state.field(languageAdapterState);
-  return languageAdapter.transformOut(editor.state.doc.toString())[0];
+  const editorText = editor.state.doc.toString();
+  if (fromPos !== undefined) {
+    return languageAdapter.transformOut(editorText.slice(fromPos, toPos))[0];
+  }
+  return languageAdapter.transformOut(editorText)[0];
 }
 
 export function updateEditorCodeFromPython(
diff --git a/frontend/src/core/hotkeys/hotkeys.ts b/frontend/src/core/hotkeys/hotkeys.ts
index 2cf214cfc2d..987fc3b8721 100644
--- a/frontend/src/core/hotkeys/hotkeys.ts
+++ b/frontend/src/core/hotkeys/hotkeys.ts
@@ -183,6 +183,11 @@ const DEFAULT_HOT_KEY = {
     group: "Editing",
     key: "Mod-p",
   },
+  "cell.splitCell": {
+    name: "Split cell",
+    group: "Editing",
+    key: "Mod-Shift-'",
+  },
 
   // Global Actions
   "global.hideCode": {


Code review comments:
diff_hunk: @@ -236,6 +240,21 @@ export function cellMovementBundle(
         return true;
       },
     },
+    {
+      key: HOTKEYS.getHotkey("cell.splitCell").key,
+      preventDefault: true,
+      stopPropagation: true,
+      run: (ev) => {
+        const cursorPos = ev.state.selection.main.head;
+        splitCell({ cellId, cursorPos }),
path: frontend/src/core/codemirror/cells/extensions.ts
text: @user1:
i think you want a `;` at the end of the line: `splitCell({ cellId, cursorPos });`