tui: use DECSC/DECRC for bottom-band anchor; normalize \r in editor input

DrawLog now saves/restores the cursor at the top of the bottom band
instead of relying on relative up-N math that drifted when the
terminal naturally scrolled between frames. This fixes duplicated
transcript blocks with empty gaps (previously only ctrl+l recovered).

Also strip literal carriage returns from pasted/typed editor text
before rendering. A bare \r moves the terminal cursor to column 0
and overwrites the left side of the input row, which looked like
missing highlight segments on continuation lines.
This commit is contained in:
patriceckhart 2026-05-05 14:35:40 +02:00
parent 07c073055d
commit ae4e019ee4
4 changed files with 65 additions and 38 deletions

26
internal/tui/crlf_test.go Normal file
View file

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

View file

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

View file

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

View file

@ -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).