detached 7970412

tui: strip ANSI from prompt before wrapping so cursor lands at end

When the interactive editor's Prompt carried ANSI styling — the
themed "▌ " glyph that interactive mode builds via
cfg.Theme.AccentBar — the raw escape bytes leaked into wrapLine's
per-rune width counter. Each byte of the SGR sequence (ESC, '[',
digits, ';', 'm') reported runewidth 1, so an 11-byte color escape
inflated the perceived prompt width by ~10 cells. wrapLine then
made wrap decisions against that inflated width, locateCursor
walked the inflated bodies, and Render() returned a cursor column
that landed inside the wrapped row instead of at its visible end.

The bug was intermittent because depending on the typed buffer's
length the geometry sometimes aligned by accident. Drag-and-dropping
a long screencaptureui temp path into VS Code's terminal reliably
triggered it, since the path stays inline (the temp file is already
gone by paste time so collapseOrQuoteFilePaths -> pathExists
returns false -> falls through to verbatim insert).

Fix: in Editor.Render, do all wrap and cursor math against a
plain-text prompt (ANSI stripped via stripANSI), then re-attach
the styled original to the very first wrapped row's leading
substring before returning. Continuation rows already use an
indent of spaces only, so they need no styling fixup. wrapLine
itself stays ANSI-unaware on purpose: the rest of the codebase
relies on its simple rune-based behaviour for plain text and
making it ANSI-aware would be a much bigger change with regression
risk elsewhere.

