mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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.
This commit is contained in:
parent
e1fdf4d42e
commit
ec3b7a7d48
2 changed files with 112 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue