Merge pull request #52 from mi-skam/pr/input-history-up-down
feat(tui): browse input history with up/down
4 files · +120 -20
M README.md +2 -2
| @@ -657,14 +657,14 @@ Type `@` followed by a filter string to narrow the list (e.g. `@read` shows only | |||
| 657 | 657 | | `alt+left`, `alt+right` | Jump one word back or forward. | | |
| 658 | 658 | | `ctrl+u`, `ctrl+k` | Delete to start or end of line. | | |
| 659 | 659 | | `ctrl+w`, `alt+backspace` | Delete the previous word. | | |
| 660 | - | | `up`, `down` (editor non-empty) | Cycle through prompt history. | | |
| 660 | + | | `up`, `down` | Move within multi-line input. At the top edge, `up` recalls previous prompts and `down` moves forward through prompt history. | | |
| 661 | 661 | ||
| 662 | 662 | ### Chat scroll | |
| 663 | 663 | ||
| 664 | 664 | | Key | Action | | |
| 665 | 665 | |---|---| | |
| 666 | 666 | | `pgup`, `pgdn` | Scroll one page up or down. | | |
| 667 | - | | `up`, `down` (editor empty) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. | | |
| 667 | + | | `up`, `down` (editor empty, not browsing prompt history) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. | | |
| 668 | 668 | ||
| 669 | 669 | ## Extensions | |
| 670 | 670 | ||
| @@ -657,14 +657,14 @@ Type `@` followed by a filter string to narrow the list (e.g. `@read` shows only | |||||
| 657 | | `alt+left`, `alt+right` | Jump one word back or forward. | | 657 | | `alt+left`, `alt+right` | Jump one word back or forward. | | ||
| 658 | | `ctrl+u`, `ctrl+k` | Delete to start or end of line. | | 658 | | `ctrl+u`, `ctrl+k` | Delete to start or end of line. | | ||
| 659 | | `ctrl+w`, `alt+backspace` | Delete the previous word. | | 659 | | `ctrl+w`, `alt+backspace` | Delete the previous word. | | ||
| 660 | - | | `up`, `down` (editor non-empty) | Cycle through prompt history. | | 660 | + | | `up`, `down` | Move within multi-line input. At the top edge, `up` recalls previous prompts and `down` moves forward through prompt history. | |
| 661 | 661 | ||||
| 662 | ### Chat scroll | 662 | ### Chat scroll | ||
| 663 | 663 | ||||
| 664 | | Key | Action | | 664 | | Key | Action | | ||
| 665 | |---|---| | 665 | |---|---| | ||
| 666 | | `pgup`, `pgdn` | Scroll one page up or down. | | 666 | | `pgup`, `pgdn` | Scroll one page up or down. | | ||
| 667 | - | | `up`, `down` (editor empty) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. | | 667 | + | | `up`, `down` (editor empty, not browsing prompt history) | Scroll three lines up or down. This is how the mouse wheel reaches the scroll logic on most terminals. | |
| 668 | 668 | ||||
| 669 | ## Extensions | 669 | ## Extensions | ||
| 670 | 670 | ||||
M packages/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", "move within multi-line input - scroll chat at input edge"}, | |
| 26 | + | {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"}, | |
| 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", "move within multi-line input - scroll chat at input edge"}, | 26 | + | {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"}, |
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | // renderHelpBlock builds the friendly /help view. Uses the shared | 29 | // renderHelpBlock builds the friendly /help view. Uses the shared | ||
A packages/agent/modes/input_history_test.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/core" | |
| 7 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 8 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestInputHistoryUsesUpDown(t *testing.T) { | |
| 12 | + | ag := core.NewAgent(nil, "", "", nil) | |
| 13 | + | ag.SetMessages([]provider.Message{ | |
| 14 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}, | |
| 15 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply"}}}, | |
| 16 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}, | |
| 17 | + | }) | |
| 18 | + | i := &Interactive{ | |
| 19 | + | agent: ag, | |
| 20 | + | ed: tui.NewEditor(""), | |
| 21 | + | inputHistoryIndex: -1, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) { | |
| 25 | + | t.Fatal("expected Up to enter history") | |
| 26 | + | } | |
| 27 | + | if got := i.ed.Value(); got != "second" { | |
| 28 | + | t.Fatalf("Up loaded %q, want newest history item", got) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) { | |
| 32 | + | t.Fatal("expected repeated Up to stay in history") | |
| 33 | + | } | |
| 34 | + | if got := i.ed.Value(); got != "first" { | |
| 35 | + | t.Fatalf("second Up loaded %q, want older history item", got) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyDown}) { | |
| 39 | + | t.Fatal("expected Down to move forward in history") | |
| 40 | + | } | |
| 41 | + | if got := i.ed.Value(); got != "second" { | |
| 42 | + | t.Fatalf("Down loaded %q, want newer history item", got) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyDown}) { | |
| 46 | + | t.Fatal("expected Down at newest item to clear editor") | |
| 47 | + | } | |
| 48 | + | if got := i.ed.Value(); got != "" { | |
| 49 | + | t.Fatalf("Down past newest loaded %q, want empty editor", got) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) { | |
| 54 | + | ag := core.NewAgent(nil, "", "", nil) | |
| 55 | + | ag.SetMessages([]provider.Message{ | |
| 56 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}, | |
| 57 | + | }) | |
| 58 | + | i := &Interactive{ | |
| 59 | + | agent: ag, | |
| 60 | + | ed: tui.NewEditor(""), | |
| 61 | + | inputHistoryIndex: -1, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyLeft}) { | |
| 65 | + | t.Fatal("Left should not browse input history") | |
| 66 | + | } | |
| 67 | + | if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyRight}) { | |
| 68 | + | t.Fatal("Right should not browse input history") | |
| 69 | + | } | |
| 70 | + | if got := i.ed.Value(); got != "" { | |
| 71 | + | t.Fatalf("left/right changed editor to %q", got) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestInputHistoryDoesNotStealNonEmptyEditor(t *testing.T) { | |
| 76 | + | ag := core.NewAgent(nil, "", "", nil) | |
| 77 | + | ag.SetMessages([]provider.Message{ | |
| 78 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}, | |
| 79 | + | }) | |
| 80 | + | i := &Interactive{ | |
| 81 | + | agent: ag, | |
| 82 | + | ed: tui.NewEditor(""), | |
| 83 | + | inputHistoryIndex: -1, | |
| 84 | + | } | |
| 85 | + | i.ed.SetValue("draft") | |
| 86 | + | ||
| 87 | + | if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) { | |
| 88 | + | t.Fatal("Up should not browse history while editing a draft") | |
| 89 | + | } | |
| 90 | + | if got := i.ed.Value(); got != "draft" { | |
| 91 | + | t.Fatalf("editor changed to %q, want draft", got) | |
| 92 | + | } | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 7 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 8 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestInputHistoryUsesUpDown(t *testing.T) { | |||
| 12 | + | ag := core.NewAgent(nil, "", "", nil) | |||
| 13 | + | ag.SetMessages([]provider.Message{ | |||
| 14 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}}, | |||
| 15 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "reply"}}}, | |||
| 16 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}}, | |||
| 17 | + | }) | |||
| 18 | + | i := &Interactive{ | |||
| 19 | + | agent: ag, | |||
| 20 | + | ed: tui.NewEditor(""), | |||
| 21 | + | inputHistoryIndex: -1, | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) { | |||
| 25 | + | t.Fatal("expected Up to enter history") | |||
| 26 | + | } | |||
| 27 | + | if got := i.ed.Value(); got != "second" { | |||
| 28 | + | t.Fatalf("Up loaded %q, want newest history item", got) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) { | |||
| 32 | + | t.Fatal("expected repeated Up to stay in history") | |||
| 33 | + | } | |||
| 34 | + | if got := i.ed.Value(); got != "first" { | |||
| 35 | + | t.Fatalf("second Up loaded %q, want older history item", got) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyDown}) { | |||
| 39 | + | t.Fatal("expected Down to move forward in history") | |||
| 40 | + | } | |||
| 41 | + | if got := i.ed.Value(); got != "second" { | |||
| 42 | + | t.Fatalf("Down loaded %q, want newer history item", got) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | if !i.handleInputHistoryKey(tui.Key{Kind: tui.KeyDown}) { | |||
| 46 | + | t.Fatal("expected Down at newest item to clear editor") | |||
| 47 | + | } | |||
| 48 | + | if got := i.ed.Value(); got != "" { | |||
| 49 | + | t.Fatalf("Down past newest loaded %q, want empty editor", got) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestInputHistoryNoLongerUsesLeftRight(t *testing.T) { | |||
| 54 | + | ag := core.NewAgent(nil, "", "", nil) | |||
| 55 | + | ag.SetMessages([]provider.Message{ | |||
| 56 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}, | |||
| 57 | + | }) | |||
| 58 | + | i := &Interactive{ | |||
| 59 | + | agent: ag, | |||
| 60 | + | ed: tui.NewEditor(""), | |||
| 61 | + | inputHistoryIndex: -1, | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyLeft}) { | |||
| 65 | + | t.Fatal("Left should not browse input history") | |||
| 66 | + | } | |||
| 67 | + | if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyRight}) { | |||
| 68 | + | t.Fatal("Right should not browse input history") | |||
| 69 | + | } | |||
| 70 | + | if got := i.ed.Value(); got != "" { | |||
| 71 | + | t.Fatalf("left/right changed editor to %q", got) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestInputHistoryDoesNotStealNonEmptyEditor(t *testing.T) { | |||
| 76 | + | ag := core.NewAgent(nil, "", "", nil) | |||
| 77 | + | ag.SetMessages([]provider.Message{ | |||
| 78 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}, | |||
| 79 | + | }) | |||
| 80 | + | i := &Interactive{ | |||
| 81 | + | agent: ag, | |||
| 82 | + | ed: tui.NewEditor(""), | |||
| 83 | + | inputHistoryIndex: -1, | |||
| 84 | + | } | |||
| 85 | + | i.ed.SetValue("draft") | |||
| 86 | + | ||||
| 87 | + | if i.handleInputHistoryKey(tui.Key{Kind: tui.KeyUp}) { | |||
| 88 | + | t.Fatal("Up should not browse history while editing a draft") | |||
| 89 | + | } | |||
| 90 | + | if got := i.ed.Value(); got != "draft" { | |||
| 91 | + | t.Fatalf("editor changed to %q, want draft", got) | |||
| 92 | + | } | |||
| 93 | + | } | |||
M packages/agent/modes/interactive.go +24 -17
| @@ -429,9 +429,8 @@ type Interactive struct { | |||
| 429 | 429 | parkedTotal int | |
| 430 | 430 | ||
| 431 | 431 | // inputHistoryIndex is -1 when not browsing history. When the | |
| 432 | - | // editor is empty, Left/Right can walk previous user prompts for | |
| 433 | - | // quick manual testing without stealing normal cursor movement in | |
| 434 | - | // non-empty input. | |
| 432 | + | // editor is empty, Up/Down can walk previous user prompts without | |
| 433 | + | // stealing normal vertical cursor movement in non-empty input. | |
| 435 | 434 | inputHistoryIndex int | |
| 436 | 435 | ||
| 437 | 436 | // lastCtrlC is when the user last pressed ctrl+c. The first press | |
| @@ -2191,16 +2190,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2191 | 2190 | i.invalidate() | |
| 2192 | 2191 | return false | |
| 2193 | 2192 | } | |
| 2194 | - | i.mu.Unlock() | |
| 2195 | 2193 | } | |
| 2196 | 2194 | // In multi-line / wrapped input, Up first moves inside the editor. | |
| 2197 | - | // At the editor's top edge it falls back to chat scrolling, preserving | |
| 2198 | - | // the old single-line scroll behavior. | |
| 2195 | + | // At the editor's top edge, plain Up can browse input history when | |
| 2196 | + | // history browsing is safe/active; otherwise it falls back to chat | |
| 2197 | + | // scrolling, preserving the old single-line scroll behavior. | |
| 2199 | 2198 | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 2200 | 2199 | if i.ed.MoveVertical(-1) { | |
| 2201 | 2200 | i.invalidate() | |
| 2202 | 2201 | return false | |
| 2203 | 2202 | } | |
| 2203 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 2204 | + | return false | |
| 2205 | + | } | |
| 2204 | 2206 | i.scrollBy(+3) | |
| 2205 | 2207 | return false | |
| 2206 | 2208 | } | |
| @@ -2210,6 +2212,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2210 | 2212 | i.invalidate() | |
| 2211 | 2213 | return false | |
| 2212 | 2214 | } | |
| 2215 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 2216 | + | return false | |
| 2217 | + | } | |
| 2213 | 2218 | if i.scrollOffset > 0 { | |
| 2214 | 2219 | i.scrollBy(-3) | |
| 2215 | 2220 | } | |
| @@ -2332,10 +2337,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2332 | 2337 | } | |
| 2333 | 2338 | } | |
| 2334 | 2339 | ||
| 2335 | - | if i.handleInputHistoryKey(k) { | |
| 2336 | - | return false | |
| 2337 | - | } | |
| 2338 | - | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |
| 2340 | + | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| 2339 | 2341 | i.inputHistoryIndex = -1 | |
| 2340 | 2342 | } | |
| 2341 | 2343 | ||
| @@ -2471,11 +2473,11 @@ func (i *Interactive) pasteClipboard() { | |||
| 2471 | 2473 | } | |
| 2472 | 2474 | ||
| 2473 | 2475 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |
| 2474 | - | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |
| 2476 | + | if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| 2475 | 2477 | return false | |
| 2476 | 2478 | } | |
| 2477 | - | // Do not steal normal cursor movement. History browsing can only | |
| 2478 | - | // start from an empty editor; once active, Left/Right keep walking | |
| 2479 | + | // Do not steal normal vertical cursor movement. History browsing can only | |
| 2480 | + | // start from an empty editor; once active, Up/Down keep walking | |
| 2479 | 2481 | // the ring so repeated presses work even though the editor now | |
| 2480 | 2482 | // contains the selected historical prompt. | |
| 2481 | 2483 | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | |
| @@ -2487,17 +2489,22 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |||
| 2487 | 2489 | } | |
| 2488 | 2490 | ||
| 2489 | 2491 | if i.inputHistoryIndex < 0 { | |
| 2490 | - | // Start just after the newest item so Left lands on the most | |
| 2491 | - | // recent user prompt and Right keeps the editor empty. | |
| 2492 | + | // Start just after the newest item so Up lands on the most | |
| 2493 | + | // recent user prompt. A lone Down from an empty editor is not | |
| 2494 | + | // history navigation; let the caller fall through to normal UI | |
| 2495 | + | // behavior instead. | |
| 2496 | + | if k.Kind != tui.KeyUp { | |
| 2497 | + | return false | |
| 2498 | + | } | |
| 2492 | 2499 | i.inputHistoryIndex = len(hist) | |
| 2493 | 2500 | } | |
| 2494 | 2501 | ||
| 2495 | 2502 | switch k.Kind { | |
| 2496 | - | case tui.KeyLeft: | |
| 2503 | + | case tui.KeyUp: | |
| 2497 | 2504 | if i.inputHistoryIndex > 0 { | |
| 2498 | 2505 | i.inputHistoryIndex-- | |
| 2499 | 2506 | } | |
| 2500 | - | case tui.KeyRight: | |
| 2507 | + | case tui.KeyDown: | |
| 2501 | 2508 | if i.inputHistoryIndex < len(hist) { | |
| 2502 | 2509 | i.inputHistoryIndex++ | |
| 2503 | 2510 | } | |
| @@ -429,9 +429,8 @@ type Interactive struct { | |||||
| 429 | parkedTotal int | 429 | parkedTotal int | ||
| 430 | 430 | ||||
| 431 | // inputHistoryIndex is -1 when not browsing history. When the | 431 | // inputHistoryIndex is -1 when not browsing history. When the | ||
| 432 | - | // editor is empty, Left/Right can walk previous user prompts for | 432 | + | // editor is empty, Up/Down can walk previous user prompts without |
| 433 | - | // quick manual testing without stealing normal cursor movement in | 433 | + | // stealing normal vertical cursor movement in non-empty input. |
| 434 | - | // non-empty input. | |||
| 435 | inputHistoryIndex int | 434 | inputHistoryIndex int | ||
| 436 | 435 | ||||
| 437 | // lastCtrlC is when the user last pressed ctrl+c. The first press | 436 | // lastCtrlC is when the user last pressed ctrl+c. The first press | ||
| @@ -2191,16 +2190,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2191 | i.invalidate() | 2190 | i.invalidate() | ||
| 2192 | return false | 2191 | return false | ||
| 2193 | } | 2192 | } | ||
| 2194 | - | i.mu.Unlock() | |||
| 2195 | } | 2193 | } | ||
| 2196 | // In multi-line / wrapped input, Up first moves inside the editor. | 2194 | // In multi-line / wrapped input, Up first moves inside the editor. | ||
| 2197 | - | // At the editor's top edge it falls back to chat scrolling, preserving | 2195 | + | // At the editor's top edge, plain Up can browse input history when |
| 2198 | - | // the old single-line scroll behavior. | 2196 | + | // history browsing is safe/active; otherwise it falls back to chat |
| 2197 | + | // scrolling, preserving the old single-line scroll behavior. | |||
| 2199 | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | 2198 | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | ||
| 2200 | if i.ed.MoveVertical(-1) { | 2199 | if i.ed.MoveVertical(-1) { | ||
| 2201 | i.invalidate() | 2200 | i.invalidate() | ||
| 2202 | return false | 2201 | return false | ||
| 2203 | } | 2202 | } | ||
| 2203 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |||
| 2204 | + | return false | |||
| 2205 | + | } | |||
| 2204 | i.scrollBy(+3) | 2206 | i.scrollBy(+3) | ||
| 2205 | return false | 2207 | return false | ||
| 2206 | } | 2208 | } | ||
| @@ -2210,6 +2212,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2210 | i.invalidate() | 2212 | i.invalidate() | ||
| 2211 | return false | 2213 | return false | ||
| 2212 | } | 2214 | } | ||
| 2215 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |||
| 2216 | + | return false | |||
| 2217 | + | } | |||
| 2213 | if i.scrollOffset > 0 { | 2218 | if i.scrollOffset > 0 { | ||
| 2214 | i.scrollBy(-3) | 2219 | i.scrollBy(-3) | ||
| 2215 | } | 2220 | } | ||
| @@ -2332,10 +2337,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2332 | } | 2337 | } | ||
| 2333 | } | 2338 | } | ||
| 2334 | 2339 | ||||
| 2335 | - | if i.handleInputHistoryKey(k) { | 2340 | + | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { |
| 2336 | - | return false | |||
| 2337 | - | } | |||
| 2338 | - | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |||
| 2339 | i.inputHistoryIndex = -1 | 2341 | i.inputHistoryIndex = -1 | ||
| 2340 | } | 2342 | } | ||
| 2341 | 2343 | ||||
| @@ -2471,11 +2473,11 @@ func (i *Interactive) pasteClipboard() { | |||||
| 2471 | } | 2473 | } | ||
| 2472 | 2474 | ||||
| 2473 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | 2475 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | ||
| 2474 | - | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | 2476 | + | if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { |
| 2475 | return false | 2477 | return false | ||
| 2476 | } | 2478 | } | ||
| 2477 | - | // Do not steal normal cursor movement. History browsing can only | 2479 | + | // Do not steal normal vertical cursor movement. History browsing can only |
| 2478 | - | // start from an empty editor; once active, Left/Right keep walking | 2480 | + | // start from an empty editor; once active, Up/Down keep walking |
| 2479 | // the ring so repeated presses work even though the editor now | 2481 | // the ring so repeated presses work even though the editor now | ||
| 2480 | // contains the selected historical prompt. | 2482 | // contains the selected historical prompt. | ||
| 2481 | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | 2483 | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | ||
| @@ -2487,17 +2489,22 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |||||
| 2487 | } | 2489 | } | ||
| 2488 | 2490 | ||||
| 2489 | if i.inputHistoryIndex < 0 { | 2491 | if i.inputHistoryIndex < 0 { | ||
| 2490 | - | // Start just after the newest item so Left lands on the most | 2492 | + | // Start just after the newest item so Up lands on the most |
| 2491 | - | // recent user prompt and Right keeps the editor empty. | 2493 | + | // recent user prompt. A lone Down from an empty editor is not |
| 2494 | + | // history navigation; let the caller fall through to normal UI | |||
| 2495 | + | // behavior instead. | |||
| 2496 | + | if k.Kind != tui.KeyUp { | |||
| 2497 | + | return false | |||
| 2498 | + | } | |||
| 2492 | i.inputHistoryIndex = len(hist) | 2499 | i.inputHistoryIndex = len(hist) | ||
| 2493 | } | 2500 | } | ||
| 2494 | 2501 | ||||
| 2495 | switch k.Kind { | 2502 | switch k.Kind { | ||
| 2496 | - | case tui.KeyLeft: | 2503 | + | case tui.KeyUp: |
| 2497 | if i.inputHistoryIndex > 0 { | 2504 | if i.inputHistoryIndex > 0 { | ||
| 2498 | i.inputHistoryIndex-- | 2505 | i.inputHistoryIndex-- | ||
| 2499 | } | 2506 | } | ||
| 2500 | - | case tui.KeyRight: | 2507 | + | case tui.KeyDown: |
| 2501 | if i.inputHistoryIndex < len(hist) { | 2508 | if i.inputHistoryIndex < len(hist) { | ||
| 2502 | i.inputHistoryIndex++ | 2509 | i.inputHistoryIndex++ | ||
| 2503 | } | 2510 | } | ||