Adds editor_ansi_prompt_test.go which reproduces the exact captured
live scenario (the ANSI-themed prompt + the verbatim screencaptureui
path + ' hello' typed afterwards) and asserts the cursor lands at
the visible end of the last wrapped row.
commit
797041288d2b7c4178e2615b874552e78aa83df1
author
patriceckhart · · 2026-05-13 09:13 UTC
parent
8b4b62f
signature
unsigned
refs
tag: v0.1.79
2 files · +64 -2
M internal/tui/editor.go +20 -2
@@ -741,13 +741,24 @@ func isWordSep(r rune) bool {
741741 // visualRow/visualCol describe where the cursor lands within the returned lines.
742742 func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
743743 e.lastRenderWidth = width
744- promptLen := visibleWidth(e.Prompt)
744+ // The Prompt may carry ANSI styling (theme-coloured glyph + reset).
745+ // We must compute wrap geometry using its *visible* width only;
746+ // otherwise the raw escape bytes leak into wrapLine's per-rune
747+ // width accounting and the cursor column lands inside the row
748+ // instead of at the end. See the wrapLine docs for why we keep it
749+ // ANSI-unaware: the rest of the codebase relies on its simple
750+ // rune-based behaviour for plain text and would regress if we
751+ // taught it about escape sequences here. Strip ANSI from the
752+ // prompt used for layout, then re-apply the original styling to
753+ // the very first wrapped row before returning.
754+ plainPrompt := stripANSI(e.Prompt)
755+ promptLen := runewidth.StringWidth(plainPrompt)
745756 indent := strings.Repeat(" ", promptLen)
746757
747758 for r, line := range e.Lines {
748759 var prefix string
749760 if r == 0 {
750- prefix = e.Prompt
761+ prefix = plainPrompt
751762 } else {
752763 prefix = indent
753764 }
@@ -764,6 +775,13 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
764775 visualRow = len(lines) + row
765776 visualCol = col
766777 }
778+ // Re-attach the styled prompt to the very first wrapped row of
779+ // the first logical line. All later rows are indent-only and
780+ // don't need styling. The replacement is byte-exact because the
781+ // plain prompt sits at the row's start.
782+ if r == 0 && len(wrapped) > 0 && strings.HasPrefix(wrapped[0], plainPrompt) {
783+ wrapped[0] = e.Prompt + wrapped[0][len(plainPrompt):]
784+ }
767785 lines = append(lines, wrapped...)
768786 }
769787 return lines, visualRow, visualCol
@@ -741,13 +741,24 @@ func isWordSep(r rune) bool {
741 // visualRow/visualCol describe where the cursor lands within the returned lines.741 // visualRow/visualCol describe where the cursor lands within the returned lines.
742 func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {742 func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
743 e.lastRenderWidth = width743 e.lastRenderWidth = width
744- promptLen := visibleWidth(e.Prompt)744+ // The Prompt may carry ANSI styling (theme-coloured glyph + reset).
745+ // We must compute wrap geometry using its *visible* width only;
746+ // otherwise the raw escape bytes leak into wrapLine's per-rune
747+ // width accounting and the cursor column lands inside the row
748+ // instead of at the end. See the wrapLine docs for why we keep it
749+ // ANSI-unaware: the rest of the codebase relies on its simple
750+ // rune-based behaviour for plain text and would regress if we
751+ // taught it about escape sequences here. Strip ANSI from the
752+ // prompt used for layout, then re-apply the original styling to
753+ // the very first wrapped row before returning.
754+ plainPrompt := stripANSI(e.Prompt)
755+ promptLen := runewidth.StringWidth(plainPrompt)
745 indent := strings.Repeat(" ", promptLen)756 indent := strings.Repeat(" ", promptLen)
746 757
747 for r, line := range e.Lines {758 for r, line := range e.Lines {
748 var prefix string759 var prefix string
749 if r == 0 {760 if r == 0 {
750- prefix = e.Prompt761+ prefix = plainPrompt
751 } else {762 } else {
752 prefix = indent763 prefix = indent
753 }764 }
@@ -764,6 +775,13 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
764 visualRow = len(lines) + row775 visualRow = len(lines) + row
765 visualCol = col776 visualCol = col
766 }777 }
778+ // Re-attach the styled prompt to the very first wrapped row of
779+ // the first logical line. All later rows are indent-only and
780+ // don't need styling. The replacement is byte-exact because the
781+ // plain prompt sits at the row's start.
782+ if r == 0 && len(wrapped) > 0 && strings.HasPrefix(wrapped[0], plainPrompt) {
783+ wrapped[0] = e.Prompt + wrapped[0][len(plainPrompt):]
784+ }
767 lines = append(lines, wrapped...)785 lines = append(lines, wrapped...)
768 }786 }
769 return lines, visualRow, visualCol787 return lines, visualRow, visualCol
A internal/tui/editor_ansi_prompt_test.go +44 -0
@@ -0,0 +1,44 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/mattn/go-runewidth"
8+)
9+
10+// TestEditor_AnsiPromptCursorAtEnd reproduces the live VS-Code drag
11+// and drop bug: when the editor's Prompt carries ANSI styling (the
12+// themed "▌ " glyph used by the interactive mode), the raw escape
13+// bytes leaked into wrapLine's per-rune width counter, and the
14+// cursor reported by Render() landed inside the wrapped row instead
15+// of at the end. Render() must return a column equal to the visible
16+// width of the last wrapped row when CursorC sits at end-of-buffer.
17+func TestEditor_AnsiPromptCursorAtEnd(t *testing.T) {
18+ // "\x1b[38;5;111m▌ \x1b[0m" is the exact prompt the interactive
19+ // mode constructs via cfg.Theme.AccentBar. We hard-code the
20+ // bytes here so the test doesn't depend on theme internals.
21+ ansiPrompt := "\x1b[38;5;111m▌ \x1b[0m"
22+
23+ e := NewEditor(ansiPrompt)
24+ // Same scenario as the captured live session: a screencaptureui
25+ // temp path then " hello" typed afterwards.
26+ pasted := "'/var/folders/xq/hdh5qm6j66nbzd0sh3ljsxyc0000gn/T/TemporaryItems/NSIRD_screencaptureui_P22eIV/Screenshot 2026-05-13 at 11.09.28.png'"
27+ e.insert(pasted)
28+ for _, r := range " hello" {
29+ e.insert(string(r))
30+ }
31+
32+ lines, row, col := e.Render(113)
33+ if len(lines) == 0 {
34+ t.Fatalf("Render returned no lines")
35+ }
36+ last := lines[len(lines)-1]
37+ wantRow := len(lines) - 1
38+ wantCol := runewidth.StringWidth(stripANSI(last))
39+
40+ if row != wantRow || col != wantCol {
41+ t.Fatalf("cursor at row=%d col=%d, want row=%d col=%d\nrendered rows:\n%s",
42+ row, col, wantRow, wantCol, strings.Join(lines, "\n"))
43+ }
44+}
@@ -0,0 +1,44 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/mattn/go-runewidth"
8+)
9+
10+// TestEditor_AnsiPromptCursorAtEnd reproduces the live VS-Code drag
11+// and drop bug: when the editor's Prompt carries ANSI styling (the
12+// themed "▌ " glyph used by the interactive mode), the raw escape
13+// bytes leaked into wrapLine's per-rune width counter, and the
14+// cursor reported by Render() landed inside the wrapped row instead
15+// of at the end. Render() must return a column equal to the visible
16+// width of the last wrapped row when CursorC sits at end-of-buffer.
17+func TestEditor_AnsiPromptCursorAtEnd(t *testing.T) {
18+ // "\x1b[38;5;111m▌ \x1b[0m" is the exact prompt the interactive
19+ // mode constructs via cfg.Theme.AccentBar. We hard-code the
20+ // bytes here so the test doesn't depend on theme internals.
21+ ansiPrompt := "\x1b[38;5;111m▌ \x1b[0m"
22+
23+ e := NewEditor(ansiPrompt)
24+ // Same scenario as the captured live session: a screencaptureui
25+ // temp path then " hello" typed afterwards.
26+ pasted := "'/var/folders/xq/hdh5qm6j66nbzd0sh3ljsxyc0000gn/T/TemporaryItems/NSIRD_screencaptureui_P22eIV/Screenshot 2026-05-13 at 11.09.28.png'"
27+ e.insert(pasted)
28+ for _, r := range " hello" {
29+ e.insert(string(r))
30+ }
31+
32+ lines, row, col := e.Render(113)
33+ if len(lines) == 0 {
34+ t.Fatalf("Render returned no lines")
35+ }
36+ last := lines[len(lines)-1]
37+ wantRow := len(lines) - 1
38+ wantCol := runewidth.StringWidth(stripANSI(last))
39+
40+ if row != wantRow || col != wantCol {
41+ t.Fatalf("cursor at row=%d col=%d, want row=%d col=%d\nrendered rows:\n%s",
42+ row, col, wantRow, wantCol, strings.Join(lines, "\n"))
43+ }
44+}

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