detached c7f7c01

Merge pull request #52 from mi-skam/pr/input-history-up-down

feat(tui): browse input history with up/down
commit
c7f7c01ea90ff1f639d9f1b45563ba2b2bdce018
author
Patric Eckhart · · 2026-07-02 05:11 UTC
committer
GitHub · · 2026-07-02 05:11 UTC
merge of
4694534 + fd78dcb · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.53
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
657657 | `alt+left`, `alt+right` | Jump one word back or forward. |
658658 | `ctrl+u`, `ctrl+k` | Delete to start or end of line. |
659659 | `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. |
661661
662662 ### Chat scroll
663663
664664 | Key | Action |
665665 |---|---|
666666 | `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. |
668668
669669 ## Extensions
670670
@@ -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 scroll662 ### 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 ## Extensions669 ## Extensions
670 670
M packages/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", "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"},
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", "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 shared29 // 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 {
429429 parkedTotal int
430430
431431 // 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.
435434 inputHistoryIndex int
436435
437436 // 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) {
21912190 i.invalidate()
21922191 return false
21932192 }
2194- i.mu.Unlock()
21952193 }
21962194 // 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.
21992198 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
22002199 if i.ed.MoveVertical(-1) {
22012200 i.invalidate()
22022201 return false
22032202 }
2203+ if !k.Alt && i.handleInputHistoryKey(k) {
2204+ return false
2205+ }
22042206 i.scrollBy(+3)
22052207 return false
22062208 }
@@ -2210,6 +2212,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22102212 i.invalidate()
22112213 return false
22122214 }
2215+ if !k.Alt && i.handleInputHistoryKey(k) {
2216+ return false
2217+ }
22132218 if i.scrollOffset > 0 {
22142219 i.scrollBy(-3)
22152220 }
@@ -2332,10 +2337,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
23322337 }
23332338 }
23342339
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 {
23392341 i.inputHistoryIndex = -1
23402342 }
23412343
@@ -2471,11 +2473,11 @@ func (i *Interactive) pasteClipboard() {
24712473 }
24722474
24732475 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 {
24752477 return false
24762478 }
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
24792481 // the ring so repeated presses work even though the editor now
24802482 // contains the selected historical prompt.
24812483 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
@@ -2487,17 +2489,22 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
24872489 }
24882490
24892491 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+ }
24922499 i.inputHistoryIndex = len(hist)
24932500 }
24942501
24952502 switch k.Kind {
2496- case tui.KeyLeft:
2503+ case tui.KeyUp:
24972504 if i.inputHistoryIndex > 0 {
24982505 i.inputHistoryIndex--
24992506 }
2500- case tui.KeyRight:
2507+ case tui.KeyDown:
25012508 if i.inputHistoryIndex < len(hist) {
25022509 i.inputHistoryIndex++
25032510 }
@@ -429,9 +429,8 @@ type Interactive struct {
429 parkedTotal int429 parkedTotal int
430 430
431 // inputHistoryIndex is -1 when not browsing history. When the431 // inputHistoryIndex is -1 when not browsing history. When the
432- // editor is empty, Left/Right can walk previous user prompts for432+ // editor is empty, Up/Down can walk previous user prompts without
433- // quick manual testing without stealing normal cursor movement in433+ // stealing normal vertical cursor movement in non-empty input.
434- // non-empty input.
435 inputHistoryIndex int434 inputHistoryIndex int
436 435
437 // lastCtrlC is when the user last pressed ctrl+c. The first press436 // 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 false2191 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, preserving2195+ // 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 false2201 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 false2207 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 false2213 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 = -12341 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 false2477 return false
2476 }2478 }
2477- // Do not steal normal cursor movement. History browsing can only2479+ // Do not steal normal vertical cursor movement. History browsing can only
2478- // start from an empty editor; once active, Left/Right keep walking2480+ // start from an empty editor; once active, Up/Down keep walking
2479 // the ring so repeated presses work even though the editor now2481 // 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 most2492+ // 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 }

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