From 76633e06ae1a2f10141abd599b856bc8e74d5380 Mon Sep 17 00:00:00 2001 From: Laurent Demailly Date: Fri, 11 Jul 2025 16:57:45 -0700 Subject: [PATCH 1/6] Allow multi-line bracketed paste to not create single line with LF entry --- terminal.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/terminal.go b/terminal.go index 13e9a64..a22a7aa 100644 --- a/terminal.go +++ b/terminal.go @@ -146,6 +146,7 @@ const ( keyCtrlD = 4 keyCtrlU = 21 keyEnter = '\r' + keyLF = '\n' // technically not a key (unless a user uses Ctrl+J), but needed for bracketed paste mode with `\n`s. keyEscape = 27 keyBackspace = 127 keyUnknown = 0xd800 /* UTF-16 surrogate area */ + iota @@ -567,7 +568,7 @@ func (t *Terminal) handleKey(key rune) (line string, ok bool) { t.setLine(runes, len(runes)) } } - case keyEnter: + case keyEnter, keyLF: t.moveCursorToPos(len(t.line)) t.queue([]rune("\r\n")) line = string(t.line) From 48ea529018f6bad083c2ff10130ada87b6406db0 Mon Sep 17 00:00:00 2001 From: Laurent Demailly Date: Fri, 11 Jul 2025 17:36:03 -0700 Subject: [PATCH 2/6] missed a spot --- terminal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terminal.go b/terminal.go index a22a7aa..956b193 100644 --- a/terminal.go +++ b/terminal.go @@ -498,7 +498,7 @@ func (t *Terminal) historyAdd(entry string) { // handleKey processes the given key and, optionally, returns a line of text // that the user has entered. func (t *Terminal) handleKey(key rune) (line string, ok bool) { - if t.pasteActive && key != keyEnter { + if t.pasteActive && key != keyEnter && key != keyLF { t.addKeyToLine(key) return } From bc5cb00b388ea3385105137b227b150421d62ecd Mon Sep 17 00:00:00 2001 From: Laurent Demailly Date: Fri, 11 Jul 2025 17:57:31 -0700 Subject: [PATCH 3/6] keyLF is as much of a key as keyCtrlD so removing the comment --- terminal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terminal.go b/terminal.go index 956b193..1d7c97f 100644 --- a/terminal.go +++ b/terminal.go @@ -146,7 +146,7 @@ const ( keyCtrlD = 4 keyCtrlU = 21 keyEnter = '\r' - keyLF = '\n' // technically not a key (unless a user uses Ctrl+J), but needed for bracketed paste mode with `\n`s. + keyLF = '\n' keyEscape = 27 keyBackspace = 127 keyUnknown = 0xd800 /* UTF-16 surrogate area */ + iota From 3d202e8148faf3b960219201957bb11504e1216c Mon Sep 17 00:00:00 2001 From: Laurent Demailly Date: Sun, 13 Jul 2025 17:27:48 -0700 Subject: [PATCH 4/6] Handle CR+LF: consume LF after CR, to avoid empty extra lines in dos new line content --- terminal.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/terminal.go b/terminal.go index 1d7c97f..bddb2e2 100644 --- a/terminal.go +++ b/terminal.go @@ -813,6 +813,10 @@ func (t *Terminal) readLine() (line string, err error) { if !t.pasteActive { lineIsPasted = false } + // If we have CR, consume LF if present (CRLF sequence) to avoid returning an extra empty line. + if key == keyEnter && len(rest) > 0 && rest[0] == keyLF { + rest = rest[1:] + } line, lineOk = t.handleKey(key) } if len(rest) > 0 { From cb5628d50cc04a58553964e81c84461658e29440 Mon Sep 17 00:00:00 2001 From: Laurent Demailly Date: Wed, 16 Jul 2025 10:36:06 -0700 Subject: [PATCH 5/6] Adding tests --- terminal_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/terminal_test.go b/terminal_test.go index 29dd874..7633003 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -6,6 +6,8 @@ package term import ( "bytes" + "errors" + "fmt" "io" "os" "runtime" @@ -208,12 +210,24 @@ var keyPressTests = []struct { line: "efgh", throwAwayLines: 1, }, + { + // Newline in bracketed paste mode should still work. + in: "abc\x1b[200~d\nefg\x1b[201~h\r", + line: "efgh", + throwAwayLines: 1, + }, { // Lines consisting entirely of pasted data should be indicated as such. in: "\x1b[200~a\r", line: "a", err: ErrPasteIndicator, }, + { + // Lines consisting entirely of pasted data should be indicated as such (\n paste). + in: "\x1b[200~a\n", + line: "a", + err: ErrPasteIndicator, + }, { // Ctrl-C terminates readline in: "\003", @@ -296,6 +310,32 @@ func TestRender(t *testing.T) { } } +func TestCRLF(t *testing.T) { + c := &MockTerminal{ + toSend: []byte("line1\rline2\r\nline3\n"), + // bytesPerRead 0 means read all at once - CR+LF need to be in same read which is what terminals would do. + } + + ss := NewTerminal(c, "> ") + for i := range 3 { + line, err := ss.ReadLine() + if err != nil { + t.Fatalf("failed to read line %d: %v", i+1, err) + } + expected := fmt.Sprintf("line%d", i+1) + if line != expected { + t.Fatalf("expected '%s', got '%s'", expected, line) + } + } + line, err := ss.ReadLine() + if !errors.Is(err, io.EOF) { + t.Fatalf("expected EOF after 3 lines, got '%s' with error %v", line, err) + } + if line != "" { + t.Fatalf("expected empty line after EOF, got '%s'", line) + } +} + func TestPasswordNotSaved(t *testing.T) { c := &MockTerminal{ toSend: []byte("password\r\x1b[A\r"), From 0cf26df9aec994dfc61392e98b9034fe7133fb7f Mon Sep 17 00:00:00 2001 From: Laurent Demailly Date: Wed, 16 Jul 2025 11:00:25 -0700 Subject: [PATCH 6/6] Clarify limitation/corner case of CRLF in test --- terminal_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/terminal_test.go b/terminal_test.go index 7633003..5d35cc5 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -313,7 +313,11 @@ func TestRender(t *testing.T) { func TestCRLF(t *testing.T) { c := &MockTerminal{ toSend: []byte("line1\rline2\r\nline3\n"), - // bytesPerRead 0 means read all at once - CR+LF need to be in same read which is what terminals would do. + // bytesPerRead 0 in this test means read all at once + // CR+LF need to be in same read for ReadLine to not produce an extra empty line + // which is what terminals do for reasonably small paste. if way many lines are pasted + // and going over say 1k-16k buffer, readline current implementation will possibly generate 1 + // extra empty line, if the CR is in chunk1 and LF in chunk2 (and that's fine). } ss := NewTerminal(c, "> ")