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
135 changes: 134 additions & 1 deletion libs/code/deepagents_code/_textual_patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,30 @@
`alt+enter`. Tracked in Textualize/textual#6378. Remove this patch and
the Textual pin comment in `pyproject.toml` when that lands.

2. Double-click word selection. Stock Textual selects the entire widget on
2. Kitty lock-key and sub-field handling. Two related problems with the
pinned Textual parser:

a. Lock keys (Caps Lock / Num Lock / Scroll Lock) must never produce
text, but terminals encode them inconsistently. kitty/Ghostty/VS Code
send the functional key code (`CSI 57358 ... u`) with associated text
set to the letter the *next* key would have produced. iTerm2 instead
reports the Caps Lock toggle as a bare upper-case ASCII letter (`CSI
65 u` → 'A') with no modifier or associated-text field — not a valid
encoding for a real key press per the kitty spec. Either way the chat
input would type a stray capital. The patch collapses both forms to a
single character-less `caps_lock` event, regardless of the modifier,
associated-text, or event-type sub-fields the terminal includes.

b. `_re_extended_key` only accepts `;`-separated numeric fields, so any
*non-lock* kitty sequence carrying `:`-separated sub-fields — alternate
keys (`unicode:shifted:base`) or an event-type (`modifiers:event`) —
fails to match and is re-emitted one byte at a time as literal text.
The patch strips the `:` sub-fields before Textual parses the sequence
so it resolves to a single key event.

Remove when the pinned Textual neutralizes lock keys and widens its parser.

3. Double-click word selection. Stock Textual selects the entire widget on
a click chain; these patches narrow a double-click (and double-click
drag) to word boundaries. No upstream issue tracks this yet, so it has
no removal criterion — it stays until Textual grows native word select.
Expand All @@ -24,6 +47,7 @@
from __future__ import annotations

import logging
import re
from inspect import isawaitable
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -60,6 +84,99 @@
except (ImportError, AttributeError) as exc: # pragma: no cover - defensive
logger.warning("Textual keyboard parser patch skipped: %s", exc)
else:
# Kitty functional key codes for the lock keys (Caps Lock, Scroll Lock,
# Num Lock). The kitty protocol assigns these Private Use Area codepoints;
# they appear as the leading key-code field of a `CSI ... u` sequence.
_KITTY_LOCK_KEY_CODES = frozenset({"57358", "57359", "57360"})
_KITTY_LOCK_KEY_NAMES = {
"57358": "caps_lock",
"57359": "scroll_lock",
"57360": "num_lock",
}

# Any `CSI <code>[:...][;...] u` sequence. Group 1 is the leading key-code
# field (before any `:` alternate-key sub-field); `_lock_key_event` checks
# it against the lock-key set. The match is deliberately broad so the code
# is extracted regardless of the modifier / associated-text / event-type
# sub-fields that follow, which iTerm2 and other terminals encode in
# varying shapes.
_KITTY_KEY_SEQUENCE = re.compile(r"\x1b\[(\d+)[\d;:]*u")

# Kitty extended-key sequence carrying `:` sub-fields (alternate keys or an
# event-type sub-field). The pinned Textual's `_re_extended_key` rejects the
# colons, so non-lock keys with these sub-fields would otherwise leak as
# literal text — strip the sub-fields so they parse to a single key event.
_KITTY_SUBFIELD_KEY = re.compile(r"\x1b\[[\d;:]*:[\d;:]*[u~ABCDEFHPQRS]")

# iTerm2 reports the Caps Lock toggle as a `CSI u` sequence whose primary
# key code is the *uppercase* ASCII letter that would be produced next
# (e.g. `CSI 65 u` → 'A'), with no real modifier bits and no associated
# text. The kitty spec requires the primary code to be the unshifted
# (lower-case) code point, so a bare upper-case letter here is iTerm2's
# Caps Lock artifact rather than a real key press. Group 1 is the code
# point; group 2 the optional modifier field; group 3 the optional text.
_KITTY_CSI_U = re.compile(
r"\x1b\[(\d+)(?::\d+)*(?:;(\d+)[\d:]*)?(?:;(\d+)[\d:]*)?u"
)
_ASCII_UPPER_A = 65
_ASCII_UPPER_Z = 90
# Modifier mask for the "real" modifiers (shift|alt|ctrl|super|hyper|meta);
# excludes the caps_lock (64) and num_lock (128) lock bits.
_REAL_MODIFIER_MASK = 0b111111

