detached 23bdde8

feat(tui): browse input history with up down

commit
23bdde8aa77fe037a2a2ca4cf39740dd622220ac
author
mi-skam · · 2026-07-01 17:02 UTC
parent
92241f6
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The 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) {
21272127 i.invalidate()
21282128 return false
21292129 }
2130- i.mu.Unlock()
21312130 }
21322131 // 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.
21352135 if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
21362136 if i.ed.MoveVertical(-1) {
21372137 i.invalidate()
21382138 return false
21392139 }
2140+ if !k.Alt && i.handleInputHistoryKey(k) {
2141+ return false
2142+ }
21402143 i.scrollBy(+3)
21412144 return false
21422145 }
@@ -2146,6 +2149,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
21462149 i.invalidate()
21472150 return false
21482151 }
2152+ if !k.Alt && i.handleInputHistoryKey(k) {
2153+ return false
2154+ }
21492155 if i.scrollOffset > 0 {
21502156 i.scrollBy(-3)
21512157 }
@@ -2268,10 +2274,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22682274 }
22692275 }
22702276
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 {
22752278 i.inputHistoryIndex = -1
22762279 }
22772280
@@ -2407,11 +2410,11 @@ func (i *Interactive) pasteClipboard() {
24072410 }
24082411
24092412 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 {
24112414 return false
24122415 }
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
24152418 // the ring so repeated presses work even though the editor now
24162419 // contains the selected historical prompt.
24172420 if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
@@ -2423,17 +2426,22 @@ func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
24232426 }
24242427
24252428 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+ }
24282436 i.inputHistoryIndex = len(hist)
24292437 }
24302438
24312439 switch k.Kind {
2432- case tui.KeyLeft:
2440+ case tui.KeyUp:
24332441 if i.inputHistoryIndex > 0 {
24342442 i.inputHistoryIndex--
24352443 }
2436- case tui.KeyRight:
2444+ case tui.KeyDown:
24372445 if i.inputHistoryIndex < len(hist) {
24382446 i.inputHistoryIndex++
24392447 }
@@ -2127,16 +2127,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2127 i.invalidate()2127 i.invalidate()
2128 return false2128 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, preserving2132+ // 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 false2138 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 false2144 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 false2150 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 = -12278 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 false2414 return false
2412 }2415 }
2413- // Do not steal normal cursor movement. History browsing can only2416+ // Do not steal normal vertical cursor movement. History browsing can only
2414- // start from an empty editor; once active, Left/Right keep walking2417+ // start from an empty editor; once active, Up/Down keep walking
2415 // the ring so repeated presses work even though the editor now2418 // 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 most2429+ // 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 }

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