detached 3e59a3c

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 {
298298 // rotating funny-line, elapsed seconds, then a muted hint
299299 // that esc cancels.
300300 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()),
303303 th.FG256(th.Muted, d.spin.Elapsed().String()),
304304 )
305305 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 hint298 // 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 (
44 "fmt"
55 "strings"
66
7+ "github.com/mattn/go-runewidth"
78 "github.com/patriceckhart/zot/internal/tui"
89 )
910
@@ -40,23 +41,31 @@ func renderHelpBlock(th tui.Theme, width int) []string {
4041 width = 20
4142 }
4243
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).
4351 labelWidth := 14
4452 for _, c := range slashCatalog {
45- if n := len(c.Name); n > labelWidth {
53+ if n := runewidth.StringWidth(c.Name); n > labelWidth {
4654 labelWidth = n
4755 }
4856 }
4957 for _, k := range helpKeyRows {
50- if n := len(k[0]); n > labelWidth {
58+ if n := runewidth.StringWidth(k[0]); n > labelWidth {
5159 labelWidth = n
5260 }
5361 }
5462
5563 pad := func(s string) string {
56- if len(s) >= labelWidth {
64+ w := runewidth.StringWidth(s)
65+ if w >= labelWidth {
5766 return s
5867 }
59- return s + strings.Repeat(" ", labelWidth-len(s))
68+ return s + strings.Repeat(" ", labelWidth-w)
6069 }
6170
6271 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 = 2041 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 := 1451 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 = n54 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 = n59 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 s66 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 []string71 var out []string
M internal/agent/modes/interactive.go +7 -3
@@ -611,12 +611,16 @@ func (i *Interactive) redraw() {
611611 suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
612612 }
613613
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.
615619 var busyPrefix string
616620 if i.busy {
617621 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()),
620624 i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
621625 )
622626 }
@@ -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 string619 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 {
780780 continue
781781 }
782782
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.
784808 if cur.Len() > 0 && !(firstLine && curW == 0) {
785- // Drop trailing whitespace on the line we're about to break.
786809 trimmed := strings.TrimRight(cur.String(), " \t")
787810 cur.Reset()
788811 cur.WriteString(trimmed)
789812 curW = visibleWidth(trimmed)
790813 newLine()
791- // Re-process this token on the fresh line.
792814 if tk.space {
793815 continue
794816 }
795817 }
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
812820 }
813821
814822 if cur.Len() > 0 || len(out) == 0 {
@@ -780,35 +780,43 @@ func wrapLine(s string, width int, cont string) []string {
780 continue780 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 continue815 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 {
983983
984984 var leftBuilder strings.Builder
985985 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)
987991 leftBuilder.WriteString(pad)
988992 }
989993 leftBuilder.WriteString(pad)
@@ -983,7 +983,11 @@ func StatusBar(p StatusBarParams) []string {
983 983
984 var leftBuilder strings.Builder984 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) {
8383 t.Errorf("visual col: want 7, got %d", col)
8484 }
8585 }
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+}

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