mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
Support Shift-Enter in terminal input
This commit is contained in:
parent
9ee726bb20
commit
94ece7d00e
7 changed files with 186 additions and 32 deletions
|
|
@ -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)"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
24
packages/tui/editor_shift_enter_test.go
Normal file
24
packages/tui/editor_shift_enter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue