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.
|
// helpKeyRows is the list of keybindings shown by /help.
|
||||||
var helpKeyRows = [][2]string{
|
var helpKeyRows = [][2]string{
|
||||||
{"enter", "submit the current input"},
|
{"enter", "submit the current input"},
|
||||||
{"alt+enter", "insert a newline"},
|
{"shift+enter / alt+enter", "insert a newline"},
|
||||||
{"tab", "complete the highlighted slash command"},
|
{"tab", "complete the highlighted slash command"},
|
||||||
{"esc", "cancel the current turn (while busy) - clear the input (while idle)"},
|
{"esc", "cancel the current turn (while busy) - clear the input (while idle)"},
|
||||||
{"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"},
|
{"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
|
// enter the alternate-screen buffer (CSI ?1049h). The renderer emits
|
||||||
// chat as normal terminal flow/scrollback and redraws only the live
|
// chat as normal terminal flow/scrollback and redraws only the live
|
||||||
// input/status block on normal typing.
|
// input/status block on normal typing.
|
||||||
_, _ = term.Write([]byte(tui.SeqBracketedPasteOn + tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqClearScreenNoHome + tui.SeqClearScrollback + tui.MoveTo(1, 1)))
|
_, _ = 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.SeqBracketedPasteOff + tui.SeqShowCursor))
|
defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
|
||||||
|
|
||||||
// Streaming pacer: drains buffered text deltas at a steady rate
|
// Streaming pacer: drains buffered text deltas at a steady rate
|
||||||
// so typewriter feel is identical across providers regardless of
|
// 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
|
// messages are queued and delivered as follow-up turns when the
|
||||||
// current turn ends. See the submit handler below.
|
// current turn ends. See the submit handler below.
|
||||||
|
|
||||||
if k.Kind == tui.KeyEnter && k.Alt {
|
if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
|
||||||
i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true})
|
i.ed.HandleKey(k)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,10 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
|
||||||
}
|
}
|
||||||
e.insert(string(k.Rune))
|
e.insert(string(k.Rune))
|
||||||
case KeyEnter:
|
case KeyEnter:
|
||||||
// Shift+Enter would be a separate key in some terminals; we treat
|
if k.Shift || k.Alt {
|
||||||
// the literal newline byte as Enter. Newline on submit is a decision
|
e.newline()
|
||||||
// for the outer UI via slash commands. Here Enter submits.
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
case KeyBackspace:
|
case KeyBackspace:
|
||||||
if k.Alt {
|
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
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -12,6 +13,7 @@ type Key struct {
|
||||||
Paste string // for KeyPaste
|
Paste string // for KeyPaste
|
||||||
Ctrl bool
|
Ctrl bool
|
||||||
Alt bool
|
Alt bool
|
||||||
|
Shift bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeyKind int
|
type KeyKind int
|
||||||
|
|
@ -240,27 +242,26 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
|
||||||
return Key{Kind: KeyUnknown}
|
return Key{Kind: KeyUnknown}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modified arrow keys come in as CSI 1;<mod><final>. Modifier values
|
shift, alt := parseCSIModifiers(params)
|
||||||
// we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt.
|
if final == 'u' {
|
||||||
var alt bool
|
if key, ok := parseCSIU(params); ok {
|
||||||
if params != "" {
|
return key
|
||||||
if i := strings.IndexByte(params, ';'); i >= 0 {
|
}
|
||||||
mod := params[i+1:]
|
}
|
||||||
if mod == "3" || mod == "4" || mod == "7" || mod == "8" {
|
if final == '~' {
|
||||||
// 3=Alt, 4=Shift+Alt, 7=Ctrl+Alt, 8=Ctrl+Shift+Alt
|
if key, ok := parseModifyOtherKeys(params); ok {
|
||||||
alt = true
|
return key
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch final {
|
switch final {
|
||||||
case 'A':
|
case 'A':
|
||||||
return Key{Kind: KeyUp, Alt: alt}
|
return Key{Kind: KeyUp, Alt: alt, Shift: shift}
|
||||||
case 'B':
|
case 'B':
|
||||||
return Key{Kind: KeyDown, Alt: alt}
|
return Key{Kind: KeyDown, Alt: alt, Shift: shift}
|
||||||
case 'C':
|
case 'C':
|
||||||
return Key{Kind: KeyRight, Alt: alt}
|
return Key{Kind: KeyRight, Alt: alt, Shift: shift}
|
||||||
case 'D':
|
case 'D':
|
||||||
return Key{Kind: KeyLeft, Alt: alt}
|
return Key{Kind: KeyLeft, Alt: alt, Shift: shift}
|
||||||
case 'H':
|
case 'H':
|
||||||
return Key{Kind: KeyHome}
|
return Key{Kind: KeyHome}
|
||||||
case 'F':
|
case 'F':
|
||||||
|
|
@ -283,6 +284,93 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
|
||||||
return Key{Kind: KeyUnknown}
|
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.
|
// readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text.
|
||||||
func (r *Reader) readPaste() Key {
|
func (r *Reader) readPaste() Key {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,34 @@ package tui
|
||||||
|
|
||||||
import "testing"
|
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) {
|
func TestReaderParsesSGRMouseWheel(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
seq string
|
seq string
|
||||||
|
|
@ -11,18 +39,24 @@ func TestReaderParsesSGRMouseWheel(t *testing.T) {
|
||||||
{"\x1b[<65;10;20M", KeyMouseWheelDown},
|
{"\x1b[<65;10;20M", KeyMouseWheelDown},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
idx := 0
|
k := readKey(t, tc.seq)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if k.Kind != tc.want {
|
if k.Kind != tc.want {
|
||||||
t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, 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\\"
|
SeqDeleteKittyImages = "\x1b_Ga=d\x1b\\"
|
||||||
SeqBracketedPasteOn = "\x1b[?2004h"
|
SeqBracketedPasteOn = "\x1b[?2004h"
|
||||||
SeqBracketedPasteOff = "\x1b[?2004l"
|
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
|
// Basic mouse tracking + SGR extended coordinates. Used only
|
||||||
// when explicitly enabled by the interactive mode (currently VS
|
// when explicitly enabled by the interactive mode (currently VS
|
||||||
// Code terminal) so terminals with good native scrolling, like
|
// Code terminal) so terminals with good native scrolling, like
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue