detached ec3b7a7

fix(tui): scroll the /sessions picker when the list overflows

On a terminal too short to show every row, /sessions used to
render the whole list top-to-bottom \u2014 the cursor would move but
the overflowing rows at the bottom got clipped off the screen
and you could never reach them visually. up/down still worked
logically but the user had no way to see which row was
currently selected past the cutoff.

The dialog now keeps a viewport around the cursor. MaxRows is
set by the interactive host each frame to (terminal rows - 12)
with a min of 3, so the viewport grows with the window. The
cursor stays two rows inside the top/bottom edge of the
viewport (one row when the viewport itself is very small) so
you can see what's coming next. When content is hidden above or
below, a muted "\u2191 N more above" / "\u2193 N more below" marker
replaces the offscreen rows so you know there's more.

Keys: up / down move one row (unchanged), PgUp / PgDn jump one
page (viewport size minus 1 for overlap), Home / End go to the
first / last entry, Enter / Esc unchanged. The hint line in the
dialog header now mentions pgup/pgdn so it's discoverable.

Empty-list behaviour is unchanged; the no-sessions message
still renders as before.
commit
ec3b7a7d48012a7acf32790859585658f82e7bd1
author
patriceckhart · · 2026-04-20 14:11 UTC
parent
e1fdf4d
signature
unsigned
refs
tag: v0.0.69
2 files · +112 -2
M internal/agent/modes/interactive.go +10 -0
@@ -652,6 +652,16 @@ func (i *Interactive) redraw() {
652652 case i.modelDialog.Active():
653653 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
654654 case i.sessionDialog.Active():
655+ // Reserve rows for the editor (~3), status line (1-2),
656+ // dialog chrome (header + hint + rule + indicators, ~5),
657+ // and leave the remainder for session rows. Minimum of 3
658+ // rows so even a very small terminal shows something.
659+ _, rows := i.cfg.Terminal.Size()
660+ avail := rows - 12
661+ if avail < 3 {
662+ avail = 3
663+ }
664+ i.sessionDialog.MaxRows = avail
655665 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
656666 case i.jumpDialog.Active():
657667 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
@@ -652,6 +652,16 @@ func (i *Interactive) redraw() {
652 case i.modelDialog.Active():652 case i.modelDialog.Active():
653 dialog = i.modelDialog.Render(i.cfg.Theme, cols)653 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
654 case i.sessionDialog.Active():654 case i.sessionDialog.Active():
655+ // Reserve rows for the editor (~3), status line (1-2),
656+ // dialog chrome (header + hint + rule + indicators, ~5),
657+ // and leave the remainder for session rows. Minimum of 3
658+ // rows so even a very small terminal shows something.
659+ _, rows := i.cfg.Terminal.Size()
660+ avail := rows - 12
661+ if avail < 3 {
662+ avail = 3
663+ }
664+ i.sessionDialog.MaxRows = avail
655 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)665 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
656 case i.jumpDialog.Active():666 case i.jumpDialog.Active():
657 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)667 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
M internal/agent/modes/session_dialog.go +102 -2
@@ -14,6 +14,20 @@ type sessionDialog struct {
1414 active bool
1515 sessions []core.SessionSummary
1616 cursor int
17+
18+ // MaxRows is the maximum number of session rows the dialog
19+ // will render in a single frame. Set by the host right before
20+ // Render based on the available chat space; if 0, the dialog
21+ // falls back to rendering every row (original behaviour).
22+ // When the list is longer than MaxRows the dialog scrolls so
23+ // the cursor stays visible and tags the first/last visible
24+ // entry with a muted "↑ N more" / "↓ N more" row so the user
25+ // knows there's offscreen content.
26+ MaxRows int
27+
28+ // viewTop is the index of the first session currently drawn.
29+ // Adjusted to follow the cursor on up/down moves.
30+ viewTop int
1731 }
1832
1933 // sessionDialogAction is returned by HandleKey.
@@ -41,6 +55,7 @@ func (d *sessionDialog) Open(root, cwd string) {
4155 }
4256 d.sessions = filtered
4357 d.cursor = 0
58+ d.viewTop = 0
4459 d.active = true
4560 }
4661
@@ -63,8 +78,31 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
6378 lines = append(lines, frameRule(th, width))
6479 return lines
6580 }
66- lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, enter, esc to cancel)"))
67- for i, s := range d.sessions {
81+ lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, pgup/pgdn, enter, esc to cancel)"))
82+
83+ // Viewport: windowed slice of d.sessions around d.cursor so a
84+ // list taller than the terminal still scrolls. Caller sets
85+ // MaxRows to the number of rows available for session entries
86+ // (i.e. excluding the header, hint, chrome). When it's zero or
87+ // bigger than the list, we draw everything.
88+ total := len(d.sessions)
89+ window := d.MaxRows
90+ if window <= 0 || window >= total {
91+ window = total
92+ }
93+ d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total)
94+ viewBot := d.viewTop + window
95+ if viewBot > total {
96+ viewBot = total
97+ }
98+
99+ // Top indicator: how many rows are above the viewport.
100+ if d.viewTop > 0 {
101+ hidden := d.viewTop
102+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden)))
103+ }
104+ for i := d.viewTop; i < viewBot; i++ {
105+ s := d.sessions[i]
68106 plain := " " + formatSessionRowPlain(s, width-2)
69107 if i == d.cursor {
70108 lines = append(lines, th.PadHighlight(plain, width))
@@ -72,10 +110,46 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
72110 lines = append(lines, th.FG256(th.Muted, plain))
73111 }
74112 }
113+ // Bottom indicator: how many rows are below the viewport.
114+ if viewBot < total {
115+ hidden := total - viewBot
116+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
117+ }
75118 lines = append(lines, frameRule(th, width))
76119 return lines
77120 }
78121
122+// clampViewTop returns a viewTop that keeps cursor visible in a
123+// window of the given size over a list of `total` rows. Leaves one
124+// row of padding above/below where possible so moving the cursor
125+// doesn't land right on the top/bottom edge — easier to see what
126+// direction you're moving.
127+func clampViewTop(viewTop, cursor, window, total int) int {
128+ if window <= 0 || total <= 0 {
129+ return 0
130+ }
131+ if window >= total {
132+ return 0
133+ }
134+ pad := 2
135+ if window < 6 {
136+ pad = 0
137+ }
138+ if cursor < viewTop+pad {
139+ viewTop = cursor - pad
140+ }
141+ if cursor >= viewTop+window-pad {
142+ viewTop = cursor - window + pad + 1
143+ }
144+ if viewTop < 0 {
145+ viewTop = 0
146+ }
147+ if viewTop+window > total {
148+ viewTop = total - window
149+ }
150+ return viewTop
151+}
152+
79153 // formatSessionRowPlain returns the session row body without any ANSI
80154 // styling so the caller can wrap it in either a plain mute color or a
81155 // full-row selection highlight.
@@ -119,6 +193,13 @@ func formatRelative(t time.Time) string {
119193
120194 // HandleKey advances the dialog and returns an action to apply, if any.
121195 func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
196+ page := d.MaxRows
197+ if page <= 0 {
198+ page = 10
199+ }
200+ if page > 1 {
201+ page--
202+ }
122203 switch k.Kind {
123204 case tui.KeyUp:
124205 if d.cursor > 0 {
@@ -128,6 +209,25 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
128209 if d.cursor < len(d.sessions)-1 {
129210 d.cursor++
130211 }
212+ case tui.KeyPageUp:
213+ d.cursor -= page
214+ if d.cursor < 0 {
215+ d.cursor = 0
216+ }
217+ case tui.KeyPageDown:
218+ d.cursor += page
219+ if d.cursor >= len(d.sessions) {
220+ d.cursor = len(d.sessions) - 1
221+ if d.cursor < 0 {
222+ d.cursor = 0
223+ }
224+ }
225+ case tui.KeyHome:
226+ d.cursor = 0
227+ case tui.KeyEnd:
228+ if len(d.sessions) > 0 {
229+ d.cursor = len(d.sessions) - 1
230+ }
131231 case tui.KeyEsc:
132232 d.Close()
133233 return sessionDialogAction{Close: true}
@@ -14,6 +14,20 @@ type sessionDialog struct {
14 active bool14 active bool
15 sessions []core.SessionSummary15 sessions []core.SessionSummary
16 cursor int16 cursor int
17+
18+ // MaxRows is the maximum number of session rows the dialog
19+ // will render in a single frame. Set by the host right before
20+ // Render based on the available chat space; if 0, the dialog
21+ // falls back to rendering every row (original behaviour).
22+ // When the list is longer than MaxRows the dialog scrolls so
23+ // the cursor stays visible and tags the first/last visible
24+ // entry with a muted "↑ N more" / "↓ N more" row so the user
25+ // knows there's offscreen content.
26+ MaxRows int
27+
28+ // viewTop is the index of the first session currently drawn.
29+ // Adjusted to follow the cursor on up/down moves.
30+ viewTop int
17 }31 }
18 32
19 // sessionDialogAction is returned by HandleKey.33 // sessionDialogAction is returned by HandleKey.
@@ -41,6 +55,7 @@ func (d *sessionDialog) Open(root, cwd string) {
41 }55 }
42 d.sessions = filtered56 d.sessions = filtered
43 d.cursor = 057 d.cursor = 0
58+ d.viewTop = 0
44 d.active = true59 d.active = true
45 }60 }
46 61
@@ -63,8 +78,31 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
63 lines = append(lines, frameRule(th, width))78 lines = append(lines, frameRule(th, width))
64 return lines79 return lines
65 }80 }
66- lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, enter, esc to cancel)"))81+ lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, pgup/pgdn, enter, esc to cancel)"))
67- for i, s := range d.sessions {82+
83+ // Viewport: windowed slice of d.sessions around d.cursor so a
84+ // list taller than the terminal still scrolls. Caller sets
85+ // MaxRows to the number of rows available for session entries
86+ // (i.e. excluding the header, hint, chrome). When it's zero or
87+ // bigger than the list, we draw everything.
88+ total := len(d.sessions)
89+ window := d.MaxRows
90+ if window <= 0 || window >= total {
91+ window = total
92+ }
93+ d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total)
94+ viewBot := d.viewTop + window
95+ if viewBot > total {
96+ viewBot = total
97+ }
98+
99+ // Top indicator: how many rows are above the viewport.
100+ if d.viewTop > 0 {
101+ hidden := d.viewTop
102+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden)))
103+ }
104+ for i := d.viewTop; i < viewBot; i++ {
105+ s := d.sessions[i]
68 plain := " " + formatSessionRowPlain(s, width-2)106 plain := " " + formatSessionRowPlain(s, width-2)
69 if i == d.cursor {107 if i == d.cursor {
70 lines = append(lines, th.PadHighlight(plain, width))108 lines = append(lines, th.PadHighlight(plain, width))
@@ -72,10 +110,46 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
72 lines = append(lines, th.FG256(th.Muted, plain))110 lines = append(lines, th.FG256(th.Muted, plain))
73 }111 }
74 }112 }
113+ // Bottom indicator: how many rows are below the viewport.
114+ if viewBot < total {
115+ hidden := total - viewBot
116+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden)))
117+ }
75 lines = append(lines, frameRule(th, width))118 lines = append(lines, frameRule(th, width))
76 return lines119 return lines
77 }120 }
78 121
122+// clampViewTop returns a viewTop that keeps cursor visible in a
123+// window of the given size over a list of `total` rows. Leaves one
124+// row of padding above/below where possible so moving the cursor
125+// doesn't land right on the top/bottom edge — easier to see what
126+// direction you're moving.
127+func clampViewTop(viewTop, cursor, window, total int) int {
128+ if window <= 0 || total <= 0 {
129+ return 0
130+ }
131+ if window >= total {
132+ return 0
133+ }
134+ pad := 2
135+ if window < 6 {
136+ pad = 0
137+ }
138+ if cursor < viewTop+pad {
139+ viewTop = cursor - pad
140+ }
141+ if cursor >= viewTop+window-pad {
142+ viewTop = cursor - window + pad + 1
143+ }
144+ if viewTop < 0 {
145+ viewTop = 0
146+ }
147+ if viewTop+window > total {
148+ viewTop = total - window
149+ }
150+ return viewTop
151+}
152+
79 // formatSessionRowPlain returns the session row body without any ANSI153 // formatSessionRowPlain returns the session row body without any ANSI
80 // styling so the caller can wrap it in either a plain mute color or a154 // styling so the caller can wrap it in either a plain mute color or a
81 // full-row selection highlight.155 // full-row selection highlight.
@@ -119,6 +193,13 @@ func formatRelative(t time.Time) string {
119 193
120 // HandleKey advances the dialog and returns an action to apply, if any.194 // HandleKey advances the dialog and returns an action to apply, if any.
121 func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {195 func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
196+ page := d.MaxRows
197+ if page <= 0 {
198+ page = 10
199+ }
200+ if page > 1 {
201+ page--
202+ }
122 switch k.Kind {203 switch k.Kind {
123 case tui.KeyUp:204 case tui.KeyUp:
124 if d.cursor > 0 {205 if d.cursor > 0 {
@@ -128,6 +209,25 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction {
128 if d.cursor < len(d.sessions)-1 {209 if d.cursor < len(d.sessions)-1 {
129 d.cursor++210 d.cursor++
130 }211 }
212+ case tui.KeyPageUp:
213+ d.cursor -= page
214+ if d.cursor < 0 {
215+ d.cursor = 0
216+ }
217+ case tui.KeyPageDown:
218+ d.cursor += page
219+ if d.cursor >= len(d.sessions) {
220+ d.cursor = len(d.sessions) - 1
221+ if d.cursor < 0 {
222+ d.cursor = 0
223+ }
224+ }
225+ case tui.KeyHome:
226+ d.cursor = 0
227+ case tui.KeyEnd:
228+ if len(d.sessions) > 0 {
229+ d.cursor = len(d.sessions) - 1
230+ }
131 case tui.KeyEsc:231 case tui.KeyEsc:
132 d.Close()232 d.Close()
133 return sessionDialogAction{Close: true}233 return sessionDialogAction{Close: true}

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