detached 92e8475

Improve multiline input navigation

Let Up/Down move through visual rows in multiline or wrapped editor input before falling back to chat scrolling.

Fix styled-prompt wrap geometry so vertical movement preserves the target visual column, and update help text to describe the new behavior.
commit
92e84753303faa8dde8db04c3557dcb47792da23
author
patriceckhart · · 2026-05-24 09:38 UTC
parent
3364159
signature
unsigned
4 files · +53 -16
M internal/agent/modes/help.go +1 -1
@@ -23,7 +23,7 @@ var helpKeyRows = [][2]string{
2323 {"ctrl+l", "redraw the screen"},
2424 {"ctrl+o", "expand / collapse long tool results"},
2525 {"pgup / pgdn", "scroll the chat one page up / down"},
26- {"up / down", "scroll by 3 lines (when input is empty) - prompt history (otherwise)"},
26+ {"up / down", "move within multi-line input - scroll chat at input edge"},
2727 }
2828
2929 // renderHelpBlock builds the friendly /help view. Uses the shared
@@ -23,7 +23,7 @@ var helpKeyRows = [][2]string{
23 {"ctrl+l", "redraw the screen"},23 {"ctrl+l", "redraw the screen"},
24 {"ctrl+o", "expand / collapse long tool results"},24 {"ctrl+o", "expand / collapse long tool results"},
25 {"pgup / pgdn", "scroll the chat one page up / down"},25 {"pgup / pgdn", "scroll the chat one page up / down"},
26- {"up / down", "scroll by 3 lines (when input is empty) - prompt history (otherwise)"},26+ {"up / down", "move within multi-line input - scroll chat at input edge"},
27 }27 }
28 28
29 // renderHelpBlock builds the friendly /help view. Uses the shared29 // renderHelpBlock builds the friendly /help view. Uses the shared
M internal/agent/modes/interactive.go +11 -5
@@ -1889,17 +1889,23 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18891889 }
18901890 i.mu.Unlock()
18911891 }
1892- // Always use up/down for chat scrolling, even when the editor
1893- // contains text. This makes keyboard scrolling consistent with
1894- // a draft present at the cost of disabling vertical cursor
1895- // motion inside the multi-line editor. Keep slash-popup
1896- // navigation working by letting it intercept later when active.
1892+ // In multi-line / wrapped input, Up first moves inside the editor.
1893+ // At the editor's top edge it falls back to chat scrolling, preserving
1894+ // the old single-line scroll behavior.
18971895 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1896+ if i.ed.MoveVertical(-1) {
1897+ i.invalidate()
1898+ return false
1899+ }
18981900 i.scrollBy(+3)
18991901 return false
19001902 }
19011903 case tui.KeyDown:
19021904 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1905+ if i.ed.MoveVertical(+1) {
1906+ i.invalidate()
1907+ return false
1908+ }
19031909 if i.scrollOffset > 0 {
19041910 i.scrollBy(-3)
19051911 }
@@ -1889,17 +1889,23 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1889 }1889 }
1890 i.mu.Unlock()1890 i.mu.Unlock()
1891 }1891 }
1892- // Always use up/down for chat scrolling, even when the editor1892+ // In multi-line / wrapped input, Up first moves inside the editor.
1893- // contains text. This makes keyboard scrolling consistent with1893+ // At the editor's top edge it falls back to chat scrolling, preserving
1894- // a draft present at the cost of disabling vertical cursor1894+ // the old single-line scroll behavior.
1895- // motion inside the multi-line editor. Keep slash-popup
1896- // navigation working by letting it intercept later when active.
1897 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {1895 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1896+ if i.ed.MoveVertical(-1) {
1897+ i.invalidate()
1898+ return false
1899+ }
1898 i.scrollBy(+3)1900 i.scrollBy(+3)
1899 return false1901 return false
1900 }1902 }
1901 case tui.KeyDown:1903 case tui.KeyDown:
1902 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {1904 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
1905+ if i.ed.MoveVertical(+1) {
1906+ i.invalidate()
1907+ return false
1908+ }
1903 if i.scrollOffset > 0 {1909 if i.scrollOffset > 0 {
1904 i.scrollBy(-3)1910 i.scrollBy(-3)
1905 }1911 }
M internal/tui/editor.go +19 -10
@@ -161,9 +161,9 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
161161 // subsumed: a visual row above may also live in the
162162 // previous logical line when a short line precedes a
163163 // wrapped one.
164- e.moveCursorVisual(-1)
164+ e.MoveVertical(-1)
165165 case KeyDown:
166- e.moveCursorVisual(+1)
166+ e.MoveVertical(+1)
167167 case KeyHome, KeyCtrlA:
168168 e.CursorC = 0
169169 case KeyEnd, KeyCtrlE:
@@ -570,14 +570,17 @@ func (e *Editor) moveRight() {
570570 // (minus the target row's leading indent) to a rune index inside
571571 // that row's slice of its logical line. No-op at the top/bottom
572572 // edges of the whole buffer.
573-func (e *Editor) moveCursorVisual(dir int) {
573+// MoveVertical moves the cursor one visual row up/down through the
574+// rendered editor layout. It returns true when the cursor moved, false
575+// at the top/bottom edge. Callers can use false to fall back to outer
576+// UI scrolling.
577+func (e *Editor) MoveVertical(dir int) bool {
574578 width := e.lastRenderWidth
575579 if width <= 0 {
576580 // Fall back to logical-line navigation if Render hasn't
577581 // been called yet (shouldn't happen in practice; the
578582 // host always renders once before accepting input).
579- e.moveCursorLogical(dir)
580- return
583+ return e.moveCursorLogical(dir)
581584 }
582585
583586 type vrow struct {
@@ -588,7 +591,11 @@ func (e *Editor) moveCursorVisual(dir int) {
588591 leadPrefix string // the prefix used (prompt on row 0, indent on cont)
589592 }
590593
591- promptLen := visibleWidth(e.Prompt)
594+ // Match Render's layout exactly: strip ANSI from the prompt before
595+ // wrapping, otherwise escape bytes count as body runes and Up/Down
596+ // lands in the wrong visual cell when the prompt is styled.
597+ plainPrompt := stripANSI(e.Prompt)
598+ promptLen := runewidth.StringWidth(plainPrompt)
592599 indent := strings.Repeat(" ", promptLen)
593600
594601 var rows []vrow
@@ -596,7 +603,7 @@ func (e *Editor) moveCursorVisual(dir int) {
596603 for r, line := range e.Lines {
597604 prefix := indent
598605 if r == 0 {
599- prefix = e.Prompt
606+ prefix = plainPrompt
600607 }
601608 wrapped := wrapLine(prefix+line, width, indent)
602609 lineRunes := []rune(line)
@@ -647,7 +654,7 @@ func (e *Editor) moveCursorVisual(dir int) {
647654
648655 target := curVRow + dir
649656 if target < 0 || target >= len(rows) {
650- return
657+ return false
651658 }
652659 tr := rows[target]
653660 line := e.Lines[tr.logical]
@@ -676,23 +683,25 @@ func (e *Editor) moveCursorVisual(dir int) {
676683 _ = bestW
677684 e.CursorR = tr.logical
678685 e.CursorC = tr.runeStart + best
686+ return true
679687 }
680688
681689 // moveCursorLogical is the pre-visual-navigation fallback used
682690 // when Render hasn't told us the terminal width yet. Walks the
683691 // e.Lines array directly.
684-func (e *Editor) moveCursorLogical(dir int) {
692+func (e *Editor) moveCursorLogical(dir int) bool {
685693 switch {
686694 case dir < 0 && e.CursorR > 0:
687695 e.CursorR--
688696 case dir > 0 && e.CursorR < len(e.Lines)-1:
689697 e.CursorR++
690698 default:
691- return
699+ return false
692700 }
693701 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
694702 e.CursorC = runeLen(e.Lines[e.CursorR])
695703 }
704+ return true
696705 }
697706
698707 func (e *Editor) deleteWord() {
@@ -161,9 +161,9 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
161 // subsumed: a visual row above may also live in the161 // subsumed: a visual row above may also live in the
162 // previous logical line when a short line precedes a162 // previous logical line when a short line precedes a
163 // wrapped one.163 // wrapped one.
164- e.moveCursorVisual(-1)164+ e.MoveVertical(-1)
165 case KeyDown:165 case KeyDown:
166- e.moveCursorVisual(+1)166+ e.MoveVertical(+1)
167 case KeyHome, KeyCtrlA:167 case KeyHome, KeyCtrlA:
168 e.CursorC = 0168 e.CursorC = 0
169 case KeyEnd, KeyCtrlE:169 case KeyEnd, KeyCtrlE:
@@ -570,14 +570,17 @@ func (e *Editor) moveRight() {
570 // (minus the target row's leading indent) to a rune index inside570 // (minus the target row's leading indent) to a rune index inside
571 // that row's slice of its logical line. No-op at the top/bottom571 // that row's slice of its logical line. No-op at the top/bottom
572 // edges of the whole buffer.572 // edges of the whole buffer.
573-func (e *Editor) moveCursorVisual(dir int) {573+// MoveVertical moves the cursor one visual row up/down through the
574+// rendered editor layout. It returns true when the cursor moved, false
575+// at the top/bottom edge. Callers can use false to fall back to outer
576+// UI scrolling.
577+func (e *Editor) MoveVertical(dir int) bool {
574 width := e.lastRenderWidth578 width := e.lastRenderWidth
575 if width <= 0 {579 if width <= 0 {
576 // Fall back to logical-line navigation if Render hasn't580 // Fall back to logical-line navigation if Render hasn't
577 // been called yet (shouldn't happen in practice; the581 // been called yet (shouldn't happen in practice; the
578 // host always renders once before accepting input).582 // host always renders once before accepting input).
579- e.moveCursorLogical(dir)583+ return e.moveCursorLogical(dir)
580- return
581 }584 }
582 585
583 type vrow struct {586 type vrow struct {
@@ -588,7 +591,11 @@ func (e *Editor) moveCursorVisual(dir int) {
588 leadPrefix string // the prefix used (prompt on row 0, indent on cont)591 leadPrefix string // the prefix used (prompt on row 0, indent on cont)
589 }592 }
590 593
591- promptLen := visibleWidth(e.Prompt)594+ // Match Render's layout exactly: strip ANSI from the prompt before
595+ // wrapping, otherwise escape bytes count as body runes and Up/Down
596+ // lands in the wrong visual cell when the prompt is styled.
597+ plainPrompt := stripANSI(e.Prompt)
598+ promptLen := runewidth.StringWidth(plainPrompt)
592 indent := strings.Repeat(" ", promptLen)599 indent := strings.Repeat(" ", promptLen)
593 600
594 var rows []vrow601 var rows []vrow
@@ -596,7 +603,7 @@ func (e *Editor) moveCursorVisual(dir int) {
596 for r, line := range e.Lines {603 for r, line := range e.Lines {
597 prefix := indent604 prefix := indent
598 if r == 0 {605 if r == 0 {
599- prefix = e.Prompt606+ prefix = plainPrompt
600 }607 }
601 wrapped := wrapLine(prefix+line, width, indent)608 wrapped := wrapLine(prefix+line, width, indent)
602 lineRunes := []rune(line)609 lineRunes := []rune(line)
@@ -647,7 +654,7 @@ func (e *Editor) moveCursorVisual(dir int) {
647 654
648 target := curVRow + dir655 target := curVRow + dir
649 if target < 0 || target >= len(rows) {656 if target < 0 || target >= len(rows) {
650- return657+ return false
651 }658 }
652 tr := rows[target]659 tr := rows[target]
653 line := e.Lines[tr.logical]660 line := e.Lines[tr.logical]
@@ -676,23 +683,25 @@ func (e *Editor) moveCursorVisual(dir int) {
676 _ = bestW683 _ = bestW
677 e.CursorR = tr.logical684 e.CursorR = tr.logical
678 e.CursorC = tr.runeStart + best685 e.CursorC = tr.runeStart + best
686+ return true
679 }687 }
680 688
681 // moveCursorLogical is the pre-visual-navigation fallback used689 // moveCursorLogical is the pre-visual-navigation fallback used
682 // when Render hasn't told us the terminal width yet. Walks the690 // when Render hasn't told us the terminal width yet. Walks the
683 // e.Lines array directly.691 // e.Lines array directly.
684-func (e *Editor) moveCursorLogical(dir int) {692+func (e *Editor) moveCursorLogical(dir int) bool {
685 switch {693 switch {
686 case dir < 0 && e.CursorR > 0:694 case dir < 0 && e.CursorR > 0:
687 e.CursorR--695 e.CursorR--
688 case dir > 0 && e.CursorR < len(e.Lines)-1:696 case dir > 0 && e.CursorR < len(e.Lines)-1:
689 e.CursorR++697 e.CursorR++
690 default:698 default:
691- return699+ return false
692 }700 }
693 if e.CursorC > runeLen(e.Lines[e.CursorR]) {701 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
694 e.CursorC = runeLen(e.Lines[e.CursorR])702 e.CursorC = runeLen(e.Lines[e.CursorR])
695 }703 }
704+ return true
696 }705 }
697 706
698 func (e *Editor) deleteWord() {707 func (e *Editor) deleteWord() {
A internal/tui/editor_vertical_test.go +22 -0
@@ -0,0 +1,22 @@
1+package tui
2+
3+import "testing"
4+
5+func TestEditorMoveVerticalStyledPromptKeepsColumn(t *testing.T) {
6+ e := NewEditor("\x1b[38;5;111m▌ \x1b[0m")
7+ e.SetValue("alpha beta gamma delta epsilon zeta eta theta")
8+ _, _, _ = e.Render(18)
9+
10+ // Put cursor on a lower wrapped row, then move up. With the styled
11+ // prompt included in wrap geometry this used to jump near the start of
12+ // the top row instead of the cell directly above.
13+ e.CursorC = 28
14+ _, _, beforeCol := e.Render(18)
15+ if !e.MoveVertical(-1) {
16+ t.Fatal("expected vertical move")
17+ }
18+ _, _, afterCol := e.Render(18)
19+ if afterCol != beforeCol {
20+ t.Fatalf("column changed: before=%d after=%d cursor=%d", beforeCol, afterCol, e.CursorC)
21+ }
22+}
@@ -0,0 +1,22 @@
1+package tui
2+
3+import "testing"
4+
5+func TestEditorMoveVerticalStyledPromptKeepsColumn(t *testing.T) {
6+ e := NewEditor("\x1b[38;5;111m▌ \x1b[0m")
7+ e.SetValue("alpha beta gamma delta epsilon zeta eta theta")
8+ _, _, _ = e.Render(18)
9+
10+ // Put cursor on a lower wrapped row, then move up. With the styled
11+ // prompt included in wrap geometry this used to jump near the start of
12+ // the top row instead of the cell directly above.
13+ e.CursorC = 28
14+ _, _, beforeCol := e.Render(18)
15+ if !e.MoveVertical(-1) {
16+ t.Fatal("expected vertical move")
17+ }
18+ _, _, afterCol := e.Render(18)
19+ if afterCol != beforeCol {
20+ t.Fatalf("column changed: before=%d after=%d cursor=%d", beforeCol, afterCol, e.CursorC)
21+ }
22+}

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