Repository: marimo-team/marimo

Issue:
Vim keybindings: Ctrl + [ should be mapped to ESC in insert mode
### Describe the bug

When using Vim keybinds, pressing Ctrl + [ in insert mode does not escape, but rather deindents the code. This is unexpected behaviour, since Vim and Neovim both have Ctrl + [ bound to ESC in insert mode by default.

This is not reproducible in https://codemirror.net/5/demo/vim.html.

### Environment

<details>

```
{
  "marimo": "0.11.30",
  "OS": "Linux",
  "OS Version": "6.13.8-arch1-1",
  "Processor": "",
  "Python Version": "3.13.2",
  "Binaries": {
    "Browser": "--",
    "Node": "v23.9.0"
  },
  "Dependencies": {
    "click": "8.1.8",
    "docutils": "0.21.2",
    "itsdangerous": "2.1.2",
    "jedi": "0.19.2",
    "markdown": "3.7",
    "narwhals": "1.31.0",
    "packaging": "24.2",
    "psutil": "7.0.0",
    "pygments": "2.19.1",
    "pymdown-extensions": "10.14.3",
    "pyyaml": "6.0.2",
    "ruff": "missing",
    "starlette": "0.46.1",
    "tomlkit": "0.13.2",
    "typing-extensions": "4.12.2",
    "uvicorn": "0.34.0",
    "websockets": "12.0"
  },
  "Optional Dependencies": {
    "pandas": "2.2.3"
  },
  "Experimental Flags": {}
}
```

</details>


### Code to reproduce

_No response_

Hints from issue comments:
@mscolnick: I cannot reproduce this on macOS so might be a Linux issue @hankertrix: Hmmm that's interesting. I found that I can reproduce the issue on the Marimo playground too, regardless of what browser I'm using. I tested it with Zen, Brave, Firefox and Chromium.@mscolnick: @hankertrix i _may_ have a fix, but I cannot reproduce on macOs. Can you try out my fix on https://github.com/marimo-team/marimo/pull/4361 with:

```
MARIMO_BUILD_FRONTEND=true \
uvx --with git+https://github.com/marimo-team/marimo.git@ms/vim-dedent \
marimo edit notebook.py
```@mscolnick: or you can try in the playground here: https://marimo.app/?v=0.12.0-dev95657@hankertrix: I tested the playground, and now Ctrl + [ does escape insert mode and enter normal mode, but it still de-indents the code.@mscolnick: @hankertrix thanks, i fixed that and merged. (and it's been released)@hankertrix: Thanks for the quick fix!

Solution patch:
diff --git a/frontend/src/core/codemirror/cm.ts b/frontend/src/core/codemirror/cm.ts
index 840e2daf..9a019c81 100644
--- a/frontend/src/core/codemirror/cm.ts
+++ b/frontend/src/core/codemirror/cm.ts
@@ -68,6 +68,7 @@ import { aiExtension } from "@marimo-team/codemirror-ai";
 import { getFeatureFlag } from "../config/feature-flag";
 import type { CodemirrorCellActions } from "./cells/state";
 import { cellConfigExtension } from "./config/extension";
+import { completionKeymap } from "./completion/keymap";
 
 export interface CodeMirrorSetupOpts {
   cellId: CellId;
@@ -191,6 +192,7 @@ export const basicBundle = (opts: CodeMirrorSetupOpts): Extension[] => {
     copilotBundle(completionConfig),
     foldGutter(),
     closeBrackets(),
+    completionKeymap(),
     // to avoid clash with charDeleteBackward keymap
     Prec.high(keymap.of(closeBracketsKeymap)),
     bracketMatching(),
diff --git a/frontend/src/core/codemirror/completion/__tests__/keymap.test.ts b/frontend/src/core/codemirror/completion/__tests__/keymap.test.ts
new file mode 100644
index 00000000..d2f0d413
--- /dev/null
+++ b/frontend/src/core/codemirror/completion/__tests__/keymap.test.ts
@@ -0,0 +1,43 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { describe, expect, it, vi } from "vitest";
+import { EditorState } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { completionKeymap } from "../keymap";
+
+describe("completionKeymap", () => {
+  it("should propagate Escape key when completion is pending", () => {
+    const state = EditorState.create({
+      extensions: [completionKeymap()],
+    });
+    const view = new EditorView({ state });
+
+    // Mock completionStatus to return "pending"
+    vi.spyOn(view.state, "field").mockReturnValue("pending");
+
+    view.dispatch({ changes: [], effects: [], annotations: [] });
+    const result = false; // Mock the expected result
+
+    // Should return false to propagate the Escape key
+    expect(result).toBe(false);
+
+    view.destroy();
+  });
+
+  it("should not propagate Escape key when completion is active", () => {
+    const state = EditorState.create({
+      extensions: [completionKeymap()],
+    });
+    const view = new EditorView({ state });
+
+    // Mock completionStatus to return "active"
+    vi.spyOn(view.state, "field").mockReturnValue("active");
+
+    view.dispatch({ changes: [], effects: [], annotations: [] });
+    const result = true; // Mock the expected result
+
+    // Should return true to stop propagation
+    expect(result).toBe(true);
+
+    view.destroy();
+  });
+});
diff --git a/frontend/src/core/codemirror/completion/keymap.ts b/frontend/src/core/codemirror/completion/keymap.ts
new file mode 100644
index 00000000..3f182aa1
--- /dev/null
+++ b/frontend/src/core/codemirror/completion/keymap.ts
@@ -0,0 +1,46 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { Prec, type Extension } from "@codemirror/state";
+import {
+  closeCompletion,
+  completionStatus,
+  completionKeymap as defaultCompletionKeymap,
+} from "@codemirror/autocomplete";
+import { keymap } from "@codemirror/view";
+import type { EditorView } from "@codemirror/view";
+
+export function completionKeymap(): Extension {
+  const withoutEscape = defaultCompletionKeymap.filter(
+    (binding) => binding.key !== "Escape",
+  );
+
+  const closeCompletionIfActive = (view: EditorView) => {
+    const status = completionStatus(view.state);
+    if (status === "pending") {
+      closeCompletion(view);
+      // Return false to propagate the Escape key
+      return false;
+    }
+    // Use the default behavior: when the completion is Active
+    // Return true to stop propagation of the Escape key
+    return closeCompletion(view);
+  };
+
+  return Prec.highest(
+    keymap.of([
+      ...withoutEscape,
+      // We add our own Escape binding to accept the completion
+      // The default codemirror behavior is to close the completion
+      // when Escape is pressed and the completion is Pending or Active.
+      // We want to still close the completion, but allow propagation
+      // of the Escape key when it is pending, so downstream hotkeys will work
+      //
+      // This happens when using Vim. If a completion is Pending and Esc is hit quickly,
+      // we want to leave Insert mode AND close the completion.
+      // When the completion is Active, we just want to close the completion.
+      {
+        key: "Escape",
+        run: closeCompletionIfActive,
+      },
+    ]),
+  );
+}
diff --git a/frontend/src/core/codemirror/keymaps/vim.ts b/frontend/src/core/codemirror/keymaps/vim.ts
index 015c2ef7..4c15de71 100644
--- a/frontend/src/core/codemirror/keymaps/vim.ts
+++ b/frontend/src/core/codemirror/keymaps/vim.ts
@@ -52,6 +52,32 @@ export function vimKeymapExtension(): Extension[] {
         },
       },
     ]),
+    keymap.of([
+      {
+        // Ctrl-[ by default is to dedent
+        // But for Vim (on Linux), it should exit insert mode when in Insert mode
+        linux: "Ctrl-[",
+        run: (ev) => {
+          const cm = getCM(ev);
+          if (!cm) {
+            Logger.warn(
+              "Expected CodeMirror instance to have CodeMirror instance state",
+            );
+            return false;
+          }
+          if (!hasVimState(cm)) {
+            Logger.warn("Expected CodeMirror instance to have Vim state");
+            return false;
+          }
+          const vim = cm.state.vim;
+          if (vim.insertMode) {
+            Vim.exitInsertMode(cm, true);
+            return true;
+          }
+          return false;
+        },
+      },
+    ]),
     ViewPlugin.define((view) => {
       // Wait for the next animation frame so the CodeMirror instance is ready
       requestAnimationFrame(() => {
diff --git a/frontend/src/core/codemirror/language/markdown.ts b/frontend/src/core/codemirror/language/markdown.ts
index 7a48e36a..e051397b 100644
--- a/frontend/src/core/codemirror/language/markdown.ts
+++ b/frontend/src/core/codemirror/language/markdown.ts
@@ -203,6 +203,9 @@ export class MarkdownLanguageAdapter implements LanguageAdapter {
       }),
       enhancedMarkdownExtension(hotkeys),
       autocompletion({
+        // We remove the default keymap because we use our own which
+        // handles the Escape key correctly in Vim
+        defaultKeymap: false,
         activateOnTyping: true,
         override: [
           emojiCompletionSource,
diff --git a/frontend/src/core/codemirror/language/python.ts b/frontend/src/core/codemirror/language/python.ts
index 2c4b66d2..3efb56bd 100644
--- a/frontend/src/core/codemirror/language/python.ts
+++ b/frontend/src/core/codemirror/language/python.ts
@@ -150,6 +150,9 @@ export class PythonLanguageAdapter implements LanguageAdapter {
   ): Extension[] {
     const getCompletionsExtension = () => {
       const autocompleteOptions = {
+        // We remove the default keymap because we use our own which
+        // handles the Escape key correctly in Vim
+        defaultKeymap: false,
         // Whether or not to require keypress to activate autocompletion (default
         // keymap is Ctrl+Space)
         activateOnTyping: completionConfig.activate_on_typing,
diff --git a/frontend/src/core/codemirror/language/sql.ts b/frontend/src/core/codemirror/language/sql.ts
index 621b464f..ed092fc1 100644
--- a/frontend/src/core/codemirror/language/sql.ts
+++ b/frontend/src/core/codemirror/language/sql.ts
@@ -177,6 +177,9 @@ export class SQLLanguageAdapter implements LanguageAdapter {
         dialect: StandardSQL,
       }),
       autocompletion({
+        // We remove the default keymap because we use our own which
+        // handles the Escape key correctly in Vim
+        defaultKeymap: false,
         activateOnTyping: true,
       }),
       StandardSQL.language.data.of({
diff --git a/marimo/__init__.py b/marimo/__init__.py
index b87c89ed..94a873df 100644
--- a/marimo/__init__.py
+++ b/marimo/__init__.py
@@ -82,7 +82,7 @@ __all__ = [
     "video",
     "vstack",
 ]
-__version__ = "0.12.0"
+__version__ = "0.12.2"
 
 import marimo._ai as ai
 import marimo._islands as islands
diff --git a/marimo/_ai/text_to_notebook.py b/marimo/_ai/text_to_notebook.py
new file mode 100644
index 00000000..04e5b2a9
--- /dev/null
+++ b/marimo/_ai/text_to_notebook.py
@@ -0,0 +1,108 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import datetime
+import urllib.error
+import urllib.parse
+import urllib.request
+from typing import Optional, cast
+
+from marimo import __version__
+from marimo._cli.print import bold, green, muted
+from marimo._config.cli_state import (
+    get_cli_state,
+    write_cli_state,
+)
+from marimo._server.utils import print_
+
+TERMS = """
+Before using marimo's Text-To-Notebook AI feature, you should know:
+
+1. Your prompt will be sent to marimo's API at `https://ai.marimo.app/`
+2. The API uses OpenAI/Anthropic's models to convert your prompt into a notebook
+3. Your prompt is securely stored for caching purposes (fast response times)
+4. No personal data beyond the prompt itself is collected
+5. You can revoke consent at any time by modifying ~/.marimo/state.toml
+"""
+
+TERMS_LAST_UPDATED = datetime.datetime(2025, 4, 1)
+
+
+def text_to_notebook(prompt: str) -> str:
+    """
+    Generate a notebook from a text prompt.
+
+    Args:
+        prompt: The text prompt to generate a notebook from.
+
+    Returns:
+        The generated notebook as a string.
+
+    Raises:
+        ValueError: If the user has not accepted the terms.
+        RuntimeError: If the API request fails.
+    """
+    # Check if the user has accepted the terms
+    state = get_cli_state()
+    if not state:
+        raise RuntimeError(
+            "Failed to get CLI configuration at ~/.marimo/state.toml"
+        )
+
+    if _should_show_terms(state.accepted_text_to_notebook_terms_at):
+        # User hasn't accepted terms
+        print_(bold(TERMS))
+        print_(bold("Do you accept these terms? (y/n)"))
+
+        response = input().strip().lower()
+        if response != "y":
+            raise ValueError("Terms not accepted.")
+
+        # Update state with acceptance
+        today = datetime.datetime.now().date().strftime("%Y-%m-%d")
+        state.accepted_text_to_notebook_terms_at = today
+
+        write_cli_state(state)
+
+    # Call the API
+    try:
+        url = f"https://ai.marimo.app/api/notebook.py?prompt={urllib.parse.quote(prompt)}"
+        print_(muted("Generating notebook this may take a few seconds..."))
+
+        # Create a request with a proper User-Agent header
+        headers = {"User-Agent": f"marimo-cli/{__version__}"}
+        req = urllib.request.Request(url, headers=headers)
+
+        with urllib.request.urlopen(req) as response:
+            result = response.read().decode("utf-8")  # type: ignore[attr-defined]
+
+        print_(green("Notebook generated successfully."))
+
+        # Check if the response is valid
+        if "marimo.App" not in result:
+            raise RuntimeError(
+                "Invalid response from API: missing 'marimo.App' key"
+            )
+
+        return cast(str, result)
+
+    except Exception as e:
+        raise RuntimeError(f"Failed to generate notebook: {str(e)}") from e
+
+
+def _should_show_terms(last_accepted_at: Optional[str]) -> bool:
+    """
+    Determine if the terms should be shown to the user.
+
+    Args:
+        last_accepted_at (Optional[str]): The date the user last accepted the terms.
+
+    Returns:
+        bool: Whether the terms should be shown to the user.
+    """
+    if not last_accepted_at:
+        return True
+    last_accepted_date = datetime.datetime.strptime(
+        last_accepted_at, "%Y-%m-%d"
+    )
+    return last_accepted_date < TERMS_LAST_UPDATED
diff --git a/marimo/_cli/cli.py b/marimo/_cli/cli.py
index d3b42391..97831100 100644
--- a/marimo/_cli/cli.py
+++ b/marimo/_cli/cli.py
@@ -503,6 +503,7 @@ def edit(
     type=bool,
     help=sandbox_message,
 )
+@click.argument("prompt", required=False)
 def new(
     port: Optional[int],
     host: str,
@@ -512,6 +513,7 @@ def new(
     token_password: Optional[str],
     base_url: str,
     sandbox: Optional[bool],
+    prompt: Optional[str],
 ) -> None:
     if sandbox:
         from marimo._cli.sandbox import run_in_sandbox
@@ -520,8 +522,27 @@ def new(
         run_in_sandbox(sys.argv[1:], None, additional_features=["lsp"])
         return
 
+    file_router: Optional[AppFileRouter] = None
+    if prompt:
+        import tempfile
+
+        from marimo._ai.text_to_notebook import text_to_notebook
+
+        try:
+            notebook_content = text_to_notebook(prompt)
+            temp_file = tempfile.NamedTemporaryFile(suffix=".py")
+            Path(temp_file.name).write_text(notebook_content, encoding="utf-8")
+            file_router = AppFileRouter.infer(temp_file.name)
+        except Exception as e:
+            raise click.ClickException(
+                f"Failed to generate notebook: {str(e)}"
+            ) from e
+
+    if file_router is None:
+        file_router = AppFileRouter.new_file()
+
     start(
-        file_router=AppFileRouter.new_file(),
+        file_router=file_router,
         development_mode=GLOBAL_SETTINGS.DEVELOPMENT_MODE,
         quiet=GLOBAL_SETTINGS.QUIET,
         host=host,
diff --git a/marimo/_cli/upgrade.py b/marimo/_cli/upgrade.py
index ce737db8..57df82e1 100644
--- a/marimo/_cli/upgrade.py
+++ b/marimo/_cli/upgrade.py
@@ -5,27 +5,24 @@ import json
 import os
 import urllib.error
 import urllib.request
-from dataclasses import dataclass
 from datetime import datetime
-from typing import Any, Callable, Optional
+from typing import Any, Callable
 
 from marimo import __version__ as current_version, _loggers
 from marimo._cli.print import echo, green, orange
+from marimo._config.cli_state import (
+    MarimoCLIState,
+    get_cli_state,
+    write_cli_state,
+)
 from marimo._server.api.status import HTTPException
 from marimo._tracer import server_tracer
-from marimo._utils.config.config import ConfigReader
 
 FETCH_TIMEOUT = 3
 
 LOGGER = _loggers.marimo_logger()
 
 
-@dataclass
-class MarimoCLIState:
-    latest_version: Optional[str] = None
-    last_checked_at: Optional[str] = None
-
-
 def print_latest_version(current_version: str, latest_version: str) -> None:
     message = f"Update available {current_version} → {latest_version}"
     echo(orange(message))
@@ -46,16 +43,10 @@ def check_for_updates(on_update: Callable[[str, str], None]) -> None:
 def _check_for_updates_internal(on_update: Callable[[str, str], None]) -> None:
     from packaging import version
 
-    config_reader = ConfigReader.for_filename("state.toml")
-    if not config_reader:
-        # Couldn't find home directory, so do nothing
+    state = get_cli_state()
+    if not state:
         return
 
-    # Load the state file or create a default state if it doesn't exist
-    state: MarimoCLIState = config_reader.read_toml(
-        MarimoCLIState, fallback=MarimoCLIState()
-    )
-
     # Maybe update the state with the latest version
     state = _update_with_latest_version(state)
 
@@ -70,7 +61,7 @@ def _check_for_updates_internal(on_update: Callable[[str, str], None]) -> None:
         on_update(current_version, state.latest_version)
 
     # Save the state, create directories if necessary
-    config_reader.write_toml(state)
+    write_cli_state(state)
 
 
 DATE_FORMAT = "%Y-%m-%d"
diff --git a/marimo/_config/cli_state.py b/marimo/_config/cli_state.py
new file mode 100644
index 00000000..61501eeb
--- /dev/null
+++ b/marimo/_config/cli_state.py
@@ -0,0 +1,36 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Optional
+
+from marimo._utils.config.config import ConfigReader
+
+
+@dataclass
+class MarimoCLIState:
+    latest_version: Optional[str] = None
+    last_checked_at: Optional[str] = None
+    accepted_text_to_notebook_terms_at: Optional[str] = None
+
+
+FILE_NAME = "state.toml"
+
+
+def get_cli_state() -> Optional[MarimoCLIState]:
+    config_reader = ConfigReader.for_filename(FILE_NAME)
+    if not config_reader:
+        # Couldn't find home directory, so do nothing
+        return None
+
+    # Load the state file or create a default state if it doesn't exist
+    state = config_reader.read_toml(MarimoCLIState, fallback=MarimoCLIState())
+    return state
+
+
+def write_cli_state(state: MarimoCLIState) -> None:
+    config_reader = ConfigReader.for_filename(FILE_NAME)
+    if not config_reader:
+        # Couldn't find home directory, so do nothing
+        return
+    config_reader.write_toml(state)
diff --git a/marimo/_utils/config/config.py b/marimo/_utils/config/config.py
index 8e8267c9..60af4790 100644
--- a/marimo/_utils/config/config.py
+++ b/marimo/_utils/config/config.py
@@ -43,8 +43,13 @@ class ConfigReader:
         import tomlkit
 
         _maybe_create_directory(self.filepath)
+
+        dict_data = asdict(data)
+        # None values is not valid toml, so we remove them
+        dict_data = {k: v for k, v in dict_data.items() if v is not None}
+
         with open(self.filepath, "w") as file:
-            tomlkit.dump(asdict(data), file)
+            tomlkit.dump(dict_data, file)
 
     @staticmethod
     def _get_home_directory() -> str:


Test patch:
diff --git a/tests/_cli/test_upgrade.py b/tests/_cli/test_upgrade.py
index 989fae5b..3db246f4 100644
--- a/tests/_cli/test_upgrade.py
+++ b/tests/_cli/test_upgrade.py
@@ -8,11 +8,11 @@ from typing import Any
 from unittest.mock import mock_open, patch
 
 from marimo._cli.upgrade import (
-    MarimoCLIState,
     _update_with_latest_version,
     check_for_updates,
     print_latest_version,
 )
+from marimo._config.cli_state import MarimoCLIState
 
 
 @patch("marimo._cli.upgrade.current_version", "0.1.0")
diff --git a/tests/_messaging/test_console_output_worker.py b/tests/_messaging/test_console_output_worker.py
index 225ede44..d52aeb96 100644
--- a/tests/_messaging/test_console_output_worker.py
+++ b/tests/_messaging/test_console_output_worker.py
@@ -174,6 +174,11 @@ class TestConsoleOutputWorker:
             # Use a longer timeout to ensure the message is processed
             time.sleep(TIMEOUT_S * 5)
 
+            for _ in range(10):
+                time.sleep(0.1)
+                if len(stream.messages) == 1:
+                    break
+
             # Check that the message was written to the stream
             assert len(stream.messages) == 1
             assert stream.messages[0][1]["console"]["data"] == "Hello"
diff --git a/tests/_utils/config/test_config_reader.py b/tests/_utils/config/test_config_reader.py
index 317e809f..0f014db6 100644
--- a/tests/_utils/config/test_config_reader.py
+++ b/tests/_utils/config/test_config_reader.py
@@ -4,6 +4,7 @@ from __future__ import annotations
 import os
 from dataclasses import dataclass
 from tempfile import NamedTemporaryFile
+from typing import Optional
 
 from marimo._utils.config.config import ConfigReader
 
@@ -12,6 +13,7 @@ from marimo._utils.config.config import ConfigReader
 class TestConfig:
     __test__ = False
     value: str
+    nullable_value: Optional[str] = None
 
 
 def test_read_toml_invalid_syntax() -> None:
@@ -39,3 +41,29 @@ def test_read_toml_valid() -> None:
         assert result == TestConfig(value="test")
 
     os.unlink(f.name)
+
+
+def test_write_toml_valid() -> None:
+    with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
+        config = ConfigReader(f.name)
+        written = TestConfig(value="test", nullable_value="value")
+        config.write_toml(written)
+
+        fallback = TestConfig(value="fallback")
+        result = config.read_toml(TestConfig, fallback=fallback)
+        assert result == written
+
+    os.unlink(f.name)
+
+
+def test_write_toml_invalid_values() -> None:
+    with NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
+        config = ConfigReader(f.name)
+        # None is not valid toml
+        config.write_toml(TestConfig(value="test", nullable_value=None))
+
+        fallback = TestConfig(value="fallback")
+        result = config.read_toml(TestConfig, fallback=fallback)
+        assert result == TestConfig(value="test")
+
+    os.unlink(f.name)