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 { | |||
| 741 | 741 | // visualRow/visualCol describe where the cursor lands within the returned lines. | |
| 742 | 742 | func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) { | |
| 743 | 743 | 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 | 756 | indent := strings.Repeat(" ", promptLen) | |
| 746 | 757 | ||
| 747 | 758 | for r, line := range e.Lines { | |
| 748 | 759 | var prefix string | |
| 749 | 760 | if r == 0 { | |
| 750 | - | prefix = e.Prompt | |
| 761 | + | prefix = plainPrompt | |
| 751 | 762 | } else { | |
| 752 | 763 | prefix = indent | |
| 753 | 764 | } | |
| @@ -764,6 +775,13 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) { | |||
| 764 | 775 | visualRow = len(lines) + row | |
| 765 | 776 | 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 | 785 | lines = append(lines, wrapped...) | |
| 768 | 786 | } | |
| 769 | 787 | 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 = width | 743 | 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 string | 759 | var prefix string | ||
| 749 | if r == 0 { | 760 | if r == 0 { | ||
| 750 | - | prefix = e.Prompt | 761 | + | prefix = plainPrompt |
| 751 | } else { | 762 | } else { | ||
| 752 | prefix = indent | 763 | prefix = indent | ||
| 753 | } | 764 | } | ||
| @@ -764,6 +775,13 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) { | |||||
| 764 | visualRow = len(lines) + row | 775 | visualRow = len(lines) + row | ||
| 765 | visualCol = col | 776 | 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, visualCol | 787 | 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 | + | } | |||