Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,15 @@
_STEADY_CURSOR = None

try:
from hermes_cli.pt_input_extras import install_shift_enter_alias, install_ctrl_enter_alias
from hermes_cli.pt_input_extras import (
install_ctrl_enter_alias,
install_ignored_terminal_sequences,
install_shift_enter_alias,
)
install_shift_enter_alias()
install_ctrl_enter_alias()
del install_shift_enter_alias, install_ctrl_enter_alias
install_ignored_terminal_sequences()
del install_shift_enter_alias, install_ctrl_enter_alias, install_ignored_terminal_sequences
except Exception:
pass
import threading
Expand Down Expand Up @@ -12717,7 +12722,21 @@ def run(self):

# Key bindings for the input area
kb = KeyBindings()


from prompt_toolkit.keys import Keys as _IgnoreKeys

@kb.add(_IgnoreKeys.Ignore, eager=True)
def handle_ignored_terminal_sequence(event):
"""Consume parser-level ignored terminal sequences before self-insert.

install_ignored_terminal_sequences() in hermes_cli.pt_input_extras
registers focus reports (CSI I / CSI O) as Keys.Ignore at the
VT100 parser level. Without this no-op binding the default
self-insert path would still fire and the bytes would land in
the buffer.
"""
return None

def handle_enter(event):
"""Handle Enter key - submit input.

Expand Down
37 changes: 37 additions & 0 deletions hermes_cli/pt_input_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,40 @@ def install_ctrl_enter_alias() -> int:
ANSI_SEQUENCES[seq] = alt_enter
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
the input buffer.

Currently covers focus reports:
- ``\\x1b[I`` — terminal regained focus (focus in)
- ``\\x1b[O`` — terminal lost focus (focus out)

Ghostty, iTerm2, and some xterm builds can emit these sequences when
the user switches tabs / windows or when a multiplexer toggles focus
tracking upstream. prompt_toolkit does not map these by default, so
its parser falls back to literal key presses (ESC, ``[``, ``I``/``O``)
and inserts ``[I``/``[O`` into the prompt buffer after the ESC byte
is handled.

Registering them as ``Keys.Ignore`` is parser-level — strictly
cleaner than post-hoc regex stripping in the input sanitizer because
the bytes never reach the buffer. ``setdefault`` is used so any user
or downstream registration wins.

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

changed = 0
for seq in ("\x1b[I", "\x1b[O"):
if seq not in ANSI_SEQUENCES:
ANSI_SEQUENCES[seq] = Keys.Ignore
changed += 1
return changed
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@
"130975919+kylekahraman@users.noreply.github.com": "kylekahraman",
"seppe@fushia.be": "seppegadeyne",
"18264851+seppegadeyne@users.noreply.github.com": "seppegadeyne",
"blackpilledsoftware@gmail.com": "blackpilledsoftware-prog",
"266800570+blackpilledsoftware-prog@users.noreply.github.com": "blackpilledsoftware-prog",
"dsr-restyn@users.noreply.github.com": "dsr-restyn",
"210765158+WuKongAI-CMU@users.noreply.github.com": "WuKongAI-CMU",
"lichriszhang@gmail.com": "codeblackhole1024",
Expand Down
49 changes: 49 additions & 0 deletions tests/cli/test_cli_terminal_shortcuts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Regression tests for terminal navigation/focus escape sequences.

Ghostty/macOS window and tab navigation can deliver terminal focus reports
(CSI I / CSI O) to the running TUI. These must be consumed by the input parser,
not inserted into the prompt buffer and cleaned up later.
"""

from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.keys import Keys

from hermes_cli.pt_input_extras import install_ignored_terminal_sequences


def _parse_keys(data: str):
events = []
parser = Vt100Parser(events.append)
parser.feed_and_flush(data)
return [(event.key, event.data) for event in events]


def test_focus_events_are_parser_level_ignored_before_prompt_buffer():
install_ignored_terminal_sequences()

assert _parse_keys("\x1b[O\x1b[Ihello") == [
(Keys.Ignore, "\x1b[O"),
(Keys.Ignore, "\x1b[I"),
("h", "h"),
("e", "e"),
("l", "l"),
("l", "l"),
("o", "o"),
]


def test_regular_escape_shortcuts_still_parse_normally():
install_ignored_terminal_sequences()

assert _parse_keys("\x1bg") == [(Keys.Escape, "\x1b"), ("g", "g")]


def test_install_is_idempotent_and_setdefault_safe():
"""Second call should return 0 (no new mappings); existing user
registrations must not be overwritten."""
first = install_ignored_terminal_sequences()
second = install_ignored_terminal_sequences()
# At most first should be 2 (both CSI I + CSI O), second always 0
# since the entries are now present.
assert second == 0
assert first in (0, 1, 2) # 0 if a prior test in same process already installed
Loading