diff --git a/internal/tui/crlf_test.go b/internal/tui/crlf_test.go new file mode 100644 index 0000000..6b2f7e6 --- /dev/null +++ b/internal/tui/crlf_test.go @@ -0,0 +1,26 @@ +package tui + +import "testing" + +func TestPasteNormalizesCarriageReturns(t *testing.T) { + e := NewEditor("▌ ") + e.HandleKey(Key{Kind: KeyPaste, Paste: "alpha\rbeta\r\ngamma"}) + + want := "alpha\nbeta\ngamma" + if got := e.Value(); got != want { + t.Fatalf("Value() = %q, want %q", got, want) + } + if got := e.SubmitValue(); got != want { + t.Fatalf("SubmitValue() = %q, want %q", got, want) + } +} + +func TestSetValueNormalizesCarriageReturns(t *testing.T) { + e := NewEditor("▌ ") + e.SetValue("one\rtwo\r\nthree") + + want := "one\ntwo\nthree" + if got := e.Value(); got != want { + t.Fatalf("Value() = %q, want %q", got, want) + } +} diff --git a/internal/tui/editor.go b/internal/tui/editor.go index 7a45b6d..489aaa6 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -92,6 +92,7 @@ func (e *Editor) SubmitValue() string { // Also drops any stored pastes because the placeholders they back // are now gone from the visible text. func (e *Editor) SetValue(s string) { + s = normalizeEditorText(s) e.Lines = strings.Split(s, "\n") if len(e.Lines) == 0 { e.Lines = []string{""} @@ -175,6 +176,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) { case KeyCtrlW: e.deleteWord() case KeyPaste: + paste := normalizeEditorText(k.Paste) // Large multi-line pastes are collapsed to a short // placeholder token so the editor doesn't balloon to // hundreds of rows. The full body is stashed in e.pastes, @@ -183,19 +185,19 @@ func (e *Editor) HandleKey(k Key) (submit bool) { // two or more newlines triggers collapse; one-liners and // drag-dropped file paths fall through to the original // insert path (including file-path quoting). - if pasteShouldCollapse(k.Paste) { + if pasteShouldCollapse(paste) { if e.pastes == nil { e.pastes = map[int]string{} } e.pasteSeq++ id := e.pasteSeq - e.pastes[id] = k.Paste - e.insert(formatPastePlaceholder(id, k.Paste)) + e.pastes[id] = paste + e.insert(formatPastePlaceholder(id, paste)) } else { // macOS Terminal / iTerm / Ghostty deliver drag-dropped // files as bracketed-paste text. Detect that pattern // and collapse long paths to a [file:basename] chip. - inserted := e.collapseOrQuoteFilePaths(k.Paste) + inserted := e.collapseOrQuoteFilePaths(paste) e.insert(inserted) } case KeyEsc: @@ -420,6 +422,7 @@ func singleQuote(s string) string { func (e *Editor) Insert(s string) { e.insert(s) } func (e *Editor) insert(s string) { + s = normalizeEditorText(s) line := e.Lines[e.CursorR] pre := substringBefore(line, e.CursorC) post := substringAfter(line, e.CursorC) @@ -849,6 +852,17 @@ func substringAfter(s string, col int) string { func runeLen(s string) int { return len([]rune(s)) } +// normalizeEditorText converts all common line endings to \n before text +// reaches the renderer. A literal carriage return would move the terminal +// cursor back to column 0 and overwrite the left side of the input row. +func normalizeEditorText(s string) string { + if !strings.ContainsRune(s, '\r') { + return s + } + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\r", "\n") +} + func visualColumn(s string, runeCol int) int { r := []rune(s) if runeCol > len(r) { diff --git a/internal/tui/render.go b/internal/tui/render.go index 1bd7aab..db8c5f0 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -36,10 +36,9 @@ type Renderer struct { // Main-screen flow renderer state. logChat is the full chat buffer // already emitted into terminal scrollback. logBottom is the // editable/status block currently drawn after the chat. - logChat []string - logBottom []string - logInit bool - logCursorR int + logChat []string + logBottom []string + logInit bool } // NewRenderer returns a renderer that writes to out. @@ -61,7 +60,6 @@ func (r *Renderer) Resize(cols, rows int) { r.logChat = nil r.logBottom = nil r.logInit = false - r.logCursorR = 0 if r.out != nil { _, _ = io.WriteString(r.out, SeqClearScreen) } @@ -78,7 +76,6 @@ func (r *Renderer) Clear() { r.logChat = nil r.logBottom = nil r.logInit = false - r.logCursorR = 0 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) } @@ -305,17 +302,17 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int w.WriteString(line) w.WriteString("\r\n") } + w.WriteString(SeqSaveCursor) writeBlock(&w, bottomFrame) r.logInit = true } else { - // Move from the currently exposed cursor back to the top of the - // live bottom block, then erase the old block. We track the row - // inside the bottom block where we left the cursor last Draw. + // Return to the saved top-of-bottom-band anchor instead of relying + // on relative cursor movement from the last exposed editor cursor. + // If the terminal naturally scrolled between frames, save/restore is + // less prone to drift that leaves duplicated transcript blocks until + // ctrl+l forces a clear repaint. + w.WriteString(SeqRestoreCursor) w.WriteString("\r") - if r.logCursorR > 0 { - w.WriteString("\x1b[" + itoa(r.logCursorR) + "A") - } - eraseRows(&w, len(r.logBottom)) prefix := len(r.logChat) <= len(chatFrame) if prefix { @@ -327,9 +324,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int } } if prefix { - // Append only genuinely new chat rows. They become real terminal + // Erase old bottom (and anything below the saved anchor), then + // append only genuinely new chat rows. They become real terminal // scrollback, and inline image escapes are emitted once here — not // on every keystroke. + w.WriteString(SeqEraseToEnd) for _, line := range chatFrame[len(r.logChat):] { w.WriteString("\x1b[0m") w.WriteString(SeqClearLine) @@ -355,6 +354,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int w.WriteString("\r\n") } } + w.WriteString(SeqSaveCursor) writeBlock(&w, bottomFrame) } @@ -370,9 +370,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int w.WriteString("\x1b[" + itoa(cursorCol) + "C") } w.WriteString(SeqShowCursor) - r.logCursorR = cursorBottomRow - } else { - r.logCursorR = len(bottomFrame) - 1 } w.WriteString(SeqSynchronizedOff) @@ -395,23 +392,6 @@ func writeBlock(w *strings.Builder, lines []string) { } } -func eraseRows(w *strings.Builder, n int) { - if n <= 0 { - return - } - for i := 0; i < n; i++ { - w.WriteString("\x1b[0m") - w.WriteString(SeqClearLine) - if i < n-1 { - w.WriteString("\r\n") - } - } - if n > 1 { - w.WriteString("\x1b[" + itoa(n-1) + "A") - } - w.WriteString("\r") -} - func tailTruncated(lines []string, maxRows, cols int) []string { if maxRows <= 0 { return nil diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index 5de151a..3a28253 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -102,6 +102,13 @@ const ( SeqAltScreenOff = "\x1b[?1049l" SeqSynchronizedOn = "\x1b[?2026h" SeqSynchronizedOff = "\x1b[?2026l" + // SeqSaveCursor / SeqRestoreCursor use DECSC/DECRC. All terminals + // we target adjust the saved row when natural scrolling occurs, so + // these survive scroll-on-write inside the bottom band. + SeqSaveCursor = "\x1b7" + SeqRestoreCursor = "\x1b8" + // SeqEraseToEnd erases from the cursor to the end of the screen. + SeqEraseToEnd = "\x1b[J" ) // MoveTo moves the cursor to 1-indexed (row, col).