diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 8e78655f1..8d34d6333 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -124,7 +124,7 @@ By default, up to 4 background tasks can run simultaneously. This can be adjuste ## Multi-line input -Sometimes you need to enter multiple lines, such as pasting a code snippet or error log. Press `Ctrl-J` or `Alt-Enter` to insert a newline instead of sending the message immediately. +Sometimes you need to enter multiple lines, such as pasting a code snippet or error log. Press `Shift+Enter`, `Ctrl-J`, or `Alt-Enter` to insert a newline instead of sending the message immediately. After finishing your input, press `Enter` to send the complete message. diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index a12f79738..1bd1cbdad 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -9,8 +9,9 @@ Kimi Code CLI shell mode supports the following keyboard shortcuts. | `Ctrl-X` | Toggle agent/shell mode | | `Shift-Tab` | Toggle plan mode (read-only research and planning) | | `Ctrl-O` | Edit in external editor (`$VISUAL`/`$EDITOR`) | -| `Ctrl-J` | Insert newline | -| `Alt-Enter` | Insert newline (same as `Ctrl-J`) | +| `Shift-Enter` | Insert newline | +| `Ctrl-J` | Insert newline (same as `Shift+Enter`) | +| `Alt-Enter` | Insert newline (same as `Shift+Enter`) | | `Ctrl-S` | Steer: inject input immediately into the running turn (during streaming) | | `Ctrl-V` | Paste (supports images and video files) | | `Ctrl-E` | Expand full approval request content | @@ -60,10 +61,11 @@ Useful for writing multi-line prompts, complex code snippets, etc. ## Multi-line input -### `Ctrl-J` / `Alt-Enter`: Insert newline +### `Shift+Enter` / `Ctrl-J` / `Alt-Enter`: Insert newline By default, pressing `Enter` submits the input. To enter multi-line content, use: +- `Shift+Enter`: Insert newline at any position - `Ctrl-J`: Insert newline at any position - `Alt-Enter`: Insert newline at any position diff --git a/src/kimi_cli/ui/shell/prompt.py b/src/kimi_cli/ui/shell/prompt.py index b7a950b09..a4fcfff22 100644 --- a/src/kimi_cli/ui/shell/prompt.py +++ b/src/kimi_cli/ui/shell/prompt.py @@ -35,6 +35,7 @@ from prompt_toolkit.filters import Condition, has_completions, has_focus, is_done from prompt_toolkit.formatted_text import AnyFormattedText, FormattedText, to_formatted_text from prompt_toolkit.history import InMemoryHistory +from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent from prompt_toolkit.keys import Keys from prompt_toolkit.layout.containers import ( @@ -72,6 +73,20 @@ from kimi_cli.utils.slashcmd import SlashCommand from kimi_cli.wire.types import ContentPart +# Register the xterm modifyOtherKeys ANSI sequences for modified Enter keys. +# This is safer than the kitty keyboard protocol because it only affects +# a narrow set of modified keys and does not change how other shortcuts +# (Ctrl-X, Ctrl-O, Ctrl-V, etc.) are encoded. +_SHIFT_ENTER_SEQ = "\x1b[27;2;13~" # Shift+Enter +_ALT_ENTER_SEQ = "\x1b[27;3;13~" # Alt+Enter +# Map modified Enter sequences to private-use characters so we can bind +# them independently of the plain Enter key. Using Keys.ControlM would +# force them through the same handler that is gated by ~has_completions, +# breaking newline insertion when the completion menu is open. +for _seq, _key in ((_SHIFT_ENTER_SEQ, "\ue001"), (_ALT_ENTER_SEQ, "\ue000")): + if ANSI_SEQUENCES.get(_seq) != _key: + ANSI_SEQUENCES[_seq] = _key # type: ignore[assignment] + AttachmentCache = prompt_placeholders.AttachmentCache CachedAttachment = prompt_placeholders.CachedAttachment _parse_attachment_kind = prompt_placeholders.parse_attachment_kind @@ -1157,7 +1172,7 @@ def _build_toolbar_tips(clipboard_available: bool) -> list[str]: "ctrl-x: toggle mode", "shift-tab: plan mode", "ctrl-o: editor", - "ctrl-j: newline", + "shift-enter / ctrl-j: newline", "/feedback: send feedback", "/theme: switch dark/light", ] @@ -1312,10 +1327,24 @@ async def _toggle() -> None: event.app.create_background_task(_toggle()) event.app.invalidate() + @_kb.add("enter", eager=True, filter=~has_completions) + def _(event: KeyPressEvent) -> None: + """Submit when plain Enter is pressed and no completion menu is open.""" + event.current_buffer.validate_and_handle() + @_kb.add("escape", "enter", eager=True) @_kb.add("c-j", eager=True) + @_kb.add("\ue000", eager=True) + @_kb.add("\ue001", eager=True) def _(event: KeyPressEvent) -> None: - """Insert a newline when Alt-Enter or Ctrl-J is pressed.""" + """Insert a newline when Alt-Enter, Shift+Enter, or Ctrl-J is pressed. + + The \ue000 binding catches Alt+Enter from terminals that emit the + xterm modifyOtherKeys sequence \x1b[27;3;13~ instead of escape+enter. + The \ue001 binding catches Shift+Enter from terminals that emit + \x1b[27;2;13~. Both are unfiltered so they work even when the + completion menu is open. + """ from kimi_cli.telemetry import track track("shortcut_newline") @@ -1514,6 +1543,14 @@ def _(event: KeyPressEvent) -> None: bottom_toolbar=self._render_bottom_toolbar, style=get_prompt_style(), ) + + # Enable xterm modifyOtherKeys so terminals that support it can + # distinguish Shift+Enter from plain Enter. + try: + self._session.app.output.write_raw("\x1b[>4;1m") + self._session.app.output.flush() + except Exception: + pass self._session.default_buffer.read_only = Condition( lambda: ( (delegate := self._active_prompt_delegate()) is not None @@ -1880,6 +1917,15 @@ def __exit__(self, *_) -> None: self._status_refresh_task.cancel() self._status_refresh_task = None + # Disable xterm modifyOtherKeys on exit. + try: + session = getattr(self, "_session", None) + if session is not None: + session.app.output.write_raw("\x1b[>4;0m") + session.app.output.flush() + except Exception: + pass + def _get_placeholder_manager(self) -> PromptPlaceholderManager: manager = getattr(self, "_placeholder_manager", None) if manager is None: diff --git a/tests/e2e/shell_pty_helpers.py b/tests/e2e/shell_pty_helpers.py index 8be0bf446..9c4503fcf 100644 --- a/tests/e2e/shell_pty_helpers.py +++ b/tests/e2e/shell_pty_helpers.py @@ -147,7 +147,10 @@ def send_key(self, key: str) -> None: "right": b"\x1b[C", "ctrl_c": b"\x03", "ctrl_d": b"\x04", + "alt_enter": b"\x1b[27;3;13~", + "ctrl_j": b"\x0a", "ctrl_x": b"\x18", + "s_enter": b"\x1b[27;2;13~", } payload = key_map.get(key) if payload is None: diff --git a/tests/e2e/test_shell_pty_e2e.py b/tests/e2e/test_shell_pty_e2e.py index 503ad10b2..685cc3841 100644 --- a/tests/e2e/test_shell_pty_e2e.py +++ b/tests/e2e/test_shell_pty_e2e.py @@ -368,6 +368,66 @@ def test_shell_ctrl_c_from_idle_prompt_after_completed_turn_shows_tip(tmp_path: shell.close() +def test_shell_shift_enter_inserts_newline(tmp_path: Path) -> None: + """Shift+Enter should insert a newline instead of submitting the message.""" + config_path = write_scripted_config(tmp_path, ["text: First turn finished."]) + work_dir = make_work_dir(tmp_path) + home_dir = make_home_dir(tmp_path) + shell = start_shell_pty( + config_path=config_path, + work_dir=work_dir, + home_dir=home_dir, + yolo=True, + ) + + try: + shell.read_until_contains("Welcome to Kimi Code CLI!") + _read_until_prompt(shell, after=shell.mark()) + + turn_mark = shell.mark() + shell.send_text("hello") + shell.send_key("s_enter") + shell.send_text("world") + shell.send_key("enter") + shell.read_until_contains("First turn finished.", after=turn_mark, timeout=15.0) + prompt_mark = shell.mark() + _read_until_prompt(shell, after=prompt_mark) + + assert list_turn_begin_inputs(home_dir, work_dir) == ["hello\nworld"] + finally: + shell.close() + + +def test_shell_alt_enter_inserts_newline(tmp_path: Path) -> None: + """Alt+Enter (xterm modifyOtherKeys sequence) should insert a newline.""" + config_path = write_scripted_config(tmp_path, ["text: First turn finished."]) + work_dir = make_work_dir(tmp_path) + home_dir = make_home_dir(tmp_path) + shell = start_shell_pty( + config_path=config_path, + work_dir=work_dir, + home_dir=home_dir, + yolo=True, + ) + + try: + shell.read_until_contains("Welcome to Kimi Code CLI!") + _read_until_prompt(shell, after=shell.mark()) + + turn_mark = shell.mark() + shell.send_text("hello") + shell.send_key("alt_enter") + shell.send_text("world") + shell.send_key("enter") + shell.read_until_contains("First turn finished.", after=turn_mark, timeout=15.0) + prompt_mark = shell.mark() + _read_until_prompt(shell, after=prompt_mark) + + assert list_turn_begin_inputs(home_dir, work_dir) == ["hello\nworld"] + finally: + shell.close() + + def test_shell_question_roundtrip_with_other_answer(tmp_path: Path) -> None: question_payload = [ { diff --git a/tests/ui_and_conv/test_prompt_shift_enter.py b/tests/ui_and_conv/test_prompt_shift_enter.py new file mode 100644 index 000000000..ce4e51fc8 --- /dev/null +++ b/tests/ui_and_conv/test_prompt_shift_enter.py @@ -0,0 +1,48 @@ +"""Tests for Shift+Enter newline support in the interactive prompt.""" + +from __future__ import annotations + +from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + +# Import the module under test so its module-level side effects run. +import kimi_cli.ui.shell.prompt as _prompt_module + + +class TestShiftEnterAnsiSequences: + """Verify that modified Enter ANSI sequences are registered at import time.""" + + def test_xterm_modifyotherkeys_shift_enter_registered(self) -> None: + """Shift+Enter is mapped to a private-use character so it can be bound + independently of the plain Enter key. This preserves newline insertion + even when the completion menu is open. + """ + seq = "\x1b[27;2;13~" + assert seq in ANSI_SEQUENCES + assert ANSI_SEQUENCES[seq] == "\ue001" + + def test_xterm_modifyotherkeys_alt_enter_registered(self) -> None: + """Alt+Enter is mapped to a private-use character so it can be bound + independently of the plain Enter key. This preserves the existing + behaviour where Alt+Enter inserts a newline even when the completion + menu is open. + """ + seq = "\x1b[27;3;13~" + assert seq in ANSI_SEQUENCES + assert ANSI_SEQUENCES[seq] == "\ue000" + + def test_kitty_sequence_is_not_registered(self) -> None: + """The kitty keyboard protocol is intentionally not enabled because + it changes encoding for many modified keys and prompt_toolkit only + recognises a subset of CSI-u sequences. Using it would risk breaking + shortcuts like Ctrl-X, Ctrl-O and Ctrl-V on kitty-protocol terminals. + """ + assert "\x1b[13;2u" not in ANSI_SEQUENCES + + def test_sequences_are_idempotent_on_reimport(self) -> None: + """Re-importing the module must not raise or change the mapping.""" + import importlib + + importlib.reload(_prompt_module) + + assert ANSI_SEQUENCES["\x1b[27;2;13~"] == "\ue001" + assert ANSI_SEQUENCES["\x1b[27;3;13~"] == "\ue000" diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index fc322804b..ad6190050 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -140,7 +140,7 @@ def test_build_toolbar_tips_without_clipboard() -> None: "ctrl-x: toggle mode", "shift-tab: plan mode", "ctrl-o: editor", - "ctrl-j: newline", + "shift-enter / ctrl-j: newline", "/feedback: send feedback", "/theme: switch dark/light", "@: mention files", @@ -152,7 +152,7 @@ def test_build_toolbar_tips_with_clipboard() -> None: "ctrl-x: toggle mode", "shift-tab: plan mode", "ctrl-o: editor", - "ctrl-j: newline", + "shift-enter / ctrl-j: newline", "/feedback: send feedback", "/theme: switch dark/light", "ctrl-v: paste clipboard",