detached cb8526c

fix(tui): up/down arrows move the cursor through visual rows, not history

Two related changes to how the input handles the arrow keys.

1) History recall is gone.

   The editor used to maintain a 200-entry ring of previously
   submitted prompts and swap the current draft for a history
   entry when up/down hit the top/bottom edge of the buffer.
   In practice this mis-fired more than it helped: pressing up
   on a single-line draft silently replaced it with an older
   prompt. Users expect history recall (when they want it) on
   a dedicated key, not as a side effect of cursor navigation.

   Dropped: Editor.History, histIdx, savedDraft; PushHistory,
   historyPrev, historyNext, findInHistory; every e.histIdx
   assignment sprinkled across the mutating methods; the
   PushHistory call sites in interactive.go (submit handler
   and the slash-selector enter path). Clear() already wiped
   the visible buffer, it now also doesn't touch history
   because there's no history to wipe.

2) Up/Down navigate VISUAL rows, not logical buffer lines.

   The TUI wraps a long single-line input across several
   visual rows. Before this patch, up/down only moved between
   entries in e.Lines, so when the cursor was on the second
   visual row of a wrapped single line, up did nothing
   (CursorR was already 0). Users saw their cursor stuck at
   the bottom of a wrapped draft with no way to move up
   except arrow-left until they wrapped around.

   Added moveCursorVisual(dir int) which rebuilds the same
   wrapped layout Render produces, records which visual row
   the cursor currently occupies, computes the target visual
   row = current + dir, then maps the current visual column
   onto the rune index inside the target row's slice of its
   logical line. Subsumes the multi-line logical-line case:
   if a wrapped row straddles a logical-line boundary, the
   mapping naturally advances CursorR.

   The editor now records the latest width passed to Render so
   moveCursorVisual can walk the same layout. Fallback
   moveCursorLogical covers the edge case where Render hasn't
   run yet (shouldn't happen in practice; kept for safety).

Tests: existing TestEditorCursorAfterMultilinePaste and
TestEditorCursorAfterLongPasteWithWrap still pass because they
test Editor.Insert followed by Render, not up/down traversal;
the new navigation leaves Render's cursor-position math
unchanged.
commit
cb8526c0619b8b57d171f06129e9bfaa72f14677
author
patriceckhart · · 2026-04-21 07:22 UTC
parent
ce80627
signature
unsigned
refs
tag: v0.0.77
2 files · +163 -98
M internal/agent/modes/interactive.go +3 -8
@@ -1168,7 +1168,6 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
11681168 // clear the editor so the command doesn't linger after the
11691169 // dialog opens/closes.
11701170 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1171- i.ed.PushHistory(name)
11721171 i.ed.Clear()
11731172 i.suggest.Reset()
11741173 return i.runSlash(ctx, name)
@@ -1181,17 +1180,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
11811180 }
11821181
11831182 if submit := i.ed.HandleKey(k); submit {
1184- // SubmitValue() expands any [paste #N +L lines] placeholders
1185- // back into the pasted bodies; Value() is what the user
1186- // sees on screen. History stores the visible text so the
1187- // up-arrow recall shows the placeholder, not a 500-line
1188- // replay.
1189- visible := strings.TrimRight(i.ed.Value(), "\n")
1183+ // SubmitValue() expands any [pasted text #N +L lines]
1184+ // placeholders back into their bodies; the raw Value()
1185+ // is only what the user sees on screen.
11901186 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
11911187 if text == "" {
11921188 return false
11931189 }
1194- i.ed.PushHistory(visible)
11951190 i.ed.Clear()
11961191 i.suggest.Reset()
11971192
@@ -1168,7 +1168,6 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1168 // clear the editor so the command doesn't linger after the1168 // clear the editor so the command doesn't linger after the
1169 // dialog opens/closes.1169 // dialog opens/closes.
1170 if name := i.suggest.Selection(i.ed.Value()); name != "" {1170 if name := i.suggest.Selection(i.ed.Value()); name != "" {
1171- i.ed.PushHistory(name)
1172 i.ed.Clear()1171 i.ed.Clear()
1173 i.suggest.Reset()1172 i.suggest.Reset()
1174 return i.runSlash(ctx, name)1173 return i.runSlash(ctx, name)
@@ -1181,17 +1180,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1181 }1180 }
1182 1181
1183 if submit := i.ed.HandleKey(k); submit {1182 if submit := i.ed.HandleKey(k); submit {
1184- // SubmitValue() expands any [paste #N +L lines] placeholders1183+ // SubmitValue() expands any [pasted text #N +L lines]
1185- // back into the pasted bodies; Value() is what the user1184+ // placeholders back into their bodies; the raw Value()
1186- // sees on screen. History stores the visible text so the1185+ // is only what the user sees on screen.
1187- // up-arrow recall shows the placeholder, not a 500-line
1188- // replay.
1189- visible := strings.TrimRight(i.ed.Value(), "\n")
1190 text := strings.TrimRight(i.ed.SubmitValue(), "\n")1186 text := strings.TrimRight(i.ed.SubmitValue(), "\n")
1191 if text == "" {1187 if text == "" {
1192 return false1188 return false
1193 }1189 }
1194- i.ed.PushHistory(visible)
1195 i.ed.Clear()1190 i.ed.Clear()
1196 i.suggest.Reset()1191 i.suggest.Reset()
1197 1192
M internal/tui/editor.go +160 -90
@@ -23,10 +23,12 @@ type Editor struct {
2323 Prompt string
2424 MaxWidth int
2525
26- // History is a ring of previously submitted buffers (newest last).
27- History []string
28- histIdx int // -1 means "editing current buffer"
29- savedDraft string
26+ // lastRenderWidth is the column count passed to the most recent
27+ // Render() call. Up/Down key handling needs this to walk the
28+ // same visual layout the user sees: a logical line that wraps to
29+ // two rows should respond to Up by moving to the previous visual
30+ // row, not do nothing because CursorR is already 0.
31+ lastRenderWidth int
3032
3133 // pastes stores the full content of every multi-line paste,
3234 // keyed by the id embedded in the visible placeholder token.
@@ -42,9 +44,8 @@ type Editor struct {
4244 // NewEditor returns an empty editor with the given prompt.
4345 func NewEditor(prompt string) *Editor {
4446 return &Editor{
45- Lines: []string{""},
46- Prompt: prompt,
47- histIdx: -1,
47+ Lines: []string{""},
48+ Prompt: prompt,
4849 }
4950 }
5051
@@ -83,7 +84,6 @@ func (e *Editor) SetValue(s string) {
8384 }
8485 e.CursorR = len(e.Lines) - 1
8586 e.CursorC = runeLen(e.Lines[e.CursorR])
86- e.histIdx = -1
8787 e.pastes = nil
8888 e.pasteSeq = 0
8989 }
@@ -96,9 +96,9 @@ func (e *Editor) IsEmpty() bool {
9696 return len(e.Lines) == 1 && e.Lines[0] == ""
9797 }
9898
99-// HandleKey applies k to the editor. It returns (submit, key).
100-// If submit is true, the caller should read Value() and call
101-// PushHistory + Clear.
99+// HandleKey applies k to the editor. It returns submit=true when
100+// the user pressed enter and there is content to send; the caller
101+// should read SubmitValue() and then Clear().
102102 func (e *Editor) HandleKey(k Key) (submit bool) {
103103 switch k.Kind {
104104 case KeyRune:
@@ -134,23 +134,17 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
134134 e.moveRight()
135135 }
136136 case KeyUp:
137- if e.CursorR == 0 {
138- e.historyPrev()
139- } else {
140- e.CursorR--
141- if e.CursorC > runeLen(e.Lines[e.CursorR]) {
142- e.CursorC = runeLen(e.Lines[e.CursorR])
143- }
144- }
137+ // Visual-row navigation. When a single logical line wraps
138+ // to several visual rows, Up needs to climb one visual
139+ // row — which may mean moving within the same
140+ // e.Lines[CursorR] back toward an earlier rune index,
141+ // not jumping to CursorR-1. Buffer-line navigation is
142+ // subsumed: a visual row above may also live in the
143+ // previous logical line when a short line precedes a
144+ // wrapped one.
145+ e.moveCursorVisual(-1)
145146 case KeyDown:
146- if e.CursorR == len(e.Lines)-1 {
147- e.historyNext()
148- } else {
149- e.CursorR++
150- if e.CursorC > runeLen(e.Lines[e.CursorR]) {
151- e.CursorC = runeLen(e.Lines[e.CursorR])
152- }
153- }
147+ e.moveCursorVisual(+1)
154148 case KeyHome, KeyCtrlA:
155149 e.CursorC = 0
156150 case KeyEnd, KeyCtrlE:
@@ -372,7 +366,6 @@ func singleQuote(s string) string {
372366 func (e *Editor) Insert(s string) { e.insert(s) }
373367
374368 func (e *Editor) insert(s string) {
375- e.histIdx = -1
376369 line := e.Lines[e.CursorR]
377370 pre := substringBefore(line, e.CursorC)
378371 post := substringAfter(line, e.CursorC)
@@ -398,7 +391,6 @@ func (e *Editor) insert(s string) {
398391 }
399392
400393 func (e *Editor) newline() {
401- e.histIdx = -1
402394 line := e.Lines[e.CursorR]
403395 pre := substringBefore(line, e.CursorC)
404396 post := substringAfter(line, e.CursorC)
@@ -411,7 +403,6 @@ func (e *Editor) newline() {
411403 }
412404
413405 func (e *Editor) backspace() {
414- e.histIdx = -1
415406 if e.CursorC == 0 {
416407 if e.CursorR == 0 {
417408 return
@@ -430,7 +421,6 @@ func (e *Editor) backspace() {
430421 }
431422
432423 func (e *Editor) delete() {
433- e.histIdx = -1
434424 line := e.Lines[e.CursorR]
435425 if e.CursorC == runeLen(line) {
436426 if e.CursorR == len(e.Lines)-1 {
@@ -510,8 +500,145 @@ func (e *Editor) moveRight() {
510500 }
511501 }
512502
503+// moveCursorVisual moves the cursor one visual row in direction
504+// dir (-1 = up, +1 = down) through the wrapped layout the user
505+// sees on screen. Handles both multi-line logical inputs and the
506+// case where a single long line wraps across several visual
507+// rows.
508+//
509+// Algorithm: rebuild the same wrapped layout Render produces,
510+// tagging each visual row with (logicalRow, runeOffsetStart,
511+// runeOffsetEnd, leadingWidth). Find the row the cursor sits on,
512+// then pick (row+dir) and map the cursor's current visual column
513+// (minus the target row's leading indent) to a rune index inside
514+// that row's slice of its logical line. No-op at the top/bottom
515+// edges of the whole buffer.
516+func (e *Editor) moveCursorVisual(dir int) {
517+ width := e.lastRenderWidth
518+ if width <= 0 {
519+ // Fall back to logical-line navigation if Render hasn't
520+ // been called yet (shouldn't happen in practice; the
521+ // host always renders once before accepting input).
522+ e.moveCursorLogical(dir)
523+ return
524+ }
525+
526+ type vrow struct {
527+ logical int // e.Lines index
528+ runeStart int // rune offset into e.Lines[logical]
529+ runeEnd int // exclusive
530+ leadWidth int // width of prompt / cont indent on this row
531+ leadPrefix string // the prefix used (prompt on row 0, indent on cont)
532+ }
533+
534+ promptLen := visibleWidth(e.Prompt)
535+ indent := strings.Repeat(" ", promptLen)
536+
537+ var rows []vrow
538+ curVRow, curVCol := 0, 0
539+ for r, line := range e.Lines {
540+ prefix := indent
541+ if r == 0 {
542+ prefix = e.Prompt
543+ }
544+ wrapped := wrapLine(prefix+line, width, indent)
545+ lineRunes := []rune(line)
546+ seen := 0
547+ for wi, w := range wrapped {
548+ var leadW int
549+ var leadP string
550+ body := w
551+ if wi == 0 {
552+ if strings.HasPrefix(body, prefix) {
553+ body = body[len(prefix):]
554+ }
555+ leadW = promptLen
556+ leadP = prefix
557+ } else {
558+ if strings.HasPrefix(body, indent) {
559+ body = body[len(indent):]
560+ }
561+ leadW = promptLen
562+ leadP = indent
563+ }
564+ bodyRunes := []rune(body)
565+ start := seen
566+ end := seen + len(bodyRunes)
567+ rows = append(rows, vrow{
568+ logical: r, runeStart: start, runeEnd: end,
569+ leadWidth: leadW, leadPrefix: leadP,
570+ })
571+ // Record where the cursor currently sits.
572+ if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
573+ curVRow = len(rows) - 1
574+ inner := e.CursorC - start
575+ if inner < 0 {
576+ inner = 0
577+ }
578+ if inner > len(bodyRunes) {
579+ inner = len(bodyRunes)
580+ }
581+ curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
582+ }
583+ seen = end
584+ // Word-wrap often drops a single space at the boundary.
585+ for seen < len(lineRunes) && lineRunes[seen] == ' ' {
586+ seen++
587+ }
588+ }
589+ }
590+
591+ target := curVRow + dir
592+ if target < 0 || target >= len(rows) {
593+ return
594+ }
595+ tr := rows[target]
596+ line := e.Lines[tr.logical]
597+ lineRunes := []rune(line)
598+ bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
599+
600+ // Find the rune offset inside bodyRunes whose visible column
601+ // most closely matches curVCol after accounting for leadWidth.
602+ want := curVCol - tr.leadWidth
603+ if want < 0 {
604+ want = 0
605+ }
606+ best := 0
607+ bestW := 0
608+ for i := 1; i <= len(bodyRunes); i++ {
609+ w := runewidth.StringWidth(string(bodyRunes[:i]))
610+ if w > want {
611+ break
612+ }
613+ best = i
614+ bestW = w
615+ if w == want {
616+ break
617+ }
618+ }
619+ _ = bestW
620+ e.CursorR = tr.logical
621+ e.CursorC = tr.runeStart + best
622+}
623+
624+// moveCursorLogical is the pre-visual-navigation fallback used
625+// when Render hasn't told us the terminal width yet. Walks the
626+// e.Lines array directly.
627+func (e *Editor) moveCursorLogical(dir int) {
628+ switch {
629+ case dir < 0 && e.CursorR > 0:
630+ e.CursorR--
631+ case dir > 0 && e.CursorR < len(e.Lines)-1:
632+ e.CursorR++
633+ default:
634+ return
635+ }
636+ if e.CursorC > runeLen(e.Lines[e.CursorR]) {
637+ e.CursorC = runeLen(e.Lines[e.CursorR])
638+ }
639+}
640+
513641 func (e *Editor) deleteWord() {
514- e.histIdx = -1
515642 line := e.Lines[e.CursorR]
516643 if e.CursorC == 0 {
517644 e.backspace()
@@ -551,69 +678,12 @@ func isWordSep(r rune) bool {
551678 return true
552679 }
553680
554-// ---- history ----
555-
556-// PushHistory saves s to the history ring.
557-func (e *Editor) PushHistory(s string) {
558- s = strings.TrimRight(s, "\n")
559- if s == "" {
560- return
561- }
562- if n := len(e.History); n > 0 && e.History[n-1] == s {
563- return
564- }
565- e.History = append(e.History, s)
566- if len(e.History) > 200 {
567- e.History = e.History[len(e.History)-200:]
568- }
569- e.histIdx = -1
570-}
571-
572-func (e *Editor) historyPrev() {
573- if len(e.History) == 0 {
574- return
575- }
576- if e.histIdx == -1 {
577- e.savedDraft = e.Value()
578- e.histIdx = len(e.History) - 1
579- } else if e.histIdx > 0 {
580- e.histIdx--
581- }
582- e.SetValue(e.History[e.histIdx])
583- // SetValue resets histIdx; restore.
584- e.histIdx = e.findInHistory()
585-}
586-
587-func (e *Editor) historyNext() {
588- if e.histIdx == -1 {
589- return
590- }
591- if e.histIdx == len(e.History)-1 {
592- e.histIdx = -1
593- e.SetValue(e.savedDraft)
594- e.histIdx = -1
595- return
596- }
597- e.histIdx++
598- e.SetValue(e.History[e.histIdx])
599- e.histIdx = e.findInHistory()
600-}
601-
602-func (e *Editor) findInHistory() int {
603- v := e.Value()
604- for i, h := range e.History {
605- if h == v {
606- return i
607- }
608- }
609- return -1
610-}
611-
612681 // ---- rendering ----
613682
614683 // Render returns the editor's visible lines (wrapped to width).
615684 // visualRow/visualCol describe where the cursor lands within the returned lines.
616685 func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
686+ e.lastRenderWidth = width
617687 promptLen := visibleWidth(e.Prompt)
618688 indent := strings.Repeat(" ", promptLen)
619689
@@ -23,10 +23,12 @@ type Editor struct {
23 Prompt string23 Prompt string
24 MaxWidth int24 MaxWidth int
25 25
26- // History is a ring of previously submitted buffers (newest last).26+ // lastRenderWidth is the column count passed to the most recent
27- History []string27+ // Render() call. Up/Down key handling needs this to walk the
28- histIdx int // -1 means "editing current buffer"28+ // same visual layout the user sees: a logical line that wraps to
29- savedDraft string29+ // two rows should respond to Up by moving to the previous visual
30+ // row, not do nothing because CursorR is already 0.
31+ lastRenderWidth int
30 32
31 // pastes stores the full content of every multi-line paste,33 // pastes stores the full content of every multi-line paste,
32 // keyed by the id embedded in the visible placeholder token.34 // keyed by the id embedded in the visible placeholder token.
@@ -42,9 +44,8 @@ type Editor struct {
42 // NewEditor returns an empty editor with the given prompt.44 // NewEditor returns an empty editor with the given prompt.
43 func NewEditor(prompt string) *Editor {45 func NewEditor(prompt string) *Editor {
44 return &Editor{46 return &Editor{
45- Lines: []string{""},47+ Lines: []string{""},
46- Prompt: prompt,48+ Prompt: prompt,
47- histIdx: -1,
48 }49 }
49 }50 }
50 51
@@ -83,7 +84,6 @@ func (e *Editor) SetValue(s string) {
83 }84 }
84 e.CursorR = len(e.Lines) - 185 e.CursorR = len(e.Lines) - 1
85 e.CursorC = runeLen(e.Lines[e.CursorR])86 e.CursorC = runeLen(e.Lines[e.CursorR])
86- e.histIdx = -1
87 e.pastes = nil87 e.pastes = nil
88 e.pasteSeq = 088 e.pasteSeq = 0
89 }89 }
@@ -96,9 +96,9 @@ func (e *Editor) IsEmpty() bool {
96 return len(e.Lines) == 1 && e.Lines[0] == ""96 return len(e.Lines) == 1 && e.Lines[0] == ""
97 }97 }
98 98
99-// HandleKey applies k to the editor. It returns (submit, key).99+// HandleKey applies k to the editor. It returns submit=true when
100-// If submit is true, the caller should read Value() and call100+// the user pressed enter and there is content to send; the caller
101-// PushHistory + Clear.101+// should read SubmitValue() and then Clear().
102 func (e *Editor) HandleKey(k Key) (submit bool) {102 func (e *Editor) HandleKey(k Key) (submit bool) {
103 switch k.Kind {103 switch k.Kind {
104 case KeyRune:104 case KeyRune:
@@ -134,23 +134,17 @@ func (e *Editor) HandleKey(k Key) (submit bool) {
134 e.moveRight()134 e.moveRight()
135 }135 }
136 case KeyUp:136 case KeyUp:
137- if e.CursorR == 0 {137+ // Visual-row navigation. When a single logical line wraps
138- e.historyPrev()138+ // to several visual rows, Up needs to climb one visual
139- } else {139+ // row — which may mean moving within the same
140- e.CursorR--140+ // e.Lines[CursorR] back toward an earlier rune index,
141- if e.CursorC > runeLen(e.Lines[e.CursorR]) {141+ // not jumping to CursorR-1. Buffer-line navigation is
142- e.CursorC = runeLen(e.Lines[e.CursorR])142+ // subsumed: a visual row above may also live in the
143- }143+ // previous logical line when a short line precedes a
144- }144+ // wrapped one.
145+ e.moveCursorVisual(-1)
145 case KeyDown:146 case KeyDown:
146- if e.CursorR == len(e.Lines)-1 {147+ e.moveCursorVisual(+1)
147- e.historyNext()
148- } else {
149- e.CursorR++
150- if e.CursorC > runeLen(e.Lines[e.CursorR]) {
151- e.CursorC = runeLen(e.Lines[e.CursorR])
152- }
153- }
154 case KeyHome, KeyCtrlA:148 case KeyHome, KeyCtrlA:
155 e.CursorC = 0149 e.CursorC = 0
156 case KeyEnd, KeyCtrlE:150 case KeyEnd, KeyCtrlE:
@@ -372,7 +366,6 @@ func singleQuote(s string) string {
372 func (e *Editor) Insert(s string) { e.insert(s) }366 func (e *Editor) Insert(s string) { e.insert(s) }
373 367
374 func (e *Editor) insert(s string) {368 func (e *Editor) insert(s string) {
375- e.histIdx = -1
376 line := e.Lines[e.CursorR]369 line := e.Lines[e.CursorR]
377 pre := substringBefore(line, e.CursorC)370 pre := substringBefore(line, e.CursorC)
378 post := substringAfter(line, e.CursorC)371 post := substringAfter(line, e.CursorC)
@@ -398,7 +391,6 @@ func (e *Editor) insert(s string) {
398 }391 }
399 392
400 func (e *Editor) newline() {393 func (e *Editor) newline() {
401- e.histIdx = -1
402 line := e.Lines[e.CursorR]394 line := e.Lines[e.CursorR]
403 pre := substringBefore(line, e.CursorC)395 pre := substringBefore(line, e.CursorC)
404 post := substringAfter(line, e.CursorC)396 post := substringAfter(line, e.CursorC)
@@ -411,7 +403,6 @@ func (e *Editor) newline() {
411 }403 }
412 404
413 func (e *Editor) backspace() {405 func (e *Editor) backspace() {
414- e.histIdx = -1
415 if e.CursorC == 0 {406 if e.CursorC == 0 {
416 if e.CursorR == 0 {407 if e.CursorR == 0 {
417 return408 return
@@ -430,7 +421,6 @@ func (e *Editor) backspace() {
430 }421 }
431 422
432 func (e *Editor) delete() {423 func (e *Editor) delete() {
433- e.histIdx = -1
434 line := e.Lines[e.CursorR]424 line := e.Lines[e.CursorR]
435 if e.CursorC == runeLen(line) {425 if e.CursorC == runeLen(line) {
436 if e.CursorR == len(e.Lines)-1 {426 if e.CursorR == len(e.Lines)-1 {
@@ -510,8 +500,145 @@ func (e *Editor) moveRight() {
510 }500 }
511 }501 }
512 502
503+// moveCursorVisual moves the cursor one visual row in direction
504+// dir (-1 = up, +1 = down) through the wrapped layout the user
505+// sees on screen. Handles both multi-line logical inputs and the
506+// case where a single long line wraps across several visual
507+// rows.
508+//
509+// Algorithm: rebuild the same wrapped layout Render produces,
510+// tagging each visual row with (logicalRow, runeOffsetStart,
511+// runeOffsetEnd, leadingWidth). Find the row the cursor sits on,
512+// then pick (row+dir) and map the cursor's current visual column
513+// (minus the target row's leading indent) to a rune index inside
514+// that row's slice of its logical line. No-op at the top/bottom
515+// edges of the whole buffer.
516+func (e *Editor) moveCursorVisual(dir int) {
517+ width := e.lastRenderWidth
518+ if width <= 0 {
519+ // Fall back to logical-line navigation if Render hasn't
520+ // been called yet (shouldn't happen in practice; the
521+ // host always renders once before accepting input).
522+ e.moveCursorLogical(dir)
523+ return
524+ }
525+
526+ type vrow struct {
527+ logical int // e.Lines index
528+ runeStart int // rune offset into e.Lines[logical]
529+ runeEnd int // exclusive
530+ leadWidth int // width of prompt / cont indent on this row
531+ leadPrefix string // the prefix used (prompt on row 0, indent on cont)
532+ }
533+
534+ promptLen := visibleWidth(e.Prompt)
535+ indent := strings.Repeat(" ", promptLen)
536+
537+ var rows []vrow
538+ curVRow, curVCol := 0, 0
539+ for r, line := range e.Lines {
540+ prefix := indent
541+ if r == 0 {
542+ prefix = e.Prompt
543+ }
544+ wrapped := wrapLine(prefix+line, width, indent)
545+ lineRunes := []rune(line)
546+ seen := 0
547+ for wi, w := range wrapped {
548+ var leadW int
549+ var leadP string
550+ body := w
551+ if wi == 0 {
552+ if strings.HasPrefix(body, prefix) {
553+ body = body[len(prefix):]
554+ }
555+ leadW = promptLen
556+ leadP = prefix
557+ } else {
558+ if strings.HasPrefix(body, indent) {
559+ body = body[len(indent):]
560+ }
561+ leadW = promptLen
562+ leadP = indent
563+ }
564+ bodyRunes := []rune(body)
565+ start := seen
566+ end := seen + len(bodyRunes)
567+ rows = append(rows, vrow{
568+ logical: r, runeStart: start, runeEnd: end,
569+ leadWidth: leadW, leadPrefix: leadP,
570+ })
571+ // Record where the cursor currently sits.
572+ if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
573+ curVRow = len(rows) - 1
574+ inner := e.CursorC - start
575+ if inner < 0 {
576+ inner = 0
577+ }
578+ if inner > len(bodyRunes) {
579+ inner = len(bodyRunes)
580+ }
581+ curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
582+ }
583+ seen = end
584+ // Word-wrap often drops a single space at the boundary.
585+ for seen < len(lineRunes) && lineRunes[seen] == ' ' {
586+ seen++
587+ }
588+ }
589+ }
590+
591+ target := curVRow + dir
592+ if target < 0 || target >= len(rows) {
593+ return
594+ }
595+ tr := rows[target]
596+ line := e.Lines[tr.logical]
597+ lineRunes := []rune(line)
598+ bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
599+
600+ // Find the rune offset inside bodyRunes whose visible column
601+ // most closely matches curVCol after accounting for leadWidth.
602+ want := curVCol - tr.leadWidth
603+ if want < 0 {
604+ want = 0
605+ }
606+ best := 0
607+ bestW := 0
608+ for i := 1; i <= len(bodyRunes); i++ {
609+ w := runewidth.StringWidth(string(bodyRunes[:i]))
610+ if w > want {
611+ break
612+ }
613+ best = i
614+ bestW = w
615+ if w == want {
616+ break
617+ }
618+ }
619+ _ = bestW
620+ e.CursorR = tr.logical
621+ e.CursorC = tr.runeStart + best
622+}
623+
624+// moveCursorLogical is the pre-visual-navigation fallback used
625+// when Render hasn't told us the terminal width yet. Walks the
626+// e.Lines array directly.
627+func (e *Editor) moveCursorLogical(dir int) {
628+ switch {
629+ case dir < 0 && e.CursorR > 0:
630+ e.CursorR--
631+ case dir > 0 && e.CursorR < len(e.Lines)-1:
632+ e.CursorR++
633+ default:
634+ return
635+ }
636+ if e.CursorC > runeLen(e.Lines[e.CursorR]) {
637+ e.CursorC = runeLen(e.Lines[e.CursorR])
638+ }
639+}
640+
513 func (e *Editor) deleteWord() {641 func (e *Editor) deleteWord() {
514- e.histIdx = -1
515 line := e.Lines[e.CursorR]642 line := e.Lines[e.CursorR]
516 if e.CursorC == 0 {643 if e.CursorC == 0 {
517 e.backspace()644 e.backspace()
@@ -551,69 +678,12 @@ func isWordSep(r rune) bool {
551 return true678 return true
552 }679 }
553 680
554-// ---- history ----
555-
556-// PushHistory saves s to the history ring.
557-func (e *Editor) PushHistory(s string) {
558- s = strings.TrimRight(s, "\n")
559- if s == "" {
560- return
561- }
562- if n := len(e.History); n > 0 && e.History[n-1] == s {
563- return
564- }
565- e.History = append(e.History, s)
566- if len(e.History) > 200 {
567- e.History = e.History[len(e.History)-200:]
568- }
569- e.histIdx = -1
570-}
571-
572-func (e *Editor) historyPrev() {
573- if len(e.History) == 0 {
574- return
575- }
576- if e.histIdx == -1 {
577- e.savedDraft = e.Value()
578- e.histIdx = len(e.History) - 1
579- } else if e.histIdx > 0 {
580- e.histIdx--
581- }
582- e.SetValue(e.History[e.histIdx])
583- // SetValue resets histIdx; restore.
584- e.histIdx = e.findInHistory()
585-}
586-
587-func (e *Editor) historyNext() {
588- if e.histIdx == -1 {
589- return
590- }
591- if e.histIdx == len(e.History)-1 {
592- e.histIdx = -1
593- e.SetValue(e.savedDraft)
594- e.histIdx = -1
595- return
596- }
597- e.histIdx++
598- e.SetValue(e.History[e.histIdx])
599- e.histIdx = e.findInHistory()
600-}
601-
602-func (e *Editor) findInHistory() int {
603- v := e.Value()
604- for i, h := range e.History {
605- if h == v {
606- return i
607- }
608- }
609- return -1
610-}
611-
612 // ---- rendering ----681 // ---- rendering ----
613 682
614 // Render returns the editor's visible lines (wrapped to width).683 // Render returns the editor's visible lines (wrapped to width).
615 // visualRow/visualCol describe where the cursor lands within the returned lines.684 // visualRow/visualCol describe where the cursor lands within the returned lines.
616 func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {685 func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
686+ e.lastRenderWidth = width
617 promptLen := visibleWidth(e.Prompt)687 promptLen := visibleWidth(e.Prompt)
618 indent := strings.Repeat(" ", promptLen)688 indent := strings.Repeat(" ", promptLen)
619 689

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