diff --git a/cli.py b/cli.py index a4ce0cdd56a..c1d5ada5562 100644 --- a/cli.py +++ b/cli.py @@ -77,14 +77,16 @@ except (ImportError, AttributeError): try: from hermes_cli.pt_input_extras import ( + install_cmd_backspace_alias, install_ctrl_enter_alias, install_ignored_terminal_sequences, install_shift_enter_alias, ) install_shift_enter_alias() install_ctrl_enter_alias() + install_cmd_backspace_alias() install_ignored_terminal_sequences() - del install_shift_enter_alias, install_ctrl_enter_alias, install_ignored_terminal_sequences + del install_shift_enter_alias, install_ctrl_enter_alias, install_cmd_backspace_alias, install_ignored_terminal_sequences except Exception: pass import threading diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index 16a0f17ea43..2efd5d8bc3b 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -83,6 +83,49 @@ def install_ctrl_enter_alias() -> int: return changed +def install_cmd_backspace_alias() -> int: + """Map Cmd+Backspace / Cmd+ForwardDelete to the readline kill bindings + prompt_toolkit already ships (``unix-line-discard`` / ``kill-line``). + + Terminals that rewrite Cmd+Backspace to Ctrl+U (``\\x15``) already work. + Kitty keyboard protocol and xterm modifyOtherKeys terminals instead + report Cmd as the *super* modifier bit (8), producing sequences + prompt_toolkit does not map — the raw bytes then fall through to + literal insertion. + + Cmd+Backspace → ``Keys.ControlU`` (kill backward to start of line). + Codepoint 127 with modifier 9 (super) / 10 (super+shift): + - ``\\x1b[127;9u`` / ``\\x1b[127;10u`` — Kitty CSI-u + - ``\\x1b[27;9;127~`` — xterm modifyOtherKeys + + Cmd+ForwardDelete → ``Keys.ControlK`` (kill to end of line). The + forward-delete key is a CSI *tilde* key, not a CSI-u codepoint, so the + modifier rides in the standard ``CSI 3 ; mod ~`` form: + - ``\\x1b[3;9~`` / ``\\x1b[3;10~`` + + Returns the number of sequences whose mapping was changed. + """ + try: + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + from prompt_toolkit.keys import Keys + except Exception: + return 0 + + aliases = { + "\x1b[127;9u": Keys.ControlU, + "\x1b[127;10u": Keys.ControlU, + "\x1b[27;9;127~": Keys.ControlU, + "\x1b[3;9~": Keys.ControlK, + "\x1b[3;10~": Keys.ControlK, + } + changed = 0 + for seq, key in aliases.items(): + if ANSI_SEQUENCES.get(seq) != key: + ANSI_SEQUENCES[seq] = key + changed += 1 + return changed + + def install_ignored_terminal_sequences() -> int: """Map terminal-emitted noise sequences to ``Keys.Ignore`` so they are consumed by the VT100 parser before they reach key bindings or diff --git a/tests/cli/test_cli_cmd_backspace.py b/tests/cli/test_cli_cmd_backspace.py new file mode 100644 index 00000000000..d9ea60a6c6c --- /dev/null +++ b/tests/cli/test_cli_cmd_backspace.py @@ -0,0 +1,79 @@ +"""Verify Cmd+Backspace / Cmd+ForwardDelete byte sequences from CSI-u +terminals reach prompt_toolkit's readline kill bindings instead of leaking +into the buffer as literal text. +""" + +from __future__ import annotations + +import pytest + +from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + +from hermes_cli.pt_input_extras import install_cmd_backspace_alias + + +# Cmd rides as the super modifier bit (8), so modifier = 9 (super) or +# 10 (super+shift). +CMD_BACKSPACE_SEQUENCES = ( + "\x1b[127;9u", # Kitty CSI-u + "\x1b[127;10u", # Kitty CSI-u, +shift + "\x1b[27;9;127~", # xterm modifyOtherKeys +) + +# Forward-delete is a CSI tilde key, not a CSI-u codepoint. +CMD_FWD_DELETE_SEQUENCES = ( + "\x1b[3;9~", + "\x1b[3;10~", +) + + +@pytest.fixture(autouse=True) +def _ensure_alias_installed(): + install_cmd_backspace_alias() + + +def _parse(byte_seq: str): + out = [] + parser = Vt100Parser(out.append) + for ch in byte_seq: + parser.feed(ch) + parser.flush() + return [kp.key for kp in out] + + +@pytest.mark.parametrize("seq", CMD_BACKSPACE_SEQUENCES) +def test_cmd_backspace_parses_as_ctrl_u(seq): + """Cmd+Backspace must reach unix-line-discard, exactly as Ctrl+U does.""" + assert _parse(seq) == _parse("\x15") + + +@pytest.mark.parametrize("seq", CMD_FWD_DELETE_SEQUENCES) +def test_cmd_forward_delete_parses_as_ctrl_k(seq): + """Cmd+ForwardDelete must reach kill-line, exactly as Ctrl+K does.""" + assert _parse(seq) == _parse("\x0b") + + +@pytest.mark.parametrize("seq", CMD_BACKSPACE_SEQUENCES + CMD_FWD_DELETE_SEQUENCES) +def test_sequences_emit_exactly_one_keypress(seq): + """The whole sequence is consumed. A partial match would emit Escape + plus the remainder as literal text — the bug this alias exists to fix.""" + assert len(_parse(seq)) == 1 + + +def test_install_is_idempotent(): + install_cmd_backspace_alias() + assert install_cmd_backspace_alias() == 0 + + +def test_unmodified_keys_keep_their_own_bindings(): + """Aliasing the Cmd variants must not disturb the bare keys.""" + assert ANSI_SEQUENCES["\x1b[3~"] == Keys.Delete + assert ANSI_SEQUENCES["\x7f"] == Keys.ControlH + + +def test_ctrl_forward_delete_is_not_remapped(): + """Ctrl+ForwardDelete (modifier 5) is delete-word on Linux/Windows and + must keep prompt_toolkit's own binding, not become kill-line.""" + assert ANSI_SEQUENCES["\x1b[3;5~"] == Keys.ControlDelete diff --git a/ui-tui/src/__tests__/textInputLineKill.test.ts b/ui-tui/src/__tests__/textInputLineKill.test.ts new file mode 100644 index 00000000000..09c22f7466f --- /dev/null +++ b/ui-tui/src/__tests__/textInputLineKill.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { isLineKillModifier } from '../components/textInput.js' + +// Cmd+Backspace should kill to the line boundary, but the modifier it is +// distinguished by matters a great deal. `isActionMod` is the wrong test: +// - on macOS it accepts `key.meta`, and hermes-ink reports Option as +// `meta` — so Option+Backspace (delete-word, the macOS standard) would +// silently become "delete the whole line". +// - on Linux/Windows it is `key.ctrl`, and Ctrl+Backspace is delete-word +// there in readline, VS Code, browsers, and Windows Terminal. +// Only the kitty CSI-u / modifyOtherKeys `super` bit means Cmd. + +const key = (over: Partial<{ ctrl: boolean; meta: boolean; super: boolean }> = {}) => ({ + ctrl: false, + meta: false, + super: false, + ...over +}) + +describe('isLineKillModifier', () => { + it('accepts the super bit (Cmd via kitty CSI-u / modifyOtherKeys)', () => { + expect(isLineKillModifier(key({ super: true }))).toBe(true) + }) + + it('accepts super even when the terminal also sets a benign ctrl bit', () => { + // VS Code/Cursor forward Cmd chords as CSI-u with super + ctrl set. + expect(isLineKillModifier(key({ ctrl: true, super: true }))).toBe(true) + }) + + it('rejects meta so Option+Backspace stays delete-word on macOS', () => { + expect(isLineKillModifier(key({ meta: true }))).toBe(false) + }) + + it('rejects ctrl so Ctrl+Backspace stays delete-word on Linux/Windows', () => { + expect(isLineKillModifier(key({ ctrl: true }))).toBe(false) + }) + + it('rejects an unmodified keypress', () => { + expect(isLineKillModifier(key())).toBe(false) + }) + + it('treats a missing super field as absent rather than truthy', () => { + expect(isLineKillModifier({ ctrl: false, meta: false })).toBe(false) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 9d35bbdf133..6fafe03b57b 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -321,6 +321,24 @@ export function resolveCursorLayout(display: string, cur: number, curRefCurrent: return cursorLayout(display, curRefCurrent, columns) } +/** + * True when a Backspace / ForwardDelete keystroke should kill to the line + * boundary rather than delete a single word. + * + * Only the *super* bit qualifies. It is tempting to reuse `isActionMod`, + * but that accepts `key.meta` on macOS — and hermes-ink reports Option as + * `meta`, so Option+Backspace (delete-word, the macOS standard) would be + * swallowed. On Linux/Windows `isActionMod` is `key.ctrl`, and + * Ctrl+Backspace is delete-word there too. `super` is set only by kitty + * CSI-u / xterm modifyOtherKeys, where it unambiguously means Cmd. + * + * Terminals that instead rewrite Cmd+Backspace to Ctrl+U are handled by + * the `isMacActionFallback` kill-to-start path, not by this predicate. + */ +export function isLineKillModifier(key: { ctrl: boolean; meta: boolean; super?: boolean }): boolean { + return key.super === true +} + /** * Pure computation for the fast-echo backspace bypass: given the * current value/cursor (already validated by `canFastBackspaceShape`), @@ -1198,7 +1216,12 @@ export function TextInput({ v = v.slice(0, range.start) + v.slice(range.end) c = range.start } else if (k.backspace && c > 0) { - if (wordMod) { + if (isLineKillModifier(k)) { + // Cmd+Backspace — kill backward to start of line, matching the + // Ctrl+U (unix-line-discard) path below. + v = v.slice(c) + c = 0 + } else if (wordMod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) c = t @@ -1222,7 +1245,10 @@ export function TextInput({ c = t } } else if (delFwd && c < v.length) { - if (wordMod) { + if (isLineKillModifier(k)) { + // Cmd+ForwardDelete — kill to end of line, matching Ctrl+K. + v = v.slice(0, c) + } else if (wordMod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t) } else {