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() { | |||
| 652 | 652 | case i.modelDialog.Active(): | |
| 653 | 653 | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | |
| 654 | 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 | 665 | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | |
| 656 | 666 | case i.jumpDialog.Active(): | |
| 657 | 667 | 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 { | |||
| 14 | 14 | active bool | |
| 15 | 15 | sessions []core.SessionSummary | |
| 16 | 16 | 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 | 33 | // sessionDialogAction is returned by HandleKey. | |
| @@ -41,6 +55,7 @@ func (d *sessionDialog) Open(root, cwd string) { | |||
| 41 | 55 | } | |
| 42 | 56 | d.sessions = filtered | |
| 43 | 57 | d.cursor = 0 | |
| 58 | + | d.viewTop = 0 | |
| 44 | 59 | d.active = true | |
| 45 | 60 | } | |
| 46 | 61 | ||
| @@ -63,8 +78,31 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 63 | 78 | lines = append(lines, frameRule(th, width)) | |
| 64 | 79 | return lines | |
| 65 | 80 | } | |
| 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] | |
| 68 | 106 | plain := " " + formatSessionRowPlain(s, width-2) | |
| 69 | 107 | if i == d.cursor { | |
| 70 | 108 | lines = append(lines, th.PadHighlight(plain, width)) | |
| @@ -72,10 +110,46 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 72 | 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 | 118 | lines = append(lines, frameRule(th, width)) | |
| 76 | 119 | 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 | 153 | // formatSessionRowPlain returns the session row body without any ANSI | |
| 80 | 154 | // styling so the caller can wrap it in either a plain mute color or a | |
| 81 | 155 | // full-row selection highlight. | |
| @@ -119,6 +193,13 @@ func formatRelative(t time.Time) string { | |||
| 119 | 193 | ||
| 120 | 194 | // HandleKey advances the dialog and returns an action to apply, if any. | |
| 121 | 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 | 203 | switch k.Kind { | |
| 123 | 204 | case tui.KeyUp: | |
| 124 | 205 | if d.cursor > 0 { | |
| @@ -128,6 +209,25 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |||
| 128 | 209 | if d.cursor < len(d.sessions)-1 { | |
| 129 | 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 | 231 | case tui.KeyEsc: | |
| 132 | 232 | d.Close() | |
| 133 | 233 | return sessionDialogAction{Close: true} | |
| @@ -14,6 +14,20 @@ type sessionDialog struct { | |||||
| 14 | active bool | 14 | active bool | ||
| 15 | sessions []core.SessionSummary | 15 | sessions []core.SessionSummary | ||
| 16 | cursor int | 16 | 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 = filtered | 56 | d.sessions = filtered | ||
| 43 | d.cursor = 0 | 57 | d.cursor = 0 | ||
| 58 | + | d.viewTop = 0 | |||
| 44 | d.active = true | 59 | 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 lines | 79 | 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 lines | 119 | 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 ANSI | 153 | // formatSessionRowPlain returns the session row body without any ANSI | ||
| 80 | // styling so the caller can wrap it in either a plain mute color or a | 154 | // 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} | ||