mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
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:
commit
9d75c418ad
5 changed files with 199 additions and 3 deletions
4
cli.py
4
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
tests/cli/test_cli_cmd_backspace.py
Normal file
79
tests/cli/test_cli_cmd_backspace.py
Normal 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
|
||||
46
ui-tui/src/__tests__/textInputLineKill.test.ts
Normal file
46
ui-tui/src/__tests__/textInputLineKill.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue