detached ae4e019

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 {
9292 // Also drops any stored pastes because the placeholders they back
9393 // are now gone from the visible text.
9494 func (e *Editor) SetValue(s string) {
95+ s = normalizeEditorText(s)
9596 e.Lines = strings.Split(s, "\n")
9697 if len(e.Lines) == 0 {
9798 e.Lines = []string{""}
@@ -175,6 +176,7 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
175176 case KeyCtrlW:
176177 e.deleteWord()
177178 case KeyPaste:
179+ paste := normalizeEditorText(k.Paste)
178180 // Large multi-line pastes are collapsed to a short
179181 // placeholder token so the editor doesn't balloon to
180182 // hundreds of rows. The full body is stashed in e.pastes,
@@ -183,19 +185,19 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
183185 // two or more newlines triggers collapse; one-liners and
184186 // drag-dropped file paths fall through to the original
185187 // insert path (including file-path quoting).
186- if pasteShouldCollapse(k.Paste) {
188+ if pasteShouldCollapse(paste) {
187189 if e.pastes == nil {
188190 e.pastes = map[int]string{}
189191 }
190192 e.pasteSeq++
191193 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))
194196 } else {
195197 // macOS Terminal / iTerm / Ghostty deliver drag-dropped
196198 // files as bracketed-paste text. Detect that pattern
197199 // and collapse long paths to a [file:basename] chip.
198- inserted := e.collapseOrQuoteFilePaths(k.Paste)
200+ inserted := e.collapseOrQuoteFilePaths(paste)
199201 e.insert(inserted)
200202 }
201203 case KeyEsc:
@@ -420,6 +422,7 @@ func singleQuote(s string) string {
420422 func (e *Editor) Insert(s string) { e.insert(s) }
421423
422424 func (e *Editor) insert(s string) {
425+ s = normalizeEditorText(s)
423426 line := e.Lines[e.CursorR]
424427 pre := substringBefore(line, e.CursorC)
425428 post := substringAfter(line, e.CursorC)
@@ -849,6 +852,17 @@ func substringAfter(s string, col int) string {
849852
850853 func runeLen(s string) int { return len([]rune(s)) }
851854
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+
852866 func visualColumn(s string, runeCol int) int {
853867 r := []rune(s)
854868 if runeCol > len(r) {
@@ -92,6 +92,7 @@ func (e *Editor) SubmitValue() string {
92 // Also drops any stored pastes because the placeholders they back92 // 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 short180 // Large multi-line pastes are collapsed to a short
179 // placeholder token so the editor doesn't balloon to181 // 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 and185 // two or more newlines triggers collapse; one-liners and
184 // drag-dropped file paths fall through to the original186 // 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.pasteSeq193 id := e.pasteSeq
192- e.pastes[id] = k.Paste194+ 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-dropped197 // macOS Terminal / iTerm / Ghostty deliver drag-dropped
196 // files as bracketed-paste text. Detect that pattern198 // 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 {
3636 // Main-screen flow renderer state. logChat is the full chat buffer
3737 // already emitted into terminal scrollback. logBottom is the
3838 // 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
4342 }
4443
4544 // NewRenderer returns a renderer that writes to out.
@@ -61,7 +60,6 @@ func (r *Renderer) Resize(cols, rows int) {
6160 r.logChat = nil
6261 r.logBottom = nil
6362 r.logInit = false
64- r.logCursorR = 0
6563 if r.out != nil {
6664 _, _ = io.WriteString(r.out, SeqClearScreen)
6765 }
@@ -78,7 +76,6 @@ func (r *Renderer) Clear() {
7876 r.logChat = nil
7977 r.logBottom = nil
8078 r.logInit = false
81- r.logCursorR = 0
8279 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
8380 }
8481
@@ -305,17 +302,17 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
305302 w.WriteString(line)
306303 w.WriteString("\r\n")
307304 }
305+ w.WriteString(SeqSaveCursor)
308306 writeBlock(&w, bottomFrame)
309307 r.logInit = true
310308 } 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)
314315 w.WriteString("\r")
315- if r.logCursorR > 0 {
316- w.WriteString("\x1b[" + itoa(r.logCursorR) + "A")
317- }
318- eraseRows(&w, len(r.logBottom))
319316
320317 prefix := len(r.logChat) <= len(chatFrame)
321318 if prefix {
@@ -327,9 +324,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
327324 }
328325 }
329326 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
331329 // scrollback, and inline image escapes are emitted once here — not
332330 // on every keystroke.
331+ w.WriteString(SeqEraseToEnd)
333332 for _, line := range chatFrame[len(r.logChat):] {
334333 w.WriteString("\x1b[0m")
335334 w.WriteString(SeqClearLine)
@@ -355,6 +354,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
355354 w.WriteString("\r\n")
356355 }
357356 }
357+ w.WriteString(SeqSaveCursor)
358358 writeBlock(&w, bottomFrame)
359359 }
360360
@@ -370,9 +370,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
370370 w.WriteString("\x1b[" + itoa(cursorCol) + "C")
371371 }
372372 w.WriteString(SeqShowCursor)
373- r.logCursorR = cursorBottomRow
374- } else {
375- r.logCursorR = len(bottomFrame) - 1
376373 }
377374
378375 w.WriteString(SeqSynchronizedOff)
@@ -395,23 +392,6 @@ func writeBlock(w *strings.Builder, lines []string) {
395392 }
396393 }
397394
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-
415395 func tailTruncated(lines []string, maxRows, cols int) []string {
416396 if maxRows <= 0 {
417397 return nil
@@ -36,10 +36,9 @@ type Renderer struct {
36 // Main-screen flow renderer state. logChat is the full chat buffer36 // Main-screen flow renderer state. logChat is the full chat buffer
37 // already emitted into terminal scrollback. logBottom is the37 // 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 []string39+ logChat []string
40- logBottom []string40+ logBottom []string
41- logInit bool41+ 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 = nil60 r.logChat = nil
62 r.logBottom = nil61 r.logBottom = nil
63 r.logInit = false62 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 = nil76 r.logChat = nil
79 r.logBottom = nil77 r.logBottom = nil
80 r.logInit = false78 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 = true307 r.logInit = true
310 } else {308 } else {
311- // Move from the currently exposed cursor back to the top of the309+ // Return to the saved top-of-bottom-band anchor instead of relying
312- // live bottom block, then erase the old block. We track the row310+ // 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 terminal327+ // 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 — not329 // 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 nil397 return nil
M internal/tui/terminal.go +7 -0
@@ -102,6 +102,13 @@ const (
102102 SeqAltScreenOff = "\x1b[?1049l"
103103 SeqSynchronizedOn = "\x1b[?2026h"
104104 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"
105112 )
106113
107114 // 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).

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close