From 844e234a7475cc65d1890be81f4dc8fe868b5b58 Mon Sep 17 00:00:00 2001 From: insjang Date: Wed, 11 Nov 2026 10:42:20 +0900 Subject: [PATCH] Terminal: send Shift+Tab, Delete, Ctrl+Alt and Ctrl+digit keys as xterm does Keys that Claude Code, Codex and other terminal programs use, checked key by key against xterm: - Shift+Tab sends back tab (CSI Z), as xterm and Windows Terminal do, and no longer moves the focus out of the terminal; Claude Code switches modes with it. - Ctrl, Shift and Alt with Delete carry the modifier (CSI 3 ; 5 ~ and so on); every Delete used to arrive as a plain Delete. - A real Ctrl+Alt keeps its Alt: ESC before the control character, as in xterm (Ctrl+Alt+V pastes an image in Codex on WSL). AltGr, which SWT reports as Ctrl+Alt with the character it types, types that character as before. - Ctrl+2 to Ctrl+8 send NUL, ESC, FS, GS, RS, US and DEL; on Windows they sent the digits. - On the alternate screen Alt and Ctrl with the arrows go to the full screen program even where Eclipse has a binding for them (Alt+Up edits a queued message in Codex); at the shell prompt Alt+Up still maximizes the view. --- .../internal/emulator/VT100Emulator.java | 7 +- .../emulator/VT100TerminalControl.java | 79 ++++++++++++++----- .../internal/emulator/VT100EmulatorTest.java | 38 ++++++++- 3 files changed, 103 insertions(+), 21 deletions(-) diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100Emulator.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100Emulator.java index 832d6e628dd..bcd14fe3b81 100644 --- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100Emulator.java +++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100Emulator.java @@ -128,7 +128,12 @@ public class VT100Emulator implements ControlListener { private final int[] savedCursorColumn = new int[2]; /** whether a program asked for the alternate screen (47, 1047 or 1049) */ - private boolean alternateScreen; + private volatile boolean alternateScreen; + + /** @return whether a program shows the alternate screen, as full screen programs do */ + public boolean isAlternateScreen() { + return alternateScreen; + } /** * This field holds an array of StringBuffer objects, each of which is one diff --git a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100TerminalControl.java b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100TerminalControl.java index 699f6a18630..e29e1d586c9 100644 --- a/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100TerminalControl.java +++ b/terminal/bundles/org.eclipse.terminal.control/src/org/eclipse/terminal/internal/emulator/VT100TerminalControl.java @@ -603,7 +603,7 @@ private record XtermKey(int number, char last) { Map.entry(SWT.ARROW_UP, new XtermKey(1, 'A')), Map.entry(SWT.ARROW_DOWN, new XtermKey(1, 'B')), Map.entry(SWT.ARROW_RIGHT, new XtermKey(1, 'C')), Map.entry(SWT.ARROW_LEFT, new XtermKey(1, 'D')), Map.entry(SWT.HOME, new XtermKey(1, 'H')), Map.entry(SWT.END, new XtermKey(1, 'F')), - Map.entry(SWT.INSERT, new XtermKey(2, '~')), + Map.entry(SWT.INSERT, new XtermKey(2, '~')), Map.entry((int) SWT.DEL, new XtermKey(3, '~')), Map.entry(SWT.PAGE_UP, new XtermKey(5, '~')), Map.entry(SWT.PAGE_DOWN, new XtermKey(6, '~')), Map.entry(SWT.F1, new XtermKey(1, 'P')), Map.entry(SWT.F2, new XtermKey(1, 'Q')), Map.entry(SWT.F3, new XtermKey(1, 'R')), Map.entry(SWT.F4, new XtermKey(1, 'S')), @@ -656,6 +656,44 @@ private static String bracketed(String text) { return PASTE_START + text.replace("\u001b", "").replace("\u009b", "") + PASTE_END; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ } + /** + * What Ctrl alone with a key sends where SWT gives the key's own character: Space + * (NUL, the mark in Emacs), / (undo in Emacs) and the digits as xterm spells them, + * Ctrl+6 being vim's alternate file. Windows hands Ctrl+2 over as '2', so its NUL + * comes from here too. + * + * @param character the character SWT reports + * @return the control character, or the character itself + */ + public static char ctrlCharacter(char character) { + return switch (character) { + case ' ', '2' -> '\u0000'; + case '3' -> '\u001b'; + case '4' -> '\u001c'; + case '5' -> '\u001d'; + case '6' -> '\u001e'; + case '/', '7' -> '\u001f'; + case '8' -> '\u007f'; + default -> character; + }; + } + + /** + * SWT reports AltGr as Ctrl+Alt, with the character AltGr types. A real Ctrl+Alt + * gives a control character instead, which keeps its Alt as in xterm: ESC before + * it, so Ctrl+Alt+V reaches Codex as itself. + * + * @param stateMask the modifiers of the key event + * @param character the character SWT reports + * @return whether the character goes out with ESC before it + */ + public static boolean altSendsEscape(int stateMask, char character) { + if ((stateMask & SWT.ALT) == 0) { + return false; + } + return (stateMask & SWT.CTRL) == 0 || character < '\u0020' || character == '\u007f'; + } + protected void sendChar(char chKey, boolean altKeyPressed) { try { int byteToSend = chKey; @@ -895,6 +933,12 @@ protected void setupListeners() { fFocusListener = new TerminalFocusListener(); getCtlText().addKeyListener(fKeyHandler); + // Shift+Tab is a key for the program (back tab, see keyPressed), not a move of the focus out of the terminal + getCtlText().addTraverseListener(e -> { + if (e.detail == SWT.TRAVERSE_TAB_PREVIOUS && (e.stateMask & SWT.MODIFIER_MASK) == SWT.SHIFT) { + e.doit = false; + } + }); getCtlText().addFocusListener(fFocusListener); } @@ -1063,7 +1107,7 @@ public void keyPressed(KeyEvent event) { // Control key is _not_ also pressed. This works around a bug in SWT where, // on European keyboards, the AltGr key being pressed appears to us as Control // + Alt being pressed simultaneously. - boolean altKeyPressed = (event.stateMask & SWT.ALT) != 0 && !ctrlKeyPressed; + boolean altKeyPressed = altSendsEscape(event.stateMask, character); //if (!isConnected()) { if (fState == TerminalState.CLOSED) { @@ -1079,9 +1123,10 @@ public void keyPressed(KeyEvent event) { return; } - // Manage the Del key - if (event.keyCode == 0x000007f) { - sendString("\u001b[3~"); //$NON-NLS-1$ + // Manage the Del key: with a modifier as xterm spells it, Ctrl+Delete deleting a word in Codex + if (event.keyCode == SWT.DEL) { + String modified = modifiedSpecialKey(SWT.DEL, modifierKeys); + sendString(modified != null ? modified : "\u001b[3~"); //$NON-NLS-1$ return; } @@ -1260,8 +1305,10 @@ public void keyPressed(KeyEvent event) { } // a key Eclipse has a binding for stays with it, as Alt+Right (Quick Access) - // and Alt+Up (Maximize) do in this terminal - if (escSeq == null && !hasKeyBinding(accelerator)) { + // and Alt+Up (Maximize) do in this terminal; on the alternate screen the + // arrows go to the full screen program, Codex and Claude Code using Alt+Up + boolean arrow = event.keyCode >= SWT.ARROW_UP && event.keyCode <= SWT.ARROW_RIGHT; + if (escSeq == null && (arrow && fTerminalText.isAlternateScreen() || !hasKeyBinding(accelerator))) { escSeq = modifiedSpecialKey(event.keyCode, modifierKeys); } @@ -1281,17 +1328,7 @@ public void keyPressed(KeyEvent event) { Logger.log("stateMask = " + event.stateMask); //$NON-NLS-1$ if (onlyCtrlKeyPressed) { - switch (character) { - case ' ': - // Send a NUL character -- many terminal emulators send NUL when - // Control-Space is pressed. This is used to set the mark in Emacs. - character = '\u0000'; - break; - case '/': - // Ctrl+/ is undo in emacs - character = '\u001f'; - break; - } + character = ctrlCharacter(character); } // see javadoc on convertBackspace for details @@ -1299,6 +1336,12 @@ public void keyPressed(KeyEvent event) { character = 0x7f; } + // Shift+Tab sends back tab (CSI Z), as xterm and Windows Terminal do: Claude Code switches modes with it + if (character == '\t' && modifierKeys == SWT.SHIFT) { + sendString("\u001b[Z"); //$NON-NLS-1$ + return; + } + //TODO: At this point, Ctrl+M sends the same as Ctrl+Shift+M . //This is undesired. Fixing this here might make the special Ctrl+Shift+C //handling unnecessary further up. diff --git a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorTest.java b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorTest.java index 32366a9f172..aef1e813a2b 100644 --- a/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorTest.java +++ b/terminal/tests/org.eclipse.terminal.test/src/org/eclipse/terminal/internal/emulator/VT100EmulatorTest.java @@ -599,8 +599,9 @@ public void testModifiedSpecialKeys() { // xterm's spelling: CSI number ; modifier final, with 2 shift, 3 alt, 5 control and their sums Map keys = Map.ofEntries(Map.entry(SWT.ARROW_UP, "1;%dA"), Map.entry(SWT.ARROW_DOWN, "1;%dB"), Map.entry(SWT.ARROW_RIGHT, "1;%dC"), Map.entry(SWT.ARROW_LEFT, "1;%dD"), Map.entry(SWT.HOME, "1;%dH"), - Map.entry(SWT.END, "1;%dF"), Map.entry(SWT.INSERT, "2;%d~"), Map.entry(SWT.PAGE_UP, "5;%d~"), - Map.entry(SWT.PAGE_DOWN, "6;%d~"), Map.entry(SWT.F1, "1;%dP"), Map.entry(SWT.F2, "1;%dQ"), + Map.entry(SWT.END, "1;%dF"), Map.entry(SWT.INSERT, "2;%d~"), Map.entry((int) SWT.DEL, "3;%d~"), + Map.entry(SWT.PAGE_UP, "5;%d~"), Map.entry(SWT.PAGE_DOWN, "6;%d~"), Map.entry(SWT.F1, "1;%dP"), + Map.entry(SWT.F2, "1;%dQ"), Map.entry(SWT.F3, "1;%dR"), Map.entry(SWT.F4, "1;%dS"), Map.entry(SWT.F5, "15;%d~"), Map.entry(SWT.F6, "17;%d~"), Map.entry(SWT.F7, "18;%d~"), Map.entry(SWT.F8, "19;%d~"), Map.entry(SWT.F9, "20;%d~"), Map.entry(SWT.F10, "21;%d~"), Map.entry(SWT.F11, "23;%d~"), @@ -617,6 +618,39 @@ public void testModifiedSpecialKeys() { assertEquals(null, VT100TerminalControl.modifiedSpecialKey(SWT.ARROW_LEFT, SWT.COMMAND | SWT.SHIFT)); } + @Test + public void testCtrlCharacter() { + // xterm: Ctrl+2 to Ctrl+8 are NUL, ESC, FS, GS, RS, US and DEL + assertEquals("\u0000\u001b\u001c\u001d\u001e\u001f\u007f", + "2345678".chars().map(c -> VT100TerminalControl.ctrlCharacter((char) c)) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString()); + assertEquals('\u0000', VT100TerminalControl.ctrlCharacter(' ')); + assertEquals('\u001f', VT100TerminalControl.ctrlCharacter('/')); + assertEquals('1', VT100TerminalControl.ctrlCharacter('1')); + assertEquals('\u0016', VT100TerminalControl.ctrlCharacter('\u0016')); + } + + @Test + public void testAltSendsEscape() { + assertTrue(VT100TerminalControl.altSendsEscape(SWT.ALT, 'f')); + assertTrue(VT100TerminalControl.altSendsEscape(SWT.ALT | SWT.SHIFT, 'F')); + // a real Ctrl+Alt+V gives a control character and keeps its Alt + assertTrue(VT100TerminalControl.altSendsEscape(SWT.CTRL | SWT.ALT, '\u0016')); + // AltGr+Q on a German keyboard: Ctrl+Alt with the '@' it types, no ESC + assertFalse(VT100TerminalControl.altSendsEscape(SWT.CTRL | SWT.ALT, '@')); + assertFalse(VT100TerminalControl.altSendsEscape(SWT.CTRL, '\u0016')); + assertFalse(VT100TerminalControl.altSendsEscape(0, 'f')); + } + + @Test + public void testAlternateScreenIsReported() { + assertFalse(emulator.isAlternateScreen()); + run("\u001b[?1049h"); + assertTrue(emulator.isAlternateScreen()); + run("\u001b[?1049l"); + assertFalse(emulator.isAlternateScreen()); + } + @Test public void testDecPrivateModes() { // the cursor hidden and shown again by a program