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{ | |||
| 23 | 23 | {"ctrl+l", "redraw the screen"}, | |
| 24 | 24 | {"ctrl+o", "expand / collapse long tool results"}, | |
| 25 | 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 | 29 | // 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 shared | 29 | // 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) { | |||
| 1889 | 1889 | } | |
| 1890 | 1890 | i.mu.Unlock() | |
| 1891 | 1891 | } | |
| 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. | |
| 1897 | 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 | 1900 | i.scrollBy(+3) | |
| 1899 | 1901 | return false | |
| 1900 | 1902 | } | |
| 1901 | 1903 | case tui.KeyDown: | |
| 1902 | 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 | 1909 | if i.scrollOffset > 0 { | |
| 1904 | 1910 | i.scrollBy(-3) | |
| 1905 | 1911 | } | |
| @@ -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 editor | 1892 | + | // In multi-line / wrapped input, Up first moves inside the editor. |
| 1893 | - | // contains text. This makes keyboard scrolling consistent with | 1893 | + | // At the editor's top edge it falls back to chat scrolling, preserving |
| 1894 | - | // a draft present at the cost of disabling vertical cursor | 1894 | + | // 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 false | 1901 | 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) { | |||
| 161 | 161 | // subsumed: a visual row above may also live in the | |
| 162 | 162 | // previous logical line when a short line precedes a | |
| 163 | 163 | // wrapped one. | |
| 164 | - | e.moveCursorVisual(-1) | |
| 164 | + | e.MoveVertical(-1) | |
| 165 | 165 | case KeyDown: | |
| 166 | - | e.moveCursorVisual(+1) | |
| 166 | + | e.MoveVertical(+1) | |
| 167 | 167 | case KeyHome, KeyCtrlA: | |
| 168 | 168 | e.CursorC = 0 | |
| 169 | 169 | case KeyEnd, KeyCtrlE: | |
| @@ -570,14 +570,17 @@ func (e *Editor) moveRight() { | |||
| 570 | 570 | // (minus the target row's leading indent) to a rune index inside | |
| 571 | 571 | // that row's slice of its logical line. No-op at the top/bottom | |
| 572 | 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 | 578 | width := e.lastRenderWidth | |
| 575 | 579 | if width <= 0 { | |
| 576 | 580 | // Fall back to logical-line navigation if Render hasn't | |
| 577 | 581 | // been called yet (shouldn't happen in practice; the | |
| 578 | 582 | // host always renders once before accepting input). | |
| 579 | - | e.moveCursorLogical(dir) | |
| 580 | - | return | |
| 583 | + | return e.moveCursorLogical(dir) | |
| 581 | 584 | } | |
| 582 | 585 | ||
| 583 | 586 | type vrow struct { | |
| @@ -588,7 +591,11 @@ func (e *Editor) moveCursorVisual(dir int) { | |||
| 588 | 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 | 599 | indent := strings.Repeat(" ", promptLen) | |
| 593 | 600 | ||
| 594 | 601 | var rows []vrow | |
| @@ -596,7 +603,7 @@ func (e *Editor) moveCursorVisual(dir int) { | |||
| 596 | 603 | for r, line := range e.Lines { | |
| 597 | 604 | prefix := indent | |
| 598 | 605 | if r == 0 { | |
| 599 | - | prefix = e.Prompt | |
| 606 | + | prefix = plainPrompt | |
| 600 | 607 | } | |
| 601 | 608 | wrapped := wrapLine(prefix+line, width, indent) | |
| 602 | 609 | lineRunes := []rune(line) | |
| @@ -647,7 +654,7 @@ func (e *Editor) moveCursorVisual(dir int) { | |||
| 647 | 654 | ||
| 648 | 655 | target := curVRow + dir | |
| 649 | 656 | if target < 0 || target >= len(rows) { | |
| 650 | - | return | |
| 657 | + | return false | |
| 651 | 658 | } | |
| 652 | 659 | tr := rows[target] | |
| 653 | 660 | line := e.Lines[tr.logical] | |
| @@ -676,23 +683,25 @@ func (e *Editor) moveCursorVisual(dir int) { | |||
| 676 | 683 | _ = bestW | |
| 677 | 684 | e.CursorR = tr.logical | |
| 678 | 685 | e.CursorC = tr.runeStart + best | |
| 686 | + | return true | |
| 679 | 687 | } | |
| 680 | 688 | ||
| 681 | 689 | // moveCursorLogical is the pre-visual-navigation fallback used | |
| 682 | 690 | // when Render hasn't told us the terminal width yet. Walks the | |
| 683 | 691 | // e.Lines array directly. | |
| 684 | - | func (e *Editor) moveCursorLogical(dir int) { | |
| 692 | + | func (e *Editor) moveCursorLogical(dir int) bool { | |
| 685 | 693 | switch { | |
| 686 | 694 | case dir < 0 && e.CursorR > 0: | |
| 687 | 695 | e.CursorR-- | |
| 688 | 696 | case dir > 0 && e.CursorR < len(e.Lines)-1: | |
| 689 | 697 | e.CursorR++ | |
| 690 | 698 | default: | |
| 691 | - | return | |
| 699 | + | return false | |
| 692 | 700 | } | |
| 693 | 701 | if e.CursorC > runeLen(e.Lines[e.CursorR]) { | |
| 694 | 702 | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 695 | 703 | } | |
| 704 | + | return true | |
| 696 | 705 | } | |
| 697 | 706 | ||
| 698 | 707 | 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 the | 161 | // subsumed: a visual row above may also live in the | ||
| 162 | // previous logical line when a short line precedes a | 162 | // 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 = 0 | 168 | 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 inside | 570 | // (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/bottom | 571 | // 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.lastRenderWidth | 578 | width := e.lastRenderWidth | ||
| 575 | if width <= 0 { | 579 | if width <= 0 { | ||
| 576 | // Fall back to logical-line navigation if Render hasn't | 580 | // Fall back to logical-line navigation if Render hasn't | ||
| 577 | // been called yet (shouldn't happen in practice; the | 581 | // 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 []vrow | 601 | 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 := indent | 604 | prefix := indent | ||
| 598 | if r == 0 { | 605 | if r == 0 { | ||
| 599 | - | prefix = e.Prompt | 606 | + | 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 + dir | 655 | target := curVRow + dir | ||
| 649 | if target < 0 || target >= len(rows) { | 656 | if target < 0 || target >= len(rows) { | ||
| 650 | - | return | 657 | + | 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 | _ = bestW | 683 | _ = bestW | ||
| 677 | e.CursorR = tr.logical | 684 | e.CursorR = tr.logical | ||
| 678 | e.CursorC = tr.runeStart + best | 685 | e.CursorC = tr.runeStart + best | ||
| 686 | + | return true | |||
| 679 | } | 687 | } | ||
| 680 | 688 | ||||
| 681 | // moveCursorLogical is the pre-visual-navigation fallback used | 689 | // moveCursorLogical is the pre-visual-navigation fallback used | ||
| 682 | // when Render hasn't told us the terminal width yet. Walks the | 690 | // 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 | - | return | 699 | + | 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 | + | } | |||