From 94ece7d00e6ba67bc4f083a223c48c2f43fb0fe1 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Mon, 15 Jun 2026 18:54:26 +0200 Subject: [PATCH] Support Shift-Enter in terminal input --- packages/agent/modes/help.go | 2 +- packages/agent/modes/interactive.go | 8 +- packages/tui/editor.go | 7 +- packages/tui/editor_shift_enter_test.go | 24 +++++ packages/tui/input.go | 116 +++++++++++++++++++++--- packages/tui/input_test.go | 54 +++++++++-- packages/tui/terminal.go | 7 ++ 7 files changed, 186 insertions(+), 32 deletions(-) create mode 100644 packages/tui/editor_shift_enter_test.go diff --git a/packages/agent/modes/help.go b/packages/agent/modes/help.go index 133d16e..1162395 100644 --- a/packages/agent/modes/help.go +++ b/packages/agent/modes/help.go @@ -11,7 +11,7 @@ import ( // helpKeyRows is the list of keybindings shown by /help. var helpKeyRows = [][2]string{ {"enter", "submit the current input"}, - {"alt+enter", "insert a newline"}, + {"shift+enter / alt+enter", "insert a newline"}, {"tab", "complete the highlighted slash command"}, {"esc", "cancel the current turn (while busy) - clear the input (while idle)"}, {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"}, diff --git a/packages/agent/modes/interactive.go b/packages/agent/modes/interactive.go index 01db170..db6d6a4 100644 --- a/packages/agent/modes/interactive.go +++ b/packages/agent/modes/interactive.go @@ -532,8 +532,8 @@ func (i *Interactive) Run(ctx context.Context) error { // enter the alternate-screen buffer (CSI ?1049h). The renderer emits // chat as normal terminal flow/scrollback and redraws only the live // input/status block on normal typing. - _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) - defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) + _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqEnhancedKeyboardOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1))) + defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) // Streaming pacer: drains buffered text deltas at a steady rate // so typewriter feel is identical across providers regardless of @@ -2040,8 +2040,8 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { // messages are queued and delivered as follow-up turns when the // current turn ends. See the submit handler below. - if k.Kind == tui.KeyEnter && k.Alt { - i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true}) + if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) { + i.ed.HandleKey(k) return false } diff --git a/packages/tui/editor.go b/packages/tui/editor.go index c69366c..8da1347 100644 --- a/packages/tui/editor.go +++ b/packages/tui/editor.go @@ -127,9 +127,10 @@ func (e *Editor) HandleKey(k Key) (submit bool) { } e.insert(string(k.Rune)) case KeyEnter: - // Shift+Enter would be a separate key in some terminals; we treat - // the literal newline byte as Enter. Newline on submit is a decision - // for the outer UI via slash commands. Here Enter submits. + if k.Shift || k.Alt { + e.newline() + return false + } return true case KeyBackspace: if k.Alt { diff --git a/packages/tui/editor_shift_enter_test.go b/packages/tui/editor_shift_enter_test.go new file mode 100644 index 0000000..663e93b --- /dev/null +++ b/packages/tui/editor_shift_enter_test.go @@ -0,0 +1,24 @@ +package tui + +import "testing" + +func TestEditorShiftEnterInsertsNewline(t *testing.T) { + e := NewEditor("> ") + e.HandleKey(Key{Kind: KeyRune, Rune: 'a'}) + if submit := e.HandleKey(Key{Kind: KeyEnter, Shift: true}); submit { + t.Fatal("Shift+Enter submitted; want newline") + } + e.HandleKey(Key{Kind: KeyRune, Rune: 'b'}) + + if got, want := e.Value(), "a\nb"; got != want { + t.Fatalf("Value() = %q, want %q", got, want) + } +} + +func TestEditorPlainEnterSubmits(t *testing.T) { + e := NewEditor("> ") + e.HandleKey(Key{Kind: KeyRune, Rune: 'a'}) + if submit := e.HandleKey(Key{Kind: KeyEnter}); !submit { + t.Fatal("Enter did not submit") + } +} diff --git a/packages/tui/input.go b/packages/tui/input.go index ae20748..3fdf463 100644 --- a/packages/tui/input.go +++ b/packages/tui/input.go @@ -1,6 +1,7 @@ package tui import ( + "strconv" "strings" "time" ) @@ -12,6 +13,7 @@ type Key struct { Paste string // for KeyPaste Ctrl bool Alt bool + Shift bool } type KeyKind int @@ -240,27 +242,26 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { return Key{Kind: KeyUnknown} } - // Modified arrow keys come in as CSI 1;. Modifier values - // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt. - var alt bool - if params != "" { - if i := strings.IndexByte(params, ';'); i >= 0 { - mod := params[i+1:] - if mod == "3" || mod == "4" || mod == "7" || mod == "8" { - // 3=Alt, 4=Shift+Alt, 7=Ctrl+Alt, 8=Ctrl+Shift+Alt - alt = true - } + shift, alt := parseCSIModifiers(params) + if final == 'u' { + if key, ok := parseCSIU(params); ok { + return key + } + } + if final == '~' { + if key, ok := parseModifyOtherKeys(params); ok { + return key } } switch final { case 'A': - return Key{Kind: KeyUp, Alt: alt} + return Key{Kind: KeyUp, Alt: alt, Shift: shift} case 'B': - return Key{Kind: KeyDown, Alt: alt} + return Key{Kind: KeyDown, Alt: alt, Shift: shift} case 'C': - return Key{Kind: KeyRight, Alt: alt} + return Key{Kind: KeyRight, Alt: alt, Shift: shift} case 'D': - return Key{Kind: KeyLeft, Alt: alt} + return Key{Kind: KeyLeft, Alt: alt, Shift: shift} case 'H': return Key{Kind: KeyHome} case 'F': @@ -283,6 +284,93 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { return Key{Kind: KeyUnknown} } +func parseCSIModifiers(params string) (shift, alt bool) { + if params == "" { + return false, false + } + i := strings.LastIndexByte(params, ';') + if i < 0 || i+1 >= len(params) { + return false, false + } + mod, err := strconv.Atoi(params[i+1:]) + if err != nil { + return false, false + } + // Xterm-style modifier values are 1 plus a bitmask: + // 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, + // 7=Alt+Ctrl, 8=Shift+Alt+Ctrl. + bits := mod - 1 + return bits&1 != 0, bits&2 != 0 +} + +func parseCSIU(params string) (Key, bool) { + parts := strings.Split(params, ";") + if len(parts) == 0 { + return Key{}, false + } + code, err := strconv.Atoi(parts[0]) + if err != nil { + return Key{}, false + } + mod := 1 + if len(parts) >= 2 { + if mod, err = strconv.Atoi(parts[1]); err != nil { + return Key{}, false + } + } + return keyFromModifiedCode(code, mod) +} + +func parseModifyOtherKeys(params string) (Key, bool) { + parts := strings.Split(params, ";") + if len(parts) != 3 || parts[0] != "27" { + return Key{}, false + } + mod, err := strconv.Atoi(parts[1]) + if err != nil { + return Key{}, false + } + code, err := strconv.Atoi(parts[2]) + if err != nil { + return Key{}, false + } + return keyFromModifiedCode(code, mod) +} + +func keyFromModifiedCode(code, mod int) (Key, bool) { + bits := mod - 1 + shift := bits&1 != 0 + alt := bits&2 != 0 + ctrl := bits&4 != 0 + switch code { + case 13: + return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true + } + if ctrl { + switch code { + case 'c', 'C': + return Key{Kind: KeyCtrlC, Shift: shift, Alt: alt, Ctrl: true}, true + case 'd', 'D': + return Key{Kind: KeyCtrlD, Shift: shift, Alt: alt, Ctrl: true}, true + case 'l', 'L': + return Key{Kind: KeyCtrlL, Shift: shift, Alt: alt, Ctrl: true}, true + case 'u', 'U': + return Key{Kind: KeyCtrlU, Shift: shift, Alt: alt, Ctrl: true}, true + case 'k', 'K': + return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true + case 'a', 'A': + return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true + case 'e', 'E': + return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true + case 'w', 'W': + return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true + case 'o', 'O': + return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true + } + } + return Key{}, false +} + // readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text. func (r *Reader) readPaste() Key { var sb strings.Builder diff --git a/packages/tui/input_test.go b/packages/tui/input_test.go index 8ab692b..dd1d03d 100644 --- a/packages/tui/input_test.go +++ b/packages/tui/input_test.go @@ -2,6 +2,34 @@ package tui import "testing" +func TestReaderParsesCSIUShiftEnter(t *testing.T) { + k := readKey(t, "\x1b[13;2u") + if k.Kind != KeyEnter || !k.Shift || k.Alt { + t.Fatalf("Read kind=%v shift=%v alt=%v, want shift+enter", k.Kind, k.Shift, k.Alt) + } +} + +func TestReaderParsesModifyOtherKeysShiftEnter(t *testing.T) { + k := readKey(t, "\x1b[27;2;13~") + if k.Kind != KeyEnter || !k.Shift || k.Alt { + t.Fatalf("Read kind=%v shift=%v alt=%v, want shift+enter", k.Kind, k.Shift, k.Alt) + } +} + +func TestReaderParsesCSIUCtrlC(t *testing.T) { + k := readKey(t, "\x1b[99;5u") + if k.Kind != KeyCtrlC || !k.Ctrl { + t.Fatalf("Read kind=%v ctrl=%v, want ctrl+c", k.Kind, k.Ctrl) + } +} + +func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { + k := readKey(t, "\x1b[27;5;99~") + if k.Kind != KeyCtrlC || !k.Ctrl { + t.Fatalf("Read kind=%v ctrl=%v, want ctrl+c", k.Kind, k.Ctrl) + } +} + func TestReaderParsesSGRMouseWheel(t *testing.T) { cases := []struct { seq string @@ -11,18 +39,24 @@ func TestReaderParsesSGRMouseWheel(t *testing.T) { {"\x1b[<65;10;20M", KeyMouseWheelDown}, } for _, tc := range cases { - idx := 0 - r := NewReader(func() (byte, error) { - b := tc.seq[idx] - idx++ - return b, nil - }) - k, err := r.Read() - if err != nil { - t.Fatalf("Read(%q): %v", tc.seq, err) - } + k := readKey(t, tc.seq) if k.Kind != tc.want { t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want) } } } + +func readKey(t *testing.T, seq string) Key { + t.Helper() + idx := 0 + r := NewReader(func() (byte, error) { + b := seq[idx] + idx++ + return b, nil + }) + k, err := r.Read() + if err != nil { + t.Fatalf("Read(%q): %v", seq, err) + } + return k +} diff --git a/packages/tui/terminal.go b/packages/tui/terminal.go index 1e49014..e6f0654 100644 --- a/packages/tui/terminal.go +++ b/packages/tui/terminal.go @@ -107,6 +107,13 @@ const ( SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\" SeqBracketedPasteOn = "\x1b[?2004h" SeqBracketedPasteOff = "\x1b[?2004l" + // Request enhanced keyboard reporting where supported. Kitty-style + // keyboard protocol covers Ghostty, Kitty, VS Code's integrated + // terminal, and recent xterm.js builds. Xterm modifyOtherKeys is a + // useful fallback for terminals/tmux configurations that expose + // modified Enter as CSI 27;;~. + SeqEnhancedKeyboardOn = "\x1b[>1u\x1b[>4;2m" + SeqEnhancedKeyboardOff = "\x1b[4m" // Basic mouse tracking + SGR extended coordinates. Used only // when explicitly enabled by the interactive mode (currently VS // Code terminal) so terminals with good native scrolling, like