fix(tui): three cursor/alignment fixes in the editor and /help
1. Drag-dropped long paths no longer strand the prompt glyph on
its own line. wrapLine() used to break before rune-splitting an
oversized token, which produced:
row 0: "▌"
row 1: " '/var/folders/.../TemporaryItems/NSIRD_screencaptu"
row 2: " re_CohJs2/Screenshot 2026-04-19 at 20.15.44.png..."
Because the prompt ("▌ ") was a separately-tokenised prefix,
overflow broke the line after writing it and started the long
token on row 1. That also shifted locateCursor's rune-walk, so
the terminal cursor drew in the wrong column after the user
typed anything past the paste. Fix: when the token will need
rune-by-rune splitting anyway (wider than width - contW), skip
the precautionary newline and stream runes from the current
column, wrapping naturally. Added two regression tests.
2. The spinner glyph and the funny-line message now render in
Theme.Assistant (the same cyan as the `▍ zot` role label) so
the busy band reads coherently with the rest of the chat.
Elapsed time stays muted; model name, stats, cost, context
meter, and cwd are unchanged. Fixed double-coloring in
StatusBar: the outer Accent wrapper was overriding the spinner
color the caller had set, so pre-colored segments now pass
through unmodified.
3. /help key-binding column alignment. Single-cell multibyte
runes like ← → · were being measured by byte length (3 bytes
each) instead of display width (1 cell), which overshot the
labelWidth calc AND caused the pad() function to return the
raw string without adding spaces. The `alt+← / alt+→` row
ended shorter than its neighbours and its description started
in the wrong column. Fix: use runewidth.StringWidth everywhere
in help.go's alignment math.- commit
- 3e59a3cfd3714aa697c9bf3b9ca1c4314c9a23c0
- author
- patriceckhart · · 2026-04-19 18:25 UTC
- parent
- f8c309e
- signature
- unsigned
- refs
- tag: v0.0.50
6 files · +106 -29
M internal/agent/modes/btw_dialog.go +2 -2
| @@ -298,8 +298,8 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||
| 298 | 298 | // rotating funny-line, elapsed seconds, then a muted hint | |
| 299 | 299 | // that esc cancels. | |
| 300 | 300 | prefix := fmt.Sprintf("%s %s · %s", | |
| 301 | - | th.FG256(th.Spinner, d.spin.Frame()), | |
| 302 | - | th.FG256(th.Spinner, d.spin.Message()), | |
| 301 | + | th.FG256(th.Assistant, d.spin.Frame()), | |
| 302 | + | th.FG256(th.Assistant, d.spin.Message()), | |
| 303 | 303 | th.FG256(th.Muted, d.spin.Elapsed().String()), | |
| 304 | 304 | ) | |
| 305 | 305 | out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)")) | |
| @@ -298,8 +298,8 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { | |||||
| 298 | // rotating funny-line, elapsed seconds, then a muted hint | 298 | // rotating funny-line, elapsed seconds, then a muted hint | ||
| 299 | // that esc cancels. | 299 | // that esc cancels. | ||
| 300 | prefix := fmt.Sprintf("%s %s · %s", | 300 | prefix := fmt.Sprintf("%s %s · %s", | ||
| 301 | - | th.FG256(th.Spinner, d.spin.Frame()), | 301 | + | th.FG256(th.Assistant, d.spin.Frame()), |
| 302 | - | th.FG256(th.Spinner, d.spin.Message()), | 302 | + | th.FG256(th.Assistant, d.spin.Message()), |
| 303 | th.FG256(th.Muted, d.spin.Elapsed().String()), | 303 | th.FG256(th.Muted, d.spin.Elapsed().String()), | ||
| 304 | ) | 304 | ) | ||
| 305 | out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)")) | 305 | out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)")) | ||
M internal/agent/modes/help.go +13 -4
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | + | "github.com/mattn/go-runewidth" | |
| 7 | 8 | "github.com/patriceckhart/zot/internal/tui" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -40,23 +41,31 @@ func renderHelpBlock(th tui.Theme, width int) []string { | |||
| 40 | 41 | width = 20 | |
| 41 | 42 | } | |
| 42 | 43 | ||
| 44 | + | // Label column width uses display cells, not byte length, so | |
| 45 | + | // single-cell multibyte runes (← → · …) don't over-count and leave | |
| 46 | + | // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but | |
| 47 | + | // only 13 cells; padding off byte length would either overshoot | |
| 48 | + | // (setting labelWidth too high and wasting space on every row) | |
| 49 | + | // or undershoot (never padding that row because len >= labelWidth | |
| 50 | + | // already, leaving its description mis-aligned). | |
| 43 | 51 | labelWidth := 14 | |
| 44 | 52 | for _, c := range slashCatalog { | |
| 45 | - | if n := len(c.Name); n > labelWidth { | |
| 53 | + | if n := runewidth.StringWidth(c.Name); n > labelWidth { | |
| 46 | 54 | labelWidth = n | |
| 47 | 55 | } | |
| 48 | 56 | } | |
| 49 | 57 | for _, k := range helpKeyRows { | |
| 50 | - | if n := len(k[0]); n > labelWidth { | |
| 58 | + | if n := runewidth.StringWidth(k[0]); n > labelWidth { | |
| 51 | 59 | labelWidth = n | |
| 52 | 60 | } | |
| 53 | 61 | } | |
| 54 | 62 | ||
| 55 | 63 | pad := func(s string) string { | |
| 56 | - | if len(s) >= labelWidth { | |
| 64 | + | w := runewidth.StringWidth(s) | |
| 65 | + | if w >= labelWidth { | |
| 57 | 66 | return s | |
| 58 | 67 | } | |
| 59 | - | return s + strings.Repeat(" ", labelWidth-len(s)) | |
| 68 | + | return s + strings.Repeat(" ", labelWidth-w) | |
| 60 | 69 | } | |
| 61 | 70 | ||
| 62 | 71 | var out []string | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/mattn/go-runewidth" | |||
| 7 | "github.com/patriceckhart/zot/internal/tui" | 8 | "github.com/patriceckhart/zot/internal/tui" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -40,23 +41,31 @@ func renderHelpBlock(th tui.Theme, width int) []string { | |||||
| 40 | width = 20 | 41 | width = 20 | ||
| 41 | } | 42 | } | ||
| 42 | 43 | ||||
| 44 | + | // Label column width uses display cells, not byte length, so | |||
| 45 | + | // single-cell multibyte runes (← → · …) don't over-count and leave | |||
| 46 | + | // a raggedy right edge. `len("alt+← / alt+→")` is 17 bytes but | |||
| 47 | + | // only 13 cells; padding off byte length would either overshoot | |||
| 48 | + | // (setting labelWidth too high and wasting space on every row) | |||
| 49 | + | // or undershoot (never padding that row because len >= labelWidth | |||
| 50 | + | // already, leaving its description mis-aligned). | |||
| 43 | labelWidth := 14 | 51 | labelWidth := 14 | ||
| 44 | for _, c := range slashCatalog { | 52 | for _, c := range slashCatalog { | ||
| 45 | - | if n := len(c.Name); n > labelWidth { | 53 | + | if n := runewidth.StringWidth(c.Name); n > labelWidth { |
| 46 | labelWidth = n | 54 | labelWidth = n | ||
| 47 | } | 55 | } | ||
| 48 | } | 56 | } | ||
| 49 | for _, k := range helpKeyRows { | 57 | for _, k := range helpKeyRows { | ||
| 50 | - | if n := len(k[0]); n > labelWidth { | 58 | + | if n := runewidth.StringWidth(k[0]); n > labelWidth { |
| 51 | labelWidth = n | 59 | labelWidth = n | ||
| 52 | } | 60 | } | ||
| 53 | } | 61 | } | ||
| 54 | 62 | ||||
| 55 | pad := func(s string) string { | 63 | pad := func(s string) string { | ||
| 56 | - | if len(s) >= labelWidth { | 64 | + | w := runewidth.StringWidth(s) |
| 65 | + | if w >= labelWidth { | |||
| 57 | return s | 66 | return s | ||
| 58 | } | 67 | } | ||
| 59 | - | return s + strings.Repeat(" ", labelWidth-len(s)) | 68 | + | return s + strings.Repeat(" ", labelWidth-w) |
| 60 | } | 69 | } | ||
| 61 | 70 | ||||
| 62 | var out []string | 71 | var out []string | ||
M internal/agent/modes/interactive.go +7 -3
| @@ -611,12 +611,16 @@ func (i *Interactive) redraw() { | |||
| 611 | 611 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | |
| 612 | 612 | } | |
| 613 | 613 | ||
| 614 | - | // Busy prefix shown at the far left of the status bar. | |
| 614 | + | // Busy prefix shown at the far left of the status bar. The | |
| 615 | + | // spinner glyph and its funny-line message share the `zot` | |
| 616 | + | // label colour (Theme.Assistant) so the whole "who's working" | |
| 617 | + | // band reads at a glance. Elapsed time stays muted because it | |
| 618 | + | // drifts every second and shouldn't grab focus. | |
| 615 | 619 | var busyPrefix string | |
| 616 | 620 | if i.busy { | |
| 617 | 621 | busyPrefix = fmt.Sprintf("%s %s · %s", | |
| 618 | - | i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Frame()), | |
| 619 | - | i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Message()), | |
| 622 | + | i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()), | |
| 623 | + | i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()), | |
| 620 | 624 | i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | |
| 621 | 625 | ) | |
| 622 | 626 | } | |
| @@ -611,12 +611,16 @@ func (i *Interactive) redraw() { | |||||
| 611 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | 611 | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | ||
| 612 | } | 612 | } | ||
| 613 | 613 | ||||
| 614 | - | // Busy prefix shown at the far left of the status bar. | 614 | + | // Busy prefix shown at the far left of the status bar. The |
| 615 | + | // spinner glyph and its funny-line message share the `zot` | |||
| 616 | + | // label colour (Theme.Assistant) so the whole "who's working" | |||
| 617 | + | // band reads at a glance. Elapsed time stays muted because it | |||
| 618 | + | // drifts every second and shouldn't grab focus. | |||
| 615 | var busyPrefix string | 619 | var busyPrefix string | ||
| 616 | if i.busy { | 620 | if i.busy { | ||
| 617 | busyPrefix = fmt.Sprintf("%s %s · %s", | 621 | busyPrefix = fmt.Sprintf("%s %s · %s", | ||
| 618 | - | i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Frame()), | 622 | + | i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Frame()), |
| 619 | - | i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Message()), | 623 | + | i.cfg.Theme.FG256(i.cfg.Theme.Assistant, i.spin.Message()), |
| 620 | i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | 624 | i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | ||
| 621 | ) | 625 | ) | ||
| 622 | } | 626 | } | ||
M internal/tui/editor.go +27 -19
| @@ -780,35 +780,43 @@ func wrapLine(s string, width int, cont string) []string { | |||
| 780 | 780 | continue | |
| 781 | 781 | } | |
| 782 | 782 | ||
| 783 | - | // Token overflows. If current line has content, break first. | |
| 783 | + | // Token overflows. | |
| 784 | + | // | |
| 785 | + | // If the token on its own is wider than what fits on a | |
| 786 | + | // continuation line (width - contW), it'll need to be split | |
| 787 | + | // rune-by-rune no matter what. In that case, don't break | |
| 788 | + | // first: let the rune-split start right here at the current | |
| 789 | + | // column and wrap naturally. Breaking first would strand the | |
| 790 | + | // prompt (on firstLine) or the cont indent (on later lines) | |
| 791 | + | // on a row by itself, which is the drag-drop-long-path bug. | |
| 792 | + | if tk.width > width-contW { | |
| 793 | + | // Rune-by-rune starting from the current column. newLine() | |
| 794 | + | // inside handles wrapping and re-indents. | |
| 795 | + | for _, r := range tk.text { | |
| 796 | + | rw := runewidth.RuneWidth(r) | |
| 797 | + | if curW+rw > width { | |
| 798 | + | newLine() | |
| 799 | + | } | |
| 800 | + | cur.WriteRune(r) | |
| 801 | + | curW += rw | |
| 802 | + | } | |
| 803 | + | continue | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | // Token fits on a fresh continuation line; break first, then | |
| 807 | + | // emit it whole. | |
| 784 | 808 | if cur.Len() > 0 && !(firstLine && curW == 0) { | |
| 785 | - | // Drop trailing whitespace on the line we're about to break. | |
| 786 | 809 | trimmed := strings.TrimRight(cur.String(), " \t") | |
| 787 | 810 | cur.Reset() | |
| 788 | 811 | cur.WriteString(trimmed) | |
| 789 | 812 | curW = visibleWidth(trimmed) | |
| 790 | 813 | newLine() | |
| 791 | - | // Re-process this token on the fresh line. | |
| 792 | 814 | if tk.space { | |
| 793 | 815 | continue | |
| 794 | 816 | } | |
| 795 | 817 | } | |
| 796 | - | ||
| 797 | - | // Token is longer than the available width on an empty line. | |
| 798 | - | // Split the token itself rune-by-rune. | |
| 799 | - | if tk.width > width-curW { | |
| 800 | - | for _, r := range tk.text { | |
| 801 | - | rw := runewidth.RuneWidth(r) | |
| 802 | - | if curW+rw > width { | |
| 803 | - | newLine() | |
| 804 | - | } | |
| 805 | - | cur.WriteRune(r) | |
| 806 | - | curW += rw | |
| 807 | - | } | |
| 808 | - | } else { | |
| 809 | - | cur.WriteString(tk.text) | |
| 810 | - | curW += tk.width | |
| 811 | - | } | |
| 818 | + | cur.WriteString(tk.text) | |
| 819 | + | curW += tk.width | |
| 812 | 820 | } | |
| 813 | 821 | ||
| 814 | 822 | if cur.Len() > 0 || len(out) == 0 { | |
| @@ -780,35 +780,43 @@ func wrapLine(s string, width int, cont string) []string { | |||||
| 780 | continue | 780 | continue | ||
| 781 | } | 781 | } | ||
| 782 | 782 | ||||
| 783 | - | // Token overflows. If current line has content, break first. | 783 | + | // Token overflows. |
| 784 | + | // | |||
| 785 | + | // If the token on its own is wider than what fits on a | |||
| 786 | + | // continuation line (width - contW), it'll need to be split | |||
| 787 | + | // rune-by-rune no matter what. In that case, don't break | |||
| 788 | + | // first: let the rune-split start right here at the current | |||
| 789 | + | // column and wrap naturally. Breaking first would strand the | |||
| 790 | + | // prompt (on firstLine) or the cont indent (on later lines) | |||
| 791 | + | // on a row by itself, which is the drag-drop-long-path bug. | |||
| 792 | + | if tk.width > width-contW { | |||
| 793 | + | // Rune-by-rune starting from the current column. newLine() | |||
| 794 | + | // inside handles wrapping and re-indents. | |||
| 795 | + | for _, r := range tk.text { | |||
| 796 | + | rw := runewidth.RuneWidth(r) | |||
| 797 | + | if curW+rw > width { | |||
| 798 | + | newLine() | |||
| 799 | + | } | |||
| 800 | + | cur.WriteRune(r) | |||
| 801 | + | curW += rw | |||
| 802 | + | } | |||
| 803 | + | continue | |||
| 804 | + | } | |||
| 805 | + | ||||
| 806 | + | // Token fits on a fresh continuation line; break first, then | |||
| 807 | + | // emit it whole. | |||
| 784 | if cur.Len() > 0 && !(firstLine && curW == 0) { | 808 | if cur.Len() > 0 && !(firstLine && curW == 0) { | ||
| 785 | - | // Drop trailing whitespace on the line we're about to break. | |||
| 786 | trimmed := strings.TrimRight(cur.String(), " \t") | 809 | trimmed := strings.TrimRight(cur.String(), " \t") | ||
| 787 | cur.Reset() | 810 | cur.Reset() | ||
| 788 | cur.WriteString(trimmed) | 811 | cur.WriteString(trimmed) | ||
| 789 | curW = visibleWidth(trimmed) | 812 | curW = visibleWidth(trimmed) | ||
| 790 | newLine() | 813 | newLine() | ||
| 791 | - | // Re-process this token on the fresh line. | |||
| 792 | if tk.space { | 814 | if tk.space { | ||
| 793 | continue | 815 | continue | ||
| 794 | } | 816 | } | ||
| 795 | } | 817 | } | ||
| 796 | - | 818 | + | cur.WriteString(tk.text) | |
| 797 | - | // Token is longer than the available width on an empty line. | 819 | + | curW += tk.width |
| 798 | - | // Split the token itself rune-by-rune. | |||
| 799 | - | if tk.width > width-curW { | |||
| 800 | - | for _, r := range tk.text { | |||
| 801 | - | rw := runewidth.RuneWidth(r) | |||
| 802 | - | if curW+rw > width { | |||
| 803 | - | newLine() | |||
| 804 | - | } | |||
| 805 | - | cur.WriteRune(r) | |||
| 806 | - | curW += rw | |||
| 807 | - | } | |||
| 808 | - | } else { | |||
| 809 | - | cur.WriteString(tk.text) | |||
| 810 | - | curW += tk.width | |||
| 811 | - | } | |||
| 812 | } | 820 | } | ||
| 813 | 821 | ||||
| 814 | if cur.Len() > 0 || len(out) == 0 { | 822 | if cur.Len() > 0 || len(out) == 0 { | ||
M internal/tui/view.go +5 -1
| @@ -983,7 +983,11 @@ func StatusBar(p StatusBarParams) []string { | |||
| 983 | 983 | ||
| 984 | 984 | var leftBuilder strings.Builder | |
| 985 | 985 | if p.BusyPrefix != "" { | |
| 986 | - | leftBuilder.WriteString(th.FG256(th.Accent, pad+p.BusyPrefix)) | |
| 986 | + | // Don't re-color the payload itself — the caller already set | |
| 987 | + | // fg colors on the spinner glyph, message, and elapsed | |
| 988 | + | // segments. Wrapping the whole thing here would override | |
| 989 | + | // those choices. The pad itself needs no color (it's spaces). | |
| 990 | + | leftBuilder.WriteString(pad + p.BusyPrefix) | |
| 987 | 991 | leftBuilder.WriteString(pad) | |
| 988 | 992 | } | |
| 989 | 993 | leftBuilder.WriteString(pad) | |
| @@ -983,7 +983,11 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 983 | 983 | ||||
| 984 | var leftBuilder strings.Builder | 984 | var leftBuilder strings.Builder | ||
| 985 | if p.BusyPrefix != "" { | 985 | if p.BusyPrefix != "" { | ||
| 986 | - | leftBuilder.WriteString(th.FG256(th.Accent, pad+p.BusyPrefix)) | 986 | + | // Don't re-color the payload itself — the caller already set |
| 987 | + | // fg colors on the spinner glyph, message, and elapsed | |||
| 988 | + | // segments. Wrapping the whole thing here would override | |||
| 989 | + | // those choices. The pad itself needs no color (it's spaces). | |||
| 990 | + | leftBuilder.WriteString(pad + p.BusyPrefix) | |||
| 987 | leftBuilder.WriteString(pad) | 991 | leftBuilder.WriteString(pad) | ||
| 988 | } | 992 | } | ||
| 989 | leftBuilder.WriteString(pad) | 993 | leftBuilder.WriteString(pad) | ||
M internal/tui/wrap_test.go +52 -0
| @@ -83,3 +83,55 @@ func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) { | |||
| 83 | 83 | t.Errorf("visual col: want 7, got %d", col) | |
| 84 | 84 | } | |
| 85 | 85 | } | |
| 86 | + | ||
| 87 | + | // TestWrapLongTokenKeepsPromptOnFirstLine guards against a bug where | |
| 88 | + | // pasting a single very long path (no spaces within it) stranded the | |
| 89 | + | // prompt on its own line and started the path on line 2 with the | |
| 90 | + | // cont indent. That happened because wrapLine broke to a new line | |
| 91 | + | // before checking whether the oncoming token would need rune-by-rune | |
| 92 | + | // splitting anyway. Stranding the prompt offset locateCursor's | |
| 93 | + | // rune walk and the terminal cursor drew in the wrong column after | |
| 94 | + | // any typing past the paste. | |
| 95 | + | func TestWrapLongTokenKeepsPromptOnFirstLine(t *testing.T) { | |
| 96 | + | // Long single token (a path) + prompt "▌ ". Width 40, the token | |
| 97 | + | // itself is way longer than (40 - cont). | |
| 98 | + | s := "▌ /var/folders/xq/hdh5qm6j66nbzd0sh3ljsxyc0000gn/T/TemporaryItems/verylongnamehere" | |
| 99 | + | out := wrapLine(s, 40, " ") | |
| 100 | + | ||
| 101 | + | if len(out) < 2 { | |
| 102 | + | t.Fatalf("expected multiple wrapped rows, got %d: %v", len(out), out) | |
| 103 | + | } | |
| 104 | + | // Row 0 must contain BOTH the prompt and the start of the path, | |
| 105 | + | // not just the prompt alone. A stranded-prompt regression would | |
| 106 | + | // show up as a short first row and the path starting on row 1. | |
| 107 | + | if !strings.HasPrefix(out[0], "▌ /") { | |
| 108 | + | t.Errorf("row 0 should start with the prompt AND path content, got %q", out[0]) | |
| 109 | + | } | |
| 110 | + | if visibleWidth(out[0]) < 20 { | |
| 111 | + | t.Errorf("row 0 width %d too small; prompt got stranded", visibleWidth(out[0])) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // TestEditorCursorAfterLongPathPaste verifies the on-screen cursor | |
| 116 | + | // lines up with the logical end of the buffer after a drag-dropped | |
| 117 | + | // long path is pasted AND the user types additional characters. | |
| 118 | + | // Screenshot scenario from 20:15 on 2026-04-19. | |
| 119 | + | func TestEditorCursorAfterLongPathPaste(t *testing.T) { | |
| 120 | + | e := NewEditor("▌ ") | |
| 121 | + | path := "/very/long/path/that/exceeds/our/terminal/width/by/a/comfortable/margin/file.png" | |
| 122 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: path}) | |
| 123 | + | for _, r := range "hello" { | |
| 124 | + | e.HandleKey(Key{Kind: KeyRune, Rune: r}) | |
| 125 | + | } | |
| 126 | + | lines, row, col := e.Render(40) | |
| 127 | + | ||
| 128 | + | // Cursor must be on the last rendered row. | |
| 129 | + | if row != len(lines)-1 { | |
| 130 | + | t.Errorf("visual row: want %d (last), got %d (lines: %v)", len(lines)-1, row, lines) | |
| 131 | + | } | |
| 132 | + | // And its column should equal the width of the last row (cursor | |
| 133 | + | // at end-of-line). | |
| 134 | + | if col != visibleWidth(lines[len(lines)-1]) { | |
| 135 | + | t.Errorf("visual col %d != last-row width %d", col, visibleWidth(lines[len(lines)-1])) | |
| 136 | + | } | |
| 137 | + | } | |
| @@ -83,3 +83,55 @@ func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) { | |||||
| 83 | t.Errorf("visual col: want 7, got %d", col) | 83 | t.Errorf("visual col: want 7, got %d", col) | ||
| 84 | } | 84 | } | ||
| 85 | } | 85 | } | ||
| 86 | + | ||||
| 87 | + | // TestWrapLongTokenKeepsPromptOnFirstLine guards against a bug where | |||
| 88 | + | // pasting a single very long path (no spaces within it) stranded the | |||
| 89 | + | // prompt on its own line and started the path on line 2 with the | |||
| 90 | + | // cont indent. That happened because wrapLine broke to a new line | |||
| 91 | + | // before checking whether the oncoming token would need rune-by-rune | |||
| 92 | + | // splitting anyway. Stranding the prompt offset locateCursor's | |||
| 93 | + | // rune walk and the terminal cursor drew in the wrong column after | |||
| 94 | + | // any typing past the paste. | |||
| 95 | + | func TestWrapLongTokenKeepsPromptOnFirstLine(t *testing.T) { | |||
| 96 | + | // Long single token (a path) + prompt "▌ ". Width 40, the token | |||
| 97 | + | // itself is way longer than (40 - cont). | |||
| 98 | + | s := "▌ /var/folders/xq/hdh5qm6j66nbzd0sh3ljsxyc0000gn/T/TemporaryItems/verylongnamehere" | |||
| 99 | + | out := wrapLine(s, 40, " ") | |||
| 100 | + | ||||
| 101 | + | if len(out) < 2 { | |||
| 102 | + | t.Fatalf("expected multiple wrapped rows, got %d: %v", len(out), out) | |||
| 103 | + | } | |||
| 104 | + | // Row 0 must contain BOTH the prompt and the start of the path, | |||
| 105 | + | // not just the prompt alone. A stranded-prompt regression would | |||
| 106 | + | // show up as a short first row and the path starting on row 1. | |||
| 107 | + | if !strings.HasPrefix(out[0], "▌ /") { | |||
| 108 | + | t.Errorf("row 0 should start with the prompt AND path content, got %q", out[0]) | |||
| 109 | + | } | |||
| 110 | + | if visibleWidth(out[0]) < 20 { | |||
| 111 | + | t.Errorf("row 0 width %d too small; prompt got stranded", visibleWidth(out[0])) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // TestEditorCursorAfterLongPathPaste verifies the on-screen cursor | |||
| 116 | + | // lines up with the logical end of the buffer after a drag-dropped | |||
| 117 | + | // long path is pasted AND the user types additional characters. | |||
| 118 | + | // Screenshot scenario from 20:15 on 2026-04-19. | |||
| 119 | + | func TestEditorCursorAfterLongPathPaste(t *testing.T) { | |||
| 120 | + | e := NewEditor("▌ ") | |||
| 121 | + | path := "/very/long/path/that/exceeds/our/terminal/width/by/a/comfortable/margin/file.png" | |||
| 122 | + | e.HandleKey(Key{Kind: KeyPaste, Paste: path}) | |||
| 123 | + | for _, r := range "hello" { | |||
| 124 | + | e.HandleKey(Key{Kind: KeyRune, Rune: r}) | |||
| 125 | + | } | |||
| 126 | + | lines, row, col := e.Render(40) | |||
| 127 | + | ||||
| 128 | + | // Cursor must be on the last rendered row. | |||
| 129 | + | if row != len(lines)-1 { | |||
| 130 | + | t.Errorf("visual row: want %d (last), got %d (lines: %v)", len(lines)-1, row, lines) | |||
| 131 | + | } | |||
| 132 | + | // And its column should equal the width of the last row (cursor | |||
| 133 | + | // at end-of-line). | |||
| 134 | + | if col != visibleWidth(lines[len(lines)-1]) { | |||
| 135 | + | t.Errorf("visual col %d != last-row width %d", col, visibleWidth(lines[len(lines)-1])) | |||
| 136 | + | } | |||
| 137 | + | } | |||