From ec3b7a7d48012a7acf32790859585658f82e7bd1 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Mon, 20 Apr 2026 16:11:49 +0200 Subject: [PATCH] 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. --- internal/agent/modes/interactive.go | 10 +++ internal/agent/modes/session_dialog.go | 104 ++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index af02dec..0183ee5 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -652,6 +652,16 @@ func (i *Interactive) redraw() { case i.modelDialog.Active(): dialog = i.modelDialog.Render(i.cfg.Theme, cols) case i.sessionDialog.Active(): + // Reserve rows for the editor (~3), status line (1-2), + // dialog chrome (header + hint + rule + indicators, ~5), + // and leave the remainder for session rows. Minimum of 3 + // rows so even a very small terminal shows something. + _, rows := i.cfg.Terminal.Size() + avail := rows - 12 + if avail < 3 { + avail = 3 + } + i.sessionDialog.MaxRows = avail dialog = i.sessionDialog.Render(i.cfg.Theme, cols) case i.jumpDialog.Active(): dialog = i.jumpDialog.Render(i.cfg.Theme, cols) diff --git a/internal/agent/modes/session_dialog.go b/internal/agent/modes/session_dialog.go index 03ae035..3450893 100644 --- a/internal/agent/modes/session_dialog.go +++ b/internal/agent/modes/session_dialog.go @@ -14,6 +14,20 @@ type sessionDialog struct { active bool sessions []core.SessionSummary cursor int + + // MaxRows is the maximum number of session rows the dialog + // will render in a single frame. Set by the host right before + // Render based on the available chat space; if 0, the dialog + // falls back to rendering every row (original behaviour). + // When the list is longer than MaxRows the dialog scrolls so + // the cursor stays visible and tags the first/last visible + // entry with a muted "↑ N more" / "↓ N more" row so the user + // knows there's offscreen content. + MaxRows int + + // viewTop is the index of the first session currently drawn. + // Adjusted to follow the cursor on up/down moves. + viewTop int } // sessionDialogAction is returned by HandleKey. @@ -41,6 +55,7 @@ func (d *sessionDialog) Open(root, cwd string) { } d.sessions = filtered d.cursor = 0 + d.viewTop = 0 d.active = true } @@ -63,8 +78,31 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { lines = append(lines, frameRule(th, width)) return lines } - lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, enter, esc to cancel)")) - for i, s := range d.sessions { + lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, pgup/pgdn, enter, esc to cancel)")) + + // Viewport: windowed slice of d.sessions around d.cursor so a + // list taller than the terminal still scrolls. Caller sets + // MaxRows to the number of rows available for session entries + // (i.e. excluding the header, hint, chrome). When it's zero or + // bigger than the list, we draw everything. + total := len(d.sessions) + window := d.MaxRows + if window <= 0 || window >= total { + window = total + } + d.viewTop = clampViewTop(d.viewTop, d.cursor, window, total) + viewBot := d.viewTop + window + if viewBot > total { + viewBot = total + } + + // Top indicator: how many rows are above the viewport. + if d.viewTop > 0 { + hidden := d.viewTop + lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", hidden))) + } + for i := d.viewTop; i < viewBot; i++ { + s := d.sessions[i] plain := " " + formatSessionRowPlain(s, width-2) if i == d.cursor { lines = append(lines, th.PadHighlight(plain, width)) @@ -72,10 +110,46 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { lines = append(lines, th.FG256(th.Muted, plain)) } } + // Bottom indicator: how many rows are below the viewport. + if viewBot < total { + hidden := total - viewBot + lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", hidden))) + } lines = append(lines, frameRule(th, width)) return lines } +// clampViewTop returns a viewTop that keeps cursor visible in a +// window of the given size over a list of `total` rows. Leaves one +// row of padding above/below where possible so moving the cursor +// doesn't land right on the top/bottom edge — easier to see what +// direction you're moving. +func clampViewTop(viewTop, cursor, window, total int) int { + if window <= 0 || total <= 0 { + return 0 + } + if window >= total { + return 0 + } + pad := 2 + if window < 6 { + pad = 0 + } + if cursor < viewTop+pad { + viewTop = cursor - pad + } + if cursor >= viewTop+window-pad { + viewTop = cursor - window + pad + 1 + } + if viewTop < 0 { + viewTop = 0 + } + if viewTop+window > total { + viewTop = total - window + } + return viewTop +} + // formatSessionRowPlain returns the session row body without any ANSI // styling so the caller can wrap it in either a plain mute color or a // full-row selection highlight. @@ -119,6 +193,13 @@ func formatRelative(t time.Time) string { // HandleKey advances the dialog and returns an action to apply, if any. func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { + page := d.MaxRows + if page <= 0 { + page = 10 + } + if page > 1 { + page-- + } switch k.Kind { case tui.KeyUp: if d.cursor > 0 { @@ -128,6 +209,25 @@ func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { if d.cursor < len(d.sessions)-1 { d.cursor++ } + case tui.KeyPageUp: + d.cursor -= page + if d.cursor < 0 { + d.cursor = 0 + } + case tui.KeyPageDown: + d.cursor += page + if d.cursor >= len(d.sessions) { + d.cursor = len(d.sessions) - 1 + if d.cursor < 0 { + d.cursor = 0 + } + } + case tui.KeyHome: + d.cursor = 0 + case tui.KeyEnd: + if len(d.sessions) > 0 { + d.cursor = len(d.sessions) - 1 + } case tui.KeyEsc: d.Close() return sessionDialogAction{Close: true}