def _spurious_caps_lock(sequence: str) -> bool:
"""Whether `sequence` is iTerm2's bare Caps Lock toggle report.

Matches a `CSI u` key whose primary code point is an upper-case ASCII
letter with no real modifiers and no associated-text field — which the
kitty spec never produces for a genuine key press.

Returns:
`True` if `sequence` is the spurious Caps Lock toggle report.
"""
match = _KITTY_CSI_U.fullmatch(sequence)
if match is None:
return False
code = int(match.group(1))
if not _ASCII_UPPER_A <= code <= _ASCII_UPPER_Z:
return False
modifier_bits = (int(match.group(2)) - 1) if match.group(2) else 0
has_text = match.group(3) is not None
return modifier_bits & _REAL_MODIFIER_MASK == 0 and not has_text

def _strip_kitty_subfields(sequence: str) -> str:
"""Drop `:` sub-fields from a kitty extended-key sequence.

Keeps the primary value of each `;`-separated field (the unicode key
code, modifier mask, and associated text), which is all Textual reads.

Returns:
The sequence with every `:` sub-field removed.
"""
body, terminator = sequence[2:-1], sequence[-1]
primary = ";".join(field.split(":", 1)[0] for field in body.split(";"))
return f"\x1b[{primary}{terminator}"

def _lock_key_event(sequence: str) -> events.Key | None:
"""Return a text-free lock-key event for a kitty lock-key sequence.

Lock keys must never produce text. Under the kitty protocol with
associated-text reporting, terminals (notably iTerm2) encode Caps
Lock as a `CSI 57358 ... u` sequence whose associated-text field is
the letter the *next* key would have produced — Textual then either
types that letter or, when `:` sub-fields are present, leaks the raw
sequence byte by byte. Collapsing any lock-key sequence to a single
character-less event stops both failure modes at the source, for
every widget.

Returns:
A `Key` event for the lock key, or `None` if `sequence` is not a
kitty lock-key sequence.
"""
match = _KITTY_KEY_SEQUENCE.fullmatch(sequence)
if match is None or match.group(1) not in _KITTY_LOCK_KEY_CODES:
return None
return events.Key(_KITTY_LOCK_KEY_NAMES[match.group(1)], None)

def _emit_alt(keys: tuple, character: str | None) -> Iterable[events.Key]:
for key in keys:
Expand All @@ -68,6 +185,22 @@ def _emit_alt(keys: tuple, character: str | None) -> Iterable[events.Key]:
def _sequence_to_key_events_with_alt(
self: XTermParser, sequence: str, alt: bool = False
) -> Iterable[events.Key]:
# Lock keys (Caps Lock / Num Lock / Scroll Lock) must never type. Emit
# a single character-less event regardless of how the terminal encoded
# the modifiers, associated text, or event-type sub-fields.
if (lock_event := _lock_key_event(sequence)) is not None:
yield lock_event
return
# iTerm2 reports the Caps Lock toggle as a bare upper-case letter (e.g.
# `CSI 65 u` → 'A') rather than the kitty `57358` functional code. Drop
# it so the toggle never types a stray capital into the input.
if _spurious_caps_lock(sequence):
yield events.Key("caps_lock", None)
return
# Normalize any other kitty sequence with `:` sub-fields so it resolves
# to a single key event instead of leaking raw bytes.
if _KITTY_SUBFIELD_KEY.fullmatch(sequence):
sequence = _strip_kitty_subfields(sequence)
# Fast path: \x1b<byte> on first pass. Short-circuits the ~100 ms
# escape-delay wait when both bytes arrive together. Semantic side
# effect: \x1b\x1b dispatches as `alt+escape` with no delay, matching
Expand Down
27 changes: 27 additions & 0 deletions libs/code/deepagents_code/widgets/chat_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ def _default_history_path() -> Path:
return DEFAULT_STATE_DIR / "history.jsonl"


