Merge pull request #74693 from NousResearch/bb/cmd-backspace

fix: Cmd+Backspace and Cmd+ForwardDelete reach the readline kill bindings
This commit is contained in:
brooklyn! 2026-07-30 04:31:48 -05:00 committed by GitHub
commit 9d75c418ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 199 additions and 3 deletions

4
cli.py
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
})
})

View file

@ -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 {