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.
- commit
- ae4e019ee441239d11f531f1e802d7d9d4781a3d
- author
- patriceckhart · · 2026-05-05 12:35 UTC
- parent
- 07c0730
- signature
- unsigned
- refs
- tag: v0.1.53
4 files · +65 -38
A internal/tui/crlf_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestPasteNormalizesCarriageReturns(t *testing.T) { | |
| 6 | + | e := NewEditor("▌ ") | |
| 7 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: "alpha\rbeta\r\ngamma"}) | |
| 8 | + | ||
| 9 | + | want := "alpha\nbeta\ngamma" | |
| 10 | + | if got := e.Value(); got != want { | |
| 11 | + | t.Fatalf("Value() = %q, want %q", got, want) | |
| 12 | + | } | |
| 13 | + | if got := e.SubmitValue(); got != want { | |
| 14 | + | t.Fatalf("SubmitValue() = %q, want %q", got, want) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestSetValueNormalizesCarriageReturns(t *testing.T) { | |
| 19 | + | e := NewEditor("▌ ") | |
| 20 | + | e.SetValue("one\rtwo\r\nthree") | |
| 21 | + | ||
| 22 | + | want := "one\ntwo\nthree" | |
| 23 | + | if got := e.Value(); got != want { | |
| 24 | + | t.Fatalf("Value() = %q, want %q", got, want) | |
| 25 | + | } | |
| 26 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestPasteNormalizesCarriageReturns(t *testing.T) { | |||
| 6 | + | e := NewEditor("▌ ") | |||
| 7 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: "alpha\rbeta\r\ngamma"}) | |||
| 8 | + | ||||
| 9 | + | want := "alpha\nbeta\ngamma" | |||
| 10 | + | if got := e.Value(); got != want { | |||
| 11 | + | t.Fatalf("Value() = %q, want %q", got, want) | |||
| 12 | + | } | |||
| 13 | + | if got := e.SubmitValue(); got != want { | |||
| 14 | + | t.Fatalf("SubmitValue() = %q, want %q", got, want) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestSetValueNormalizesCarriageReturns(t *testing.T) { | |||
| 19 | + | e := NewEditor("▌ ") | |||
| 20 | + | e.SetValue("one\rtwo\r\nthree") | |||
| 21 | + | ||||
| 22 | + | want := "one\ntwo\nthree" | |||
| 23 | + | if got := e.Value(); got != want { | |||
| 24 | + | t.Fatalf("Value() = %q, want %q", got, want) | |||
| 25 | + | } | |||
| 26 | + | } | |||
M internal/tui/editor.go +18 -4
| @@ -92,6 +92,7 @@ func (e *Editor) SubmitValue() string { | |||
| 92 | 92 | // Also drops any stored pastes because the placeholders they back | |
| 93 | 93 | // are now gone from the visible text. | |
| 94 | 94 | func (e *Editor) SetValue(s string) { | |
| 95 | + | s = normalizeEditorText(s) | |
| 95 | 96 | e.Lines = strings.Split(s, "\n") | |
| 96 | 97 | if len(e.Lines) == 0 { | |
| 97 | 98 | e.Lines = []string{""} | |
| @@ -175,6 +176,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||
| 175 | 176 | case KeyCtrlW: | |
| 176 | 177 | e.deleteWord() | |
| 177 | 178 | case KeyPaste: | |
| 179 | + | paste := normalizeEditorText(k.Paste) | |
| 178 | 180 | // Large multi-line pastes are collapsed to a short | |
| 179 | 181 | // placeholder token so the editor doesn't balloon to | |
| 180 | 182 | // hundreds of rows. The full body is stashed in e.pastes, | |
| @@ -183,19 +185,19 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||
| 183 | 185 | // two or more newlines triggers collapse; one-liners and | |
| 184 | 186 | // drag-dropped file paths fall through to the original | |
| 185 | 187 | // insert path (including file-path quoting). | |
| 186 | - | if pasteShouldCollapse(k.Paste) { | |
| 188 | + | if pasteShouldCollapse(paste) { | |
| 187 | 189 | if e.pastes == nil { | |
| 188 | 190 | e.pastes = map[int]string{} | |
| 189 | 191 | } | |
| 190 | 192 | e.pasteSeq++ | |
| 191 | 193 | id := e.pasteSeq | |
| 192 | - | e.pastes[id] = k.Paste | |
| 193 | - | e.insert(formatPastePlaceholder(id, k.Paste)) | |
| 194 | + | e.pastes[id] = paste | |
| 195 | + | e.insert(formatPastePlaceholder(id, paste)) | |
| 194 | 196 | } else { | |
| 195 | 197 | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | |
| 196 | 198 | // files as bracketed-paste text. Detect that pattern | |
| 197 | 199 | // and collapse long paths to a [file:basename] chip. | |
| 198 | - | inserted := e.collapseOrQuoteFilePaths(k.Paste) | |
| 200 | + | inserted := e.collapseOrQuoteFilePaths(paste) | |
| 199 | 201 | e.insert(inserted) | |
| 200 | 202 | } | |
| 201 | 203 | case KeyEsc: | |
| @@ -420,6 +422,7 @@ func singleQuote(s string) string { | |||
| 420 | 422 | func (e *Editor) Insert(s string) { e.insert(s) } | |
| 421 | 423 | ||
| 422 | 424 | func (e *Editor) insert(s string) { | |
| 425 | + | s = normalizeEditorText(s) | |
| 423 | 426 | line := e.Lines[e.CursorR] | |
| 424 | 427 | pre := substringBefore(line, e.CursorC) | |
| 425 | 428 | post := substringAfter(line, e.CursorC) | |
| @@ -849,6 +852,17 @@ func substringAfter(s string, col int) string { | |||
| 849 | 852 | ||
| 850 | 853 | func runeLen(s string) int { return len([]rune(s)) } | |
| 851 | 854 | ||
| 855 | + | // normalizeEditorText converts all common line endings to \n before text | |
| 856 | + | // reaches the renderer. A literal carriage return would move the terminal | |
| 857 | + | // cursor back to column 0 and overwrite the left side of the input row. | |
| 858 | + | func normalizeEditorText(s string) string { | |
| 859 | + | if !strings.ContainsRune(s, '\r') { | |
| 860 | + | return s | |
| 861 | + | } | |
| 862 | + | s = strings.ReplaceAll(s, "\r\n", "\n") | |
| 863 | + | return strings.ReplaceAll(s, "\r", "\n") | |
| 864 | + | } | |
| 865 | + | ||
| 852 | 866 | func visualColumn(s string, runeCol int) int { | |
| 853 | 867 | r := []rune(s) | |
| 854 | 868 | if runeCol > len(r) { | |
| @@ -92,6 +92,7 @@ func (e *Editor) SubmitValue() string { | |||||
| 92 | // Also drops any stored pastes because the placeholders they back | 92 | // Also drops any stored pastes because the placeholders they back | ||
| 93 | // are now gone from the visible text. | 93 | // are now gone from the visible text. | ||
| 94 | func (e *Editor) SetValue(s string) { | 94 | func (e *Editor) SetValue(s string) { | ||
| 95 | + | s = normalizeEditorText(s) | |||
| 95 | e.Lines = strings.Split(s, "\n") | 96 | e.Lines = strings.Split(s, "\n") | ||
| 96 | if len(e.Lines) == 0 { | 97 | if len(e.Lines) == 0 { | ||
| 97 | e.Lines = []string{""} | 98 | e.Lines = []string{""} | ||
| @@ -175,6 +176,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||||
| 175 | case KeyCtrlW: | 176 | case KeyCtrlW: | ||
| 176 | e.deleteWord() | 177 | e.deleteWord() | ||
| 177 | case KeyPaste: | 178 | case KeyPaste: | ||
| 179 | + | paste := normalizeEditorText(k.Paste) | |||
| 178 | // Large multi-line pastes are collapsed to a short | 180 | // Large multi-line pastes are collapsed to a short | ||
| 179 | // placeholder token so the editor doesn't balloon to | 181 | // placeholder token so the editor doesn't balloon to | ||
| 180 | // hundreds of rows. The full body is stashed in e.pastes, | 182 | // hundreds of rows. The full body is stashed in e.pastes, | ||
| @@ -183,19 +185,19 @@ func (e *Editor) HandleKey(k Key) (submit bool) { | |||||
| 183 | // two or more newlines triggers collapse; one-liners and | 185 | // two or more newlines triggers collapse; one-liners and | ||
| 184 | // drag-dropped file paths fall through to the original | 186 | // drag-dropped file paths fall through to the original | ||
| 185 | // insert path (including file-path quoting). | 187 | // insert path (including file-path quoting). | ||
| 186 | - | if pasteShouldCollapse(k.Paste) { | 188 | + | if pasteShouldCollapse(paste) { |
| 187 | if e.pastes == nil { | 189 | if e.pastes == nil { | ||
| 188 | e.pastes = map[int]string{} | 190 | e.pastes = map[int]string{} | ||
| 189 | } | 191 | } | ||
| 190 | e.pasteSeq++ | 192 | e.pasteSeq++ | ||
| 191 | id := e.pasteSeq | 193 | id := e.pasteSeq | ||
| 192 | - | e.pastes[id] = k.Paste | 194 | + | e.pastes[id] = paste |
| 193 | - | e.insert(formatPastePlaceholder(id, k.Paste)) | 195 | + | e.insert(formatPastePlaceholder(id, paste)) |
| 194 | } else { | 196 | } else { | ||
| 195 | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | 197 | // macOS Terminal / iTerm / Ghostty deliver drag-dropped | ||
| 196 | // files as bracketed-paste text. Detect that pattern | 198 | // files as bracketed-paste text. Detect that pattern | ||
| 197 | // and collapse long paths to a [file:basename] chip. | 199 | // and collapse long paths to a [file:basename] chip. | ||
| 198 | - | inserted := e.collapseOrQuoteFilePaths(k.Paste) | 200 | + | inserted := e.collapseOrQuoteFilePaths(paste) |
| 199 | e.insert(inserted) | 201 | e.insert(inserted) | ||
| 200 | } | 202 | } | ||
| 201 | case KeyEsc: | 203 | case KeyEsc: | ||
| @@ -420,6 +422,7 @@ func singleQuote(s string) string { | |||||
| 420 | func (e *Editor) Insert(s string) { e.insert(s) } | 422 | func (e *Editor) Insert(s string) { e.insert(s) } | ||
| 421 | 423 | ||||
| 422 | func (e *Editor) insert(s string) { | 424 | func (e *Editor) insert(s string) { | ||
| 425 | + | s = normalizeEditorText(s) | |||
| 423 | line := e.Lines[e.CursorR] | 426 | line := e.Lines[e.CursorR] | ||
| 424 | pre := substringBefore(line, e.CursorC) | 427 | pre := substringBefore(line, e.CursorC) | ||
| 425 | post := substringAfter(line, e.CursorC) | 428 | post := substringAfter(line, e.CursorC) | ||
| @@ -849,6 +852,17 @@ func substringAfter(s string, col int) string { | |||||
| 849 | 852 | ||||
| 850 | func runeLen(s string) int { return len([]rune(s)) } | 853 | func runeLen(s string) int { return len([]rune(s)) } | ||
| 851 | 854 | ||||
| 855 | + | // normalizeEditorText converts all common line endings to \n before text | |||
| 856 | + | // reaches the renderer. A literal carriage return would move the terminal | |||
| 857 | + | // cursor back to column 0 and overwrite the left side of the input row. | |||
| 858 | + | func normalizeEditorText(s string) string { | |||
| 859 | + | if !strings.ContainsRune(s, '\r') { | |||
| 860 | + | return s | |||
| 861 | + | } | |||
| 862 | + | s = strings.ReplaceAll(s, "\r\n", "\n") | |||
| 863 | + | return strings.ReplaceAll(s, "\r", "\n") | |||
| 864 | + | } | |||
| 865 | + | ||||
| 852 | func visualColumn(s string, runeCol int) int { | 866 | func visualColumn(s string, runeCol int) int { | ||
| 853 | r := []rune(s) | 867 | r := []rune(s) | ||
| 854 | if runeCol > len(r) { | 868 | if runeCol > len(r) { | ||
M internal/tui/render.go +14 -34
| @@ -36,10 +36,9 @@ type Renderer struct { | |||
| 36 | 36 | // Main-screen flow renderer state. logChat is the full chat buffer | |
| 37 | 37 | // already emitted into terminal scrollback. logBottom is the | |
| 38 | 38 | // editable/status block currently drawn after the chat. | |
| 39 | - | logChat []string | |
| 40 | - | logBottom []string | |
| 41 | - | logInit bool | |
| 42 | - | logCursorR int | |
| 39 | + | logChat []string | |
| 40 | + | logBottom []string | |
| 41 | + | logInit bool | |
| 43 | 42 | } | |
| 44 | 43 | ||
| 45 | 44 | // NewRenderer returns a renderer that writes to out. | |
| @@ -61,7 +60,6 @@ func (r *Renderer) Resize(cols, rows int) { | |||
| 61 | 60 | r.logChat = nil | |
| 62 | 61 | r.logBottom = nil | |
| 63 | 62 | r.logInit = false | |
| 64 | - | r.logCursorR = 0 | |
| 65 | 63 | if r.out != nil { | |
| 66 | 64 | _, _ = io.WriteString(r.out, SeqClearScreen) | |
| 67 | 65 | } | |
| @@ -78,7 +76,6 @@ func (r *Renderer) Clear() { | |||
| 78 | 76 | r.logChat = nil | |
| 79 | 77 | r.logBottom = nil | |
| 80 | 78 | r.logInit = false | |
| 81 | - | r.logCursorR = 0 | |
| 82 | 79 | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) | |
| 83 | 80 | } | |
| 84 | 81 | ||
| @@ -305,17 +302,17 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 305 | 302 | w.WriteString(line) | |
| 306 | 303 | w.WriteString("\r\n") | |
| 307 | 304 | } | |
| 305 | + | w.WriteString(SeqSaveCursor) | |
| 308 | 306 | writeBlock(&w, bottomFrame) | |
| 309 | 307 | r.logInit = true | |
| 310 | 308 | } else { | |
| 311 | - | // Move from the currently exposed cursor back to the top of the | |
| 312 | - | // live bottom block, then erase the old block. We track the row | |
| 313 | - | // inside the bottom block where we left the cursor last Draw. | |
| 309 | + | // Return to the saved top-of-bottom-band anchor instead of relying | |
| 310 | + | // on relative cursor movement from the last exposed editor cursor. | |
| 311 | + | // If the terminal naturally scrolled between frames, save/restore is | |
| 312 | + | // less prone to drift that leaves duplicated transcript blocks until | |
| 313 | + | // ctrl+l forces a clear repaint. | |
| 314 | + | w.WriteString(SeqRestoreCursor) | |
| 314 | 315 | w.WriteString("\r") | |
| 315 | - | if r.logCursorR > 0 { | |
| 316 | - | w.WriteString("\x1b[" + itoa(r.logCursorR) + "A") | |
| 317 | - | } | |
| 318 | - | eraseRows(&w, len(r.logBottom)) | |
| 319 | 316 | ||
| 320 | 317 | prefix := len(r.logChat) <= len(chatFrame) | |
| 321 | 318 | if prefix { | |
| @@ -327,9 +324,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 327 | 324 | } | |
| 328 | 325 | } | |
| 329 | 326 | if prefix { | |
| 330 | - | // Append only genuinely new chat rows. They become real terminal | |
| 327 | + | // Erase old bottom (and anything below the saved anchor), then | |
| 328 | + | // append only genuinely new chat rows. They become real terminal | |
| 331 | 329 | // scrollback, and inline image escapes are emitted once here — not | |
| 332 | 330 | // on every keystroke. | |
| 331 | + | w.WriteString(SeqEraseToEnd) | |
| 333 | 332 | for _, line := range chatFrame[len(r.logChat):] { | |
| 334 | 333 | w.WriteString("\x1b[0m") | |
| 335 | 334 | w.WriteString(SeqClearLine) | |
| @@ -355,6 +354,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 355 | 354 | w.WriteString("\r\n") | |
| 356 | 355 | } | |
| 357 | 356 | } | |
| 357 | + | w.WriteString(SeqSaveCursor) | |
| 358 | 358 | writeBlock(&w, bottomFrame) | |
| 359 | 359 | } | |
| 360 | 360 | ||
| @@ -370,9 +370,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 370 | 370 | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | |
| 371 | 371 | } | |
| 372 | 372 | w.WriteString(SeqShowCursor) | |
| 373 | - | r.logCursorR = cursorBottomRow | |
| 374 | - | } else { | |
| 375 | - | r.logCursorR = len(bottomFrame) - 1 | |
| 376 | 373 | } | |
| 377 | 374 | ||
| 378 | 375 | w.WriteString(SeqSynchronizedOff) | |
| @@ -395,23 +392,6 @@ func writeBlock(w *strings.Builder, lines []string) { | |||
| 395 | 392 | } | |
| 396 | 393 | } | |
| 397 | 394 | ||
| 398 | - | func eraseRows(w *strings.Builder, n int) { | |
| 399 | - | if n <= 0 { | |
| 400 | - | return | |
| 401 | - | } | |
| 402 | - | for i := 0; i < n; i++ { | |
| 403 | - | w.WriteString("\x1b[0m") | |
| 404 | - | w.WriteString(SeqClearLine) | |
| 405 | - | if i < n-1 { | |
| 406 | - | w.WriteString("\r\n") | |
| 407 | - | } | |
| 408 | - | } | |
| 409 | - | if n > 1 { | |
| 410 | - | w.WriteString("\x1b[" + itoa(n-1) + "A") | |
| 411 | - | } | |
| 412 | - | w.WriteString("\r") | |
| 413 | - | } | |
| 414 | - | ||
| 415 | 395 | func tailTruncated(lines []string, maxRows, cols int) []string { | |
| 416 | 396 | if maxRows <= 0 { | |
| 417 | 397 | return nil | |
| @@ -36,10 +36,9 @@ type Renderer struct { | |||||
| 36 | // Main-screen flow renderer state. logChat is the full chat buffer | 36 | // Main-screen flow renderer state. logChat is the full chat buffer | ||
| 37 | // already emitted into terminal scrollback. logBottom is the | 37 | // already emitted into terminal scrollback. logBottom is the | ||
| 38 | // editable/status block currently drawn after the chat. | 38 | // editable/status block currently drawn after the chat. | ||
| 39 | - | logChat []string | 39 | + | logChat []string |
| 40 | - | logBottom []string | 40 | + | logBottom []string |
| 41 | - | logInit bool | 41 | + | logInit bool |
| 42 | - | logCursorR int | |||
| 43 | } | 42 | } | ||
| 44 | 43 | ||||
| 45 | // NewRenderer returns a renderer that writes to out. | 44 | // NewRenderer returns a renderer that writes to out. | ||
| @@ -61,7 +60,6 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 61 | r.logChat = nil | 60 | r.logChat = nil | ||
| 62 | r.logBottom = nil | 61 | r.logBottom = nil | ||
| 63 | r.logInit = false | 62 | r.logInit = false | ||
| 64 | - | r.logCursorR = 0 | |||
| 65 | if r.out != nil { | 63 | if r.out != nil { | ||
| 66 | _, _ = io.WriteString(r.out, SeqClearScreen) | 64 | _, _ = io.WriteString(r.out, SeqClearScreen) | ||
| 67 | } | 65 | } | ||
| @@ -78,7 +76,6 @@ func (r *Renderer) Clear() { | |||||
| 78 | r.logChat = nil | 76 | r.logChat = nil | ||
| 79 | r.logBottom = nil | 77 | r.logBottom = nil | ||
| 80 | r.logInit = false | 78 | r.logInit = false | ||
| 81 | - | r.logCursorR = 0 | |||
| 82 | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) | 79 | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) | ||
| 83 | } | 80 | } | ||
| 84 | 81 | ||||
| @@ -305,17 +302,17 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 305 | w.WriteString(line) | 302 | w.WriteString(line) | ||
| 306 | w.WriteString("\r\n") | 303 | w.WriteString("\r\n") | ||
| 307 | } | 304 | } | ||
| 305 | + | w.WriteString(SeqSaveCursor) | |||
| 308 | writeBlock(&w, bottomFrame) | 306 | writeBlock(&w, bottomFrame) | ||
| 309 | r.logInit = true | 307 | r.logInit = true | ||
| 310 | } else { | 308 | } else { | ||
| 311 | - | // Move from the currently exposed cursor back to the top of the | 309 | + | // Return to the saved top-of-bottom-band anchor instead of relying |
| 312 | - | // live bottom block, then erase the old block. We track the row | 310 | + | // on relative cursor movement from the last exposed editor cursor. |
| 313 | - | // inside the bottom block where we left the cursor last Draw. | 311 | + | // If the terminal naturally scrolled between frames, save/restore is |
| 312 | + | // less prone to drift that leaves duplicated transcript blocks until | |||
| 313 | + | // ctrl+l forces a clear repaint. | |||
| 314 | + | w.WriteString(SeqRestoreCursor) | |||
| 314 | w.WriteString("\r") | 315 | w.WriteString("\r") | ||
| 315 | - | if r.logCursorR > 0 { | |||
| 316 | - | w.WriteString("\x1b[" + itoa(r.logCursorR) + "A") | |||
| 317 | - | } | |||
| 318 | - | eraseRows(&w, len(r.logBottom)) | |||
| 319 | 316 | ||||
| 320 | prefix := len(r.logChat) <= len(chatFrame) | 317 | prefix := len(r.logChat) <= len(chatFrame) | ||
| 321 | if prefix { | 318 | if prefix { | ||
| @@ -327,9 +324,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 327 | } | 324 | } | ||
| 328 | } | 325 | } | ||
| 329 | if prefix { | 326 | if prefix { | ||
| 330 | - | // Append only genuinely new chat rows. They become real terminal | 327 | + | // Erase old bottom (and anything below the saved anchor), then |
| 328 | + | // append only genuinely new chat rows. They become real terminal | |||
| 331 | // scrollback, and inline image escapes are emitted once here — not | 329 | // scrollback, and inline image escapes are emitted once here — not | ||
| 332 | // on every keystroke. | 330 | // on every keystroke. | ||
| 331 | + | w.WriteString(SeqEraseToEnd) | |||
| 333 | for _, line := range chatFrame[len(r.logChat):] { | 332 | for _, line := range chatFrame[len(r.logChat):] { | ||
| 334 | w.WriteString("\x1b[0m") | 333 | w.WriteString("\x1b[0m") | ||
| 335 | w.WriteString(SeqClearLine) | 334 | w.WriteString(SeqClearLine) | ||
| @@ -355,6 +354,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 355 | w.WriteString("\r\n") | 354 | w.WriteString("\r\n") | ||
| 356 | } | 355 | } | ||
| 357 | } | 356 | } | ||
| 357 | + | w.WriteString(SeqSaveCursor) | |||
| 358 | writeBlock(&w, bottomFrame) | 358 | writeBlock(&w, bottomFrame) | ||
| 359 | } | 359 | } | ||
| 360 | 360 | ||||
| @@ -370,9 +370,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 370 | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | 370 | w.WriteString("\x1b[" + itoa(cursorCol) + "C") | ||
| 371 | } | 371 | } | ||
| 372 | w.WriteString(SeqShowCursor) | 372 | w.WriteString(SeqShowCursor) | ||
| 373 | - | r.logCursorR = cursorBottomRow | |||
| 374 | - | } else { | |||
| 375 | - | r.logCursorR = len(bottomFrame) - 1 | |||
| 376 | } | 373 | } | ||
| 377 | 374 | ||||
| 378 | w.WriteString(SeqSynchronizedOff) | 375 | w.WriteString(SeqSynchronizedOff) | ||
| @@ -395,23 +392,6 @@ func writeBlock(w *strings.Builder, lines []string) { | |||||
| 395 | } | 392 | } | ||
| 396 | } | 393 | } | ||
| 397 | 394 | ||||
| 398 | - | func eraseRows(w *strings.Builder, n int) { | |||
| 399 | - | if n <= 0 { | |||
| 400 | - | return | |||
| 401 | - | } | |||
| 402 | - | for i := 0; i < n; i++ { | |||
| 403 | - | w.WriteString("\x1b[0m") | |||
| 404 | - | w.WriteString(SeqClearLine) | |||
| 405 | - | if i < n-1 { | |||
| 406 | - | w.WriteString("\r\n") | |||
| 407 | - | } | |||
| 408 | - | } | |||
| 409 | - | if n > 1 { | |||
| 410 | - | w.WriteString("\x1b[" + itoa(n-1) + "A") | |||
| 411 | - | } | |||
| 412 | - | w.WriteString("\r") | |||
| 413 | - | } | |||
| 414 | - | ||||
| 415 | func tailTruncated(lines []string, maxRows, cols int) []string { | 395 | func tailTruncated(lines []string, maxRows, cols int) []string { | ||
| 416 | if maxRows <= 0 { | 396 | if maxRows <= 0 { | ||
| 417 | return nil | 397 | return nil | ||
M internal/tui/terminal.go +7 -0
| @@ -102,6 +102,13 @@ const ( | |||
| 102 | 102 | SeqAltScreenOff = "\x1b[?1049l" | |
| 103 | 103 | SeqSynchronizedOn = "\x1b[?2026h" | |
| 104 | 104 | SeqSynchronizedOff = "\x1b[?2026l" | |
| 105 | + | // SeqSaveCursor / SeqRestoreCursor use DECSC/DECRC. All terminals | |
| 106 | + | // we target adjust the saved row when natural scrolling occurs, so | |
| 107 | + | // these survive scroll-on-write inside the bottom band. | |
| 108 | + | SeqSaveCursor = "\x1b7" | |
| 109 | + | SeqRestoreCursor = "\x1b8" | |
| 110 | + | // SeqEraseToEnd erases from the cursor to the end of the screen. | |
| 111 | + | SeqEraseToEnd = "\x1b[J" | |
| 105 | 112 | ) | |
| 106 | 113 | ||
| 107 | 114 | // MoveTo moves the cursor to 1-indexed (row, col). | |
| @@ -102,6 +102,13 @@ const ( | |||||
| 102 | SeqAltScreenOff = "\x1b[?1049l" | 102 | SeqAltScreenOff = "\x1b[?1049l" | ||
| 103 | SeqSynchronizedOn = "\x1b[?2026h" | 103 | SeqSynchronizedOn = "\x1b[?2026h" | ||
| 104 | SeqSynchronizedOff = "\x1b[?2026l" | 104 | SeqSynchronizedOff = "\x1b[?2026l" | ||
| 105 | + | // SeqSaveCursor / SeqRestoreCursor use DECSC/DECRC. All terminals | |||
| 106 | + | // we target adjust the saved row when natural scrolling occurs, so | |||
| 107 | + | // these survive scroll-on-write inside the bottom band. | |||
| 108 | + | SeqSaveCursor = "\x1b7" | |||
| 109 | + | SeqRestoreCursor = "\x1b8" | |||
| 110 | + | // SeqEraseToEnd erases from the cursor to the end of the screen. | |||
| 111 | + | SeqEraseToEnd = "\x1b[J" | |||
| 105 | ) | 112 | ) | ||
| 106 | 113 | ||||
| 107 | // MoveTo moves the cursor to 1-indexed (row, col). | 114 | // MoveTo moves the cursor to 1-indexed (row, col). | ||