Skip to content

Fix modified arrow and page keys resolving to the opposite direction - #2093

Open
despotak wants to merge 1 commit into
prompt-toolkit:mainfrom
despotak:fix/transposed-modified-arrow-keys
Open

Fix modified arrow and page keys resolving to the opposite direction#2093
despotak wants to merge 1 commit into
prompt-toolkit:mainfrom
despotak:fix/transposed-modified-arrow-keys

Conversation

@despotak

@despotak despotak commented Sep 6, 2026

Copy link
Copy Markdown

Fixes #2092.

Ten entries in ANSI_SEQUENCES name the wrong key, so Ctrl+Shift+Up moves the cursor
down
and Ctrl+Shift+Down moves it up, in any prompt_toolkit application on any terminal
that reports modified arrows.

The evidence, from the table's own entries

The table already agrees that final byte A is cursor-up and B is cursor-down at four of
the eight modifier levels it carries, and disagrees with itself at the other four:

mod   ESC[1;<n>A  (cursor up)          ESC[1;<n>B  (cursor down)
2     ShiftUp                          ShiftDown                    ok
3     (Escape, Up)                     (Escape, Down)               ok
4     (Escape, ShiftDown)              (Escape, ShiftUp)            SWAPPED
5     ControlUp                        ControlDown                  ok
6     ControlShiftDown                 ControlShiftUp               SWAPPED
7     (Escape, ControlDown)            (Escape, ControlUp)          SWAPPED
8     (Escape, ControlShiftDown)       (Escape, ControlShiftUp)     SWAPPED
9     (Escape, Up)                     (Escape, Down)               ok

ESC[5;7~ and ESC[5;8~ are a different slip rather than a swap — they resolve to the
same values as ESC[6;7~ and ESC[6;8~, so the PageUp rows read as copied from the
PageDown rows.

Left/Right (C/D) and Home/End (H/F) are correct at every modifier level. Only
the up/down axis is affected: 8 arrow entries + 2 tilde entries.

Corroboration outside the table

src/prompt_toolkit/input/win32.py:441-442 already maps Keys.Up → Keys.ControlShiftUp
and Keys.Down → Keys.ControlShiftDown. The Windows input path has it right, so this makes
the two paths agree rather than changing what the library thinks these keys mean.

A terminal, asked directly. kitty 0.48.2's own encoder, using its own key identities:

Ctrl+Shift+Up      -> ESC[1;6A       table said ControlShiftDown
Ctrl+Shift+Down    -> ESC[1;6B       table said ControlShiftUp
Alt+Shift+Up       -> ESC[1;4A       table said (Escape, ShiftDown)
Ctrl+Alt+Up        -> ESC[1;7A       table said (Escape, ControlDown)
Ctrl+Alt+PageUp    -> ESC[5;7~       table said (Escape, ControlPageDown)

And xterm's ctlseqs: CSI 1 ; modifier A is cursor-up, B is cursor-down; CSI 5 ; modifier ~ is Prior/PageUp and CSI 6 ; modifier ~ is Next/PageDown.

The tests

Deliberately not ten assertions. Each row of a literal table is independently
unfalsifiable — a wrong row looks exactly like a right one, and nothing cross-checks A
against B, which is how these survived. So the tests assert the property instead: for
each final byte, every modifier level the table carries must resolve to a key naming the
right direction.

That also makes them specific rather than blanket. Without the source fix:

FAILED  [A-Up]        FAILED  [B-Down]        FAILED  [5-PageUp]
passed  [C-Right]     passed  [D-Left]        passed  [6-PageDown]

Three of six red, and they are exactly the three families that are broken; the ones that
were already correct stay green.

Verification

Run against the checkout rather than an installed wheel (PYTHONPATH=src — without it,
pytest silently tests the installed copy):

main @ 583b341     156 passed
this branch        162 passed

ruff check and ruff format --check clean on both files. I have not run mypy — it is not
installed in the environment I verified this in.

How these were found

By building a parser that computes the key from the sequence's grammar rather than looking
it up, and diffing its answers against the table across every key × modifier combination a
terminal can emit. A parser cannot disagree with itself about which final byte means up, so
the disagreements surfaced on their own — there were exactly these ten, plus two divergences
that are not defects (modifier 9, which xterm reads as meta and the kitty protocol defines
as super).

Ten entries in ANSI_SEQUENCES named the wrong key, so Ctrl+Shift+Up moved the
cursor down and Ctrl+Shift+Down moved it up, in any application on any terminal
that reports modified arrows.

The table already agreed that final byte A is cursor-up and B is cursor-down at
modifiers 2, 3, 5 and 9; it disagreed with itself at 4, 6, 7 and 8, where the
pairs were transposed. ESC[5;7~ and ESC[5;8~ are a different slip: they resolve
to the same values as ESC[6;7~ and ESC[6;8~, so the PageUp rows read as copied
from the PageDown rows rather than swapped with them.

Left/Right and Home/End were correct at every level; only the up/down axis was
affected. input/win32.py already maps Up to ControlShiftUp and Down to
ControlShiftDown, so this makes the two input paths agree rather than changing
what the library thinks these keys mean.

The tests assert the property rather than the ten values: for each final byte,
every modifier level the table carries must resolve to a key naming the right
direction. Three of the six parametrised cases fail without the fix and three
pass, which are exactly the families that were already correct.

Fixes prompt-toolkit#2092
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Modified arrow keys resolve to the opposite direction: ESC[1;6A is Ctrl+Shift+Up but maps to ControlShiftDown (10 entries)

1 participant