_LOCK_KEYS = frozenset({"caps_lock", "num_lock", "scroll_lock"})
"""Lock keys that must never insert text.

Under the kitty keyboard protocol with associated-text reporting (VS Code's
xterm.js and others), pressing a lock key arrives as a `Key` event whose
`character` is the text that *would* have been produced by the next key —
e.g. pressing Caps Lock reports `key='caps_lock'`, `character='A'`. Textual's
parser does not strip this, so `TextArea` inserts a stray letter. We drop
these events entirely. Terminals encode lock keys in several shapes (iTerm2
notably differs from kitty/Ghostty); `_textual_patches.py` is the canonical
reference and neutralizes every shape at the parser. See the kitty keyboard
protocol spec (functional key definitions) for background.
"""

_PASTE_BURST_CHAR_GAP_SECONDS = 0.03
"""Maximum time between chars to treat input as a paste-like burst."""

Expand Down Expand Up @@ -803,6 +817,19 @@ def _delete_preceding_backslash(self) -> bool:

async def _on_key(self, event: events.Key) -> None:
"""Handle key events."""
# Lock keys (Caps Lock, Num Lock, Scroll Lock) must never type. The
# kitty parser patch in `_textual_patches.py` already neutralizes these
# at the source; this is defense-in-depth in case a lock key still
# arrives with associated text (e.g. if that patch failed to install or
# a future terminal bypasses it). Note this only shields the chat input
# — if the parser patch silently no-ops, other widgets stay broken. The
# key may carry modifier prefixes (e.g. 'ctrl+caps_lock'), so match on
# the final '+'-delimited token.
if event.key.rsplit("+", 1)[-1] in _LOCK_KEYS:
event.prevent_default()
event.stop()
return

# VS Code 1.110 incorrectly sends space as a CSI u escape code
# (`\x1b[32u`) instead of a plain ` ` character. Textual parses
# this as Key(key='space', character=None, is_printable=False), so
Expand Down
41 changes: 41 additions & 0 deletions libs/code/tests/unit_tests/test_chat_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -2663,6 +2663,47 @@ async def test_normal_space_still_works(self) -> None:
assert ta.text == "hello "


class TestLockKeysDoNotType:
"""Lock keys must never insert text.

Under the kitty keyboard protocol with associated-text reporting (iTerm2,
VS Code's xterm.js, etc.), pressing Caps Lock arrives as
Key(key='caps_lock', character='A'), which would otherwise make TextArea
insert a stray letter.
"""

@pytest.mark.parametrize(
"lock_key",
[
"caps_lock",
"num_lock",
"scroll_lock",
# Modifier-prefixed variants: the lock bit can arrive alongside
# other modifier bits, so the key string is suffixed.
"ctrl+caps_lock",
"alt+ctrl+hyper+meta+super+caps_lock",
],
)
async def test_lock_key_with_associated_text_inserts_nothing(
self, lock_key: str
) -> None:
"""A lock-key event carrying associated text should insert nothing."""
app = _ChatInputTestApp()
async with app.run_test() as pilot:
chat = app.query_one(ChatInput)
ta = chat._text_area
assert ta is not None

ta.insert("hello")
await pilot.pause()

# iTerm2/kitty protocol reports the would-be text as `character`.
await ta._on_key(events.Key(lock_key, "A"))
await pilot.pause()

assert ta.text == "hello"


class TestCtrlUDeleteToLineStart:
"""Test that ctrl+u deletes from cursor to start of line (readline convention)."""

Expand Down
115 changes: 115 additions & 0 deletions libs/code/tests/unit_tests/test_textual_patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import importlib.util
from pathlib import Path

import pytest
from textual._time import get_time
from textual._xterm_parser import XTermParser
from textual.app import App, ComposeResult
Expand Down Expand Up @@ -120,6 +121,120 @@ def test_fast_path_falls_through_when_inner_byte_unmapped(self) -> None:
"""
assert _keys_for("\x1bZ", alt=False) == []

@pytest.mark.parametrize(
("sequence", "key"),
[
# Plain press, no associated text.
("\x1b[57358u", "caps_lock"),
# Conformant flags-25 form: modifier + associated text.
("\x1b[57358;1;65u", "caps_lock"),
# Lock bit set in the modifier mask.
("\x1b[57358;65;65u", "caps_lock"),
# Other modifier bits set alongside the lock key.
("\x1b[57358;64;65u", "caps_lock"),
# Alternate-key sub-field (iTerm2): `unicode:shifted`.
("\x1b[57358:65;1;65u", "caps_lock"),
# Event-type sub-field on the modifier field.
("\x1b[57358;1:1;65u", "caps_lock"),
# Num Lock and Scroll Lock use the same encoding family.
("\x1b[57360;1;65u", "num_lock"),
("\x1b[57359;1;65u", "scroll_lock"),
],
)
def test_kitty_lock_keys_never_carry_text(self, sequence: str, key: str) -> None:
r"""Lock keys must decode to a single character-less event.

