feat(tui): browse input history with up down
- commit
- 23bdde8aa77fe037a2a2ca4cf39740dd622220ac
- author
- mi-skam · · 2026-07-01 17:02 UTC
- parent
- 92241f6
- signature
- SSH Unverified
SHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0wThe key is unknown
2 files · +115 -14
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 +22 -14
| @@ -2127,16 +2127,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2127 | 2127 | i.invalidate() | |
| 2128 | 2128 | return false | |
| 2129 | 2129 | } | |
| 2130 | - | i.mu.Unlock() | |
| 2131 | 2130 | } | |
| 2132 | 2131 | // In multi-line / wrapped input, Up first moves inside the editor. | |
| 2133 | - | // At the editor's top edge it falls back to chat scrolling, preserving | |
| 2134 | - | // the old single-line scroll behavior. | |
| 2132 | + | // At the editor's top edge, plain Up can browse input history when | |
| 2133 | + | // history browsing is safe/active; otherwise it falls back to chat | |
| 2134 | + | // scrolling, preserving the old single-line scroll behavior. | |
| 2135 | 2135 | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 2136 | 2136 | if i.ed.MoveVertical(-1) { | |
| 2137 | 2137 | i.invalidate() | |
| 2138 | 2138 | return false | |
| 2139 | 2139 | } | |
| 2140 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 2141 | + | return false | |
| 2142 | + | } | |
| 2140 | 2143 | i.scrollBy(+3) | |
| 2141 | 2144 | return false | |
| 2142 | 2145 | } | |
| @@ -2146,6 +2149,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2146 | 2149 | i.invalidate() | |
| 2147 | 2150 | return false | |
| 2148 | 2151 | } | |
| 2152 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |
| 2153 | + | return false | |
| 2154 | + | } | |
| 2149 | 2155 | if i.scrollOffset > 0 { | |
| 2150 | 2156 | i.scrollBy(-3) | |
| 2151 | 2157 | } | |
| @@ -2268,10 +2274,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2268 | 2274 | } | |
| 2269 | 2275 | } | |
| 2270 | 2276 | ||
| 2271 | - | if i.handleInputHistoryKey(k) { | |
| 2272 | - | return false | |
| 2273 | - | } | |
| 2274 | - | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |
| 2277 | + | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| 2275 | 2278 | i.inputHistoryIndex = -1 | |
| 2276 | 2279 | } | |
| 2277 | 2280 | ||
| @@ -2407,11 +2410,11 @@ func (i *Interactive) pasteClipboard() { | |||
| 2407 | 2410 | } | |
| 2408 | 2411 | ||
| 2409 | 2412 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |
| 2410 | - | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |
| 2413 | + | if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| 2411 | 2414 | return false | |
| 2412 | 2415 | } | |
| 2413 | - | // Do not steal normal cursor movement. History browsing can only | |
| 2414 | - | // start from an empty editor; once active, Left/Right keep walking | |
| 2416 | + | // Do not steal normal vertical cursor movement. History browsing can only | |
| 2417 | + | // start from an empty editor; once active, Up/Down keep walking | |
| 2415 | 2418 | // the ring so repeated presses work even though the editor now | |
| 2416 | 2419 | // contains the selected historical prompt. | |
| 2417 | 2420 | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | |
| @@ -2423,17 +2426,22 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |||
| 2423 | 2426 | } | |
| 2424 | 2427 | ||
| 2425 | 2428 | if i.inputHistoryIndex < 0 { | |
| 2426 | - | // Start just after the newest item so Left lands on the most | |
| 2427 | - | // recent user prompt and Right keeps the editor empty. | |
| 2429 | + | // Start just after the newest item so Up lands on the most | |
| 2430 | + | // recent user prompt. A lone Down from an empty editor is not | |
| 2431 | + | // history navigation; let the caller fall through to normal UI | |
| 2432 | + | // behavior instead. | |
| 2433 | + | if k.Kind != tui.KeyUp { | |
| 2434 | + | return false | |
| 2435 | + | } | |
| 2428 | 2436 | i.inputHistoryIndex = len(hist) | |
| 2429 | 2437 | } | |
| 2430 | 2438 | ||
| 2431 | 2439 | switch k.Kind { | |
| 2432 | - | case tui.KeyLeft: | |
| 2440 | + | case tui.KeyUp: | |
| 2433 | 2441 | if i.inputHistoryIndex > 0 { | |
| 2434 | 2442 | i.inputHistoryIndex-- | |
| 2435 | 2443 | } | |
| 2436 | - | case tui.KeyRight: | |
| 2444 | + | case tui.KeyDown: | |
| 2437 | 2445 | if i.inputHistoryIndex < len(hist) { | |
| 2438 | 2446 | i.inputHistoryIndex++ | |
| 2439 | 2447 | } | |
| @@ -2127,16 +2127,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2127 | i.invalidate() | 2127 | i.invalidate() | ||
| 2128 | return false | 2128 | return false | ||
| 2129 | } | 2129 | } | ||
| 2130 | - | i.mu.Unlock() | |||
| 2131 | } | 2130 | } | ||
| 2132 | // In multi-line / wrapped input, Up first moves inside the editor. | 2131 | // In multi-line / wrapped input, Up first moves inside the editor. | ||
| 2133 | - | // At the editor's top edge it falls back to chat scrolling, preserving | 2132 | + | // At the editor's top edge, plain Up can browse input history when |
| 2134 | - | // the old single-line scroll behavior. | 2133 | + | // history browsing is safe/active; otherwise it falls back to chat |
| 2134 | + | // scrolling, preserving the old single-line scroll behavior. | |||
| 2135 | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | 2135 | if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | ||
| 2136 | if i.ed.MoveVertical(-1) { | 2136 | if i.ed.MoveVertical(-1) { | ||
| 2137 | i.invalidate() | 2137 | i.invalidate() | ||
| 2138 | return false | 2138 | return false | ||
| 2139 | } | 2139 | } | ||
| 2140 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |||
| 2141 | + | return false | |||
| 2142 | + | } | |||
| 2140 | i.scrollBy(+3) | 2143 | i.scrollBy(+3) | ||
| 2141 | return false | 2144 | return false | ||
| 2142 | } | 2145 | } | ||
| @@ -2146,6 +2149,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2146 | i.invalidate() | 2149 | i.invalidate() | ||
| 2147 | return false | 2150 | return false | ||
| 2148 | } | 2151 | } | ||
| 2152 | + | if !k.Alt && i.handleInputHistoryKey(k) { | |||
| 2153 | + | return false | |||
| 2154 | + | } | |||
| 2149 | if i.scrollOffset > 0 { | 2155 | if i.scrollOffset > 0 { | ||
| 2150 | i.scrollBy(-3) | 2156 | i.scrollBy(-3) | ||
| 2151 | } | 2157 | } | ||
| @@ -2268,10 +2274,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2268 | } | 2274 | } | ||
| 2269 | } | 2275 | } | ||
| 2270 | 2276 | ||||
| 2271 | - | if i.handleInputHistoryKey(k) { | 2277 | + | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { |
| 2272 | - | return false | |||
| 2273 | - | } | |||
| 2274 | - | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | |||
| 2275 | i.inputHistoryIndex = -1 | 2278 | i.inputHistoryIndex = -1 | ||
| 2276 | } | 2279 | } | ||
| 2277 | 2280 | ||||
| @@ -2407,11 +2410,11 @@ func (i *Interactive) pasteClipboard() { | |||||
| 2407 | } | 2410 | } | ||
| 2408 | 2411 | ||||
| 2409 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | 2412 | func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | ||
| 2410 | - | if k.Kind != tui.KeyLeft && k.Kind != tui.KeyRight { | 2413 | + | if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { |
| 2411 | return false | 2414 | return false | ||
| 2412 | } | 2415 | } | ||
| 2413 | - | // Do not steal normal cursor movement. History browsing can only | 2416 | + | // Do not steal normal vertical cursor movement. History browsing can only |
| 2414 | - | // start from an empty editor; once active, Left/Right keep walking | 2417 | + | // start from an empty editor; once active, Up/Down keep walking |
| 2415 | // the ring so repeated presses work even though the editor now | 2418 | // the ring so repeated presses work even though the editor now | ||
| 2416 | // contains the selected historical prompt. | 2419 | // contains the selected historical prompt. | ||
| 2417 | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | 2420 | if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() { | ||
| @@ -2423,17 +2426,22 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool { | |||||
| 2423 | } | 2426 | } | ||
| 2424 | 2427 | ||||
| 2425 | if i.inputHistoryIndex < 0 { | 2428 | if i.inputHistoryIndex < 0 { | ||
| 2426 | - | // Start just after the newest item so Left lands on the most | 2429 | + | // Start just after the newest item so Up lands on the most |
| 2427 | - | // recent user prompt and Right keeps the editor empty. | 2430 | + | // recent user prompt. A lone Down from an empty editor is not |
| 2431 | + | // history navigation; let the caller fall through to normal UI | |||
| 2432 | + | // behavior instead. | |||
| 2433 | + | if k.Kind != tui.KeyUp { | |||
| 2434 | + | return false | |||
| 2435 | + | } | |||
| 2428 | i.inputHistoryIndex = len(hist) | 2436 | i.inputHistoryIndex = len(hist) | ||
| 2429 | } | 2437 | } | ||
| 2430 | 2438 | ||||
| 2431 | switch k.Kind { | 2439 | switch k.Kind { | ||
| 2432 | - | case tui.KeyLeft: | 2440 | + | case tui.KeyUp: |
| 2433 | if i.inputHistoryIndex > 0 { | 2441 | if i.inputHistoryIndex > 0 { | ||
| 2434 | i.inputHistoryIndex-- | 2442 | i.inputHistoryIndex-- | ||
| 2435 | } | 2443 | } | ||
| 2436 | - | case tui.KeyRight: | 2444 | + | case tui.KeyDown: |
| 2437 | if i.inputHistoryIndex < len(hist) { | 2445 | if i.inputHistoryIndex < len(hist) { | ||
| 2438 | i.inputHistoryIndex++ | 2446 | i.inputHistoryIndex++ | ||
| 2439 | } | 2447 | } | ||