Support Shift-Enter in terminal input
Some checks are pending
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

This commit is contained in:
patriceckhart 2026-06-15 18:54:26 +02:00
parent 9ee726bb20
commit 94ece7d00e
7 changed files with 186 additions and 32 deletions

View file

@ -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)"},

View file

@ -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
}

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -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;<mod><final>. 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

View file

@ -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
}

View file

@ -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;<mod>;<code>~.
SeqEnhancedKeyboardOn = "\x1b[>1u\x1b[>4;2m"
SeqEnhancedKeyboardOff = "\x1b[<u\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