Under the kitty protocol with associated-text reporting, terminals
(notably iTerm2) encode Caps Lock with the letter the next key would
have produced. Without the patch Textual either types that letter or,
when `:` sub-fields are present, leaks the raw sequence byte by byte.
The patch collapses every lock-key sequence to a text-free event.
"""
assert _keys_for(sequence, alt=False) == [(key, None)]

def test_kitty_subfield_strip_preserves_normal_keys(self) -> None:
r"""Alternate-key sub-fields on text keys still decode to the key.

`CSI 97:65;1;65u` is the `a` key with shifted alternate `A`; only the
primary code point and associated text matter to Textual. This guards
against the sub-field strip swallowing real characters.
"""
assert _keys_for("\x1b[97:65;1;65u", alt=False) == [("A", "A")]

@pytest.mark.parametrize(
("sequence", "key"),
[
# `~`-terminated sequence (Delete) with an event-type `:` sub-field.
("\x1b[3:3~", "delete"),
# Cursor key (letter terminator) with a `:` sub-field on the
# modifier field.
("\x1b[1;5:1C", "ctrl+right"),
],
)
def test_kitty_subfield_strip_handles_non_u_terminators(
self, sequence: str, key: str
) -> None:
r"""Sub-field stripping covers `~` and letter terminators, not just `u`.

`_KITTY_SUBFIELD_KEY` matches terminators `[u~ABCDEFHPQRS]`, so F-keys,
arrows, and Insert/Delete carrying `:` sub-fields are normalized rather
than leaked byte by byte. Every other test ends in `u`; this pins the
non-`u` paths against a regex regression that would reintroduce the
very byte-by-byte leak this patch exists to fix.
"""
assert _keys_for(sequence, alt=False) == [(key, None)]

@pytest.mark.parametrize(
"sequence",
[
# iTerm2 Caps Lock toggle: bare upper-case code point, no fields.
"\x1b[65u",
# With an explicit "no modifiers" field (value 1).
"\x1b[65;1u",
# Upper-case letters across the ASCII range.
"\x1b[90u",
# Caps-lock bit present in the modifier mask, still no text.
"\x1b[67;65u",
],
)
def test_iterm_caps_lock_toggle_inserts_nothing(self, sequence: str) -> None:
r"""iTerm2's bare upper-case Caps Lock report must not type.

iTerm2 encodes the Caps Lock toggle as the upper-case letter that
would be produced next (`CSI 65 u` → 'A') rather than the kitty
functional code, with no associated-text field. The kitty spec never
emits an upper-case primary code point for a real press, so the patch
treats it as the lock toggle and drops the character.
"""
assert _keys_for(sequence, alt=False) == [("caps_lock", None)]

@pytest.mark.parametrize(
("sequence", "expected"),
[
# Lower-case letters are always real text.
("\x1b[97u", [("a", "a")]),
# Shift+A reported as lower-case primary + shift modifier.
("\x1b[97;2u", [("shift+a", None)]),
# Upper-case primary WITH associated text is a real character
# (e.g. caps-on typing): the text field disambiguates it.
("\x1b[65;1;65u", [("A", "A")]),
("\x1b[67;65;67u", [("C", "C")]),
# Upper-case primary with a real modifier (ctrl) and no text is a
# genuine press — the `_REAL_MODIFIER_MASK` guard must not drop it.
("\x1b[65;5u", [("ctrl+A", None)]),
],
)
def test_iterm_caps_lock_guard_preserves_real_keys(
self, sequence: str, expected: list[tuple[str, str | None]]
) -> None:
r"""The Caps Lock guard must not swallow genuine key presses.

Only a bare upper-case primary code point with no real modifiers and
no associated text is treated as the toggle; everything else decodes
normally.
"""
assert _keys_for(sequence, alt=False) == expected


def test_app_imports_textual_patches_for_side_effect() -> None:
"""`app.py` must import `_textual_patches` for the patch to install.
Expand Down
Loading