detached a86fc63

cli: fix sessions TUI height via bounded list rendering

commit
a86fc6300725f87d1ae52392d2c64c6490b425e1
author
brice · · 2026-02-10 06:25 UTC
parent
f6e095c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +80 -29
M internal/cli/model/sessions.go +80 -29
@@ -353,40 +353,43 @@ func (m SessionsModel) View() string {
353353 }
354354
355355 t := m.theme
356- var b strings.Builder
356+ var prefix strings.Builder
357357
358358 // Header
359- header := m.renderHeader()
360- b.WriteString(header)
361- b.WriteString("\n\n")
359+ prefix.WriteString(m.renderHeader())
360+ prefix.WriteString("\n")
362361
363362 // Error display
364363 if m.err != nil {
365- b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366- b.WriteString("\n\n")
364+ prefix.WriteString("\n")
365+ prefix.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366+ prefix.WriteString("\n")
367367 }
368368
369369 // Status message
370370 if m.statusMessage != "" {
371- b.WriteString(t.Subtle.Render(m.statusMessage))
372- b.WriteString("\n\n")
373- }
374-
375- // Sessions list
376- if len(m.sessions) == 0 {
377- b.WriteString(t.Subtle.Render(" No saved sessions found."))
378- b.WriteString("\n")
379- } else {
380- b.WriteString(m.renderSessionsList())
371+ prefix.WriteString("\n")
372+ prefix.WriteString(t.Subtle.Render(m.statusMessage))
373+ prefix.WriteString("\n")
381374 }
382375
383- b.WriteString("\n")
384-
385376 // Help
386377 helpView := m.help.View(m.keys)
387- b.WriteString(helpView)
388378
389- return b.String()
379+ // Compute available height for list so the view never exceeds terminal height.
380+ // This prevents Bubble Tea from truncating the bottom of the UI when there
381+ // are many sessions.
382+ prefixH := lipgloss.Height(prefix.String())
383+ helpH := lipgloss.Height(helpView)
384+ listH := m.height - prefixH - 1 - helpH // one spacer line between list and help
385+ if listH < 1 {
386+ listH = 1
387+ }
388+
389+ listView := m.renderSessionsList(listH)
390+
391+ // Compose final output with a single spacer line above help.
392+ return strings.TrimRight(prefix.String(), "\n") + "\n" + listView + "\n" + helpView
390393 }
391394
392395 func (m SessionsModel) renderHeader() string {
@@ -416,23 +419,61 @@ func (m SessionsModel) renderHeader() string {
416419 return icon + title + stats
417420 }
418421
419-func (m SessionsModel) renderSessionsList() string {
422+func (m SessionsModel) renderSessionsList(maxHeight int) string {
423+ t := m.theme
424+
425+ if maxHeight <= 0 {
426+ return ""
427+ }
428+ if len(m.sessions) == 0 {
429+ return t.Subtle.Render(" No saved sessions found.")
430+ }
431+
432+ // Determine starting index so the selected row stays visible.
433+ start := 0
434+ if len(m.sessions) > maxHeight {
435+ // Keep selection roughly centered, but always visible.
436+ start = m.selectedIdx - (maxHeight / 2)
437+ if start < 0 {
438+ start = 0
439+ }
440+ maxStart := len(m.sessions) - maxHeight
441+ if start > maxStart {
442+ start = maxStart
443+ }
444+ }
445+
420446 var b strings.Builder
447+ remaining := maxHeight
421448
422- for i, info := range m.sessions {
449+ for i := start; i < len(m.sessions) && remaining > 0; i++ {
450+ info := m.sessions[i]
423451 isSelected := i == m.selectedIdx
424452 isExpanded := i == m.expandedIdx
425453
426454 b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
427- b.WriteString("\n")
455+ remaining--
456+ if remaining <= 0 {
457+ break
458+ }
459+
460+ // Render expanded details, but never exceed available height.
461+ if isExpanded && info.State != nil && remaining > 0 {
462+ details := m.renderSessionDetails(info)
463+ details = truncateLines(details, remaining)
464+ if details != "" {
465+ b.WriteString("\n")
466+ b.WriteString(details)
467+ remaining -= lipgloss.Height(details)
468+ }
469+ }
428470
429- // Render expanded details
430- if isExpanded && info.State != nil {
431- b.WriteString(m.renderSessionDetails(info))
471+ if remaining > 0 {
472+ b.WriteString("\n")
432473 }
433474 }
434475
435- return b.String()
476+ return strings.TrimRight(b.String(), "\n")
436477 }
437478
438479 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
@@ -509,7 +550,7 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
509550 var b strings.Builder
510551
511552 if info.State == nil {
512- b.WriteString(t.Subtle.Render(" No state data available\n"))
553+ b.WriteString(t.Subtle.Render(" No state data available"))
513554 return b.String()
514555 }
515556
@@ -547,10 +588,20 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
547588 m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
548589 }
549590
550- b.WriteString("\n")
551591 return b.String()
552592 }
553593
594+func truncateLines(s string, maxLines int) string {
595+ if maxLines <= 0 || s == "" {
596+ return ""
597+ }
598+ lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
599+ if len(lines) <= maxLines {
600+ return strings.Join(lines, "\n")
601+ }
602+ return strings.Join(lines[:maxLines], "\n")
603+}
604+
554605 func (m SessionsModel) renderPaneTree(
555606 b *strings.Builder,
556607 ws *entity.WorkspaceSnapshot,
@@ -353,40 +353,43 @@ func (m SessionsModel) View() string {
353 }353 }
354 354
355 t := m.theme355 t := m.theme
356- var b strings.Builder356+ var prefix strings.Builder
357 357
358 // Header358 // Header
359- header := m.renderHeader()359+ prefix.WriteString(m.renderHeader())
360- b.WriteString(header)360+ prefix.WriteString("\n")
361- b.WriteString("\n\n")
362 361
363 // Error display362 // Error display
364 if m.err != nil {363 if m.err != nil {
365- b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))364+ prefix.WriteString("\n")
366- b.WriteString("\n\n")365+ prefix.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366+ prefix.WriteString("\n")
367 }367 }
368 368
369 // Status message369 // Status message
370 if m.statusMessage != "" {370 if m.statusMessage != "" {
371- b.WriteString(t.Subtle.Render(m.statusMessage))371+ prefix.WriteString("\n")
372- b.WriteString("\n\n")372+ prefix.WriteString(t.Subtle.Render(m.statusMessage))
373- }373+ prefix.WriteString("\n")
374-
375- // Sessions list
376- if len(m.sessions) == 0 {
377- b.WriteString(t.Subtle.Render(" No saved sessions found."))
378- b.WriteString("\n")
379- } else {
380- b.WriteString(m.renderSessionsList())
381 }374 }
382 375
383- b.WriteString("\n")
384-
385 // Help376 // Help
386 helpView := m.help.View(m.keys)377 helpView := m.help.View(m.keys)
387- b.WriteString(helpView)
388 378
389- return b.String()379+ // Compute available height for list so the view never exceeds terminal height.
380+ // This prevents Bubble Tea from truncating the bottom of the UI when there
381+ // are many sessions.
382+ prefixH := lipgloss.Height(prefix.String())
383+ helpH := lipgloss.Height(helpView)
384+ listH := m.height - prefixH - 1 - helpH // one spacer line between list and help
385+ if listH < 1 {
386+ listH = 1
387+ }
388+
389+ listView := m.renderSessionsList(listH)
390+
391+ // Compose final output with a single spacer line above help.
392+ return strings.TrimRight(prefix.String(), "\n") + "\n" + listView + "\n" + helpView
390 }393 }
391 394
392 func (m SessionsModel) renderHeader() string {395 func (m SessionsModel) renderHeader() string {
@@ -416,23 +419,61 @@ func (m SessionsModel) renderHeader() string {
416 return icon + title + stats419 return icon + title + stats
417 }420 }
418 421
419-func (m SessionsModel) renderSessionsList() string {422+func (m SessionsModel) renderSessionsList(maxHeight int) string {
423+ t := m.theme
424+
425+ if maxHeight <= 0 {
426+ return ""
427+ }
428+ if len(m.sessions) == 0 {
429+ return t.Subtle.Render(" No saved sessions found.")
430+ }
431+
432+ // Determine starting index so the selected row stays visible.
433+ start := 0
434+ if len(m.sessions) > maxHeight {
435+ // Keep selection roughly centered, but always visible.
436+ start = m.selectedIdx - (maxHeight / 2)
437+ if start < 0 {
438+ start = 0
439+ }
440+ maxStart := len(m.sessions) - maxHeight
441+ if start > maxStart {
442+ start = maxStart
443+ }
444+ }
445+
420 var b strings.Builder446 var b strings.Builder
447+ remaining := maxHeight
421 448
422- for i, info := range m.sessions {449+ for i := start; i < len(m.sessions) && remaining > 0; i++ {
450+ info := m.sessions[i]
423 isSelected := i == m.selectedIdx451 isSelected := i == m.selectedIdx
424 isExpanded := i == m.expandedIdx452 isExpanded := i == m.expandedIdx
425 453
426 b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))454 b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
427- b.WriteString("\n")455+ remaining--
456+ if remaining <= 0 {
457+ break
458+ }
459+
460+ // Render expanded details, but never exceed available height.
461+ if isExpanded && info.State != nil && remaining > 0 {
462+ details := m.renderSessionDetails(info)
463+ details = truncateLines(details, remaining)
464+ if details != "" {
465+ b.WriteString("\n")
466+ b.WriteString(details)
467+ remaining -= lipgloss.Height(details)
468+ }
469+ }
428 470
429- // Render expanded details471+ if remaining > 0 {
430- if isExpanded && info.State != nil {472+ b.WriteString("\n")
431- b.WriteString(m.renderSessionDetails(info))
432 }473 }
433 }474 }
434 475
435- return b.String()476+ return strings.TrimRight(b.String(), "\n")
436 }477 }
437 478
438 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {479 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
@@ -509,7 +550,7 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
509 var b strings.Builder550 var b strings.Builder
510 551
511 if info.State == nil {552 if info.State == nil {
512- b.WriteString(t.Subtle.Render(" No state data available\n"))553+ b.WriteString(t.Subtle.Render(" No state data available"))
513 return b.String()554 return b.String()
514 }555 }
515 556
@@ -547,10 +588,20 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
547 m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)588 m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
548 }589 }
549 590
550- b.WriteString("\n")
551 return b.String()591 return b.String()
552 }592 }
553 593
594+func truncateLines(s string, maxLines int) string {
595+ if maxLines <= 0 || s == "" {
596+ return ""
597+ }
598+ lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
599+ if len(lines) <= maxLines {
600+ return strings.Join(lines, "\n")
601+ }
602+ return strings.Join(lines[:maxLines], "\n")
603+}
604+
554 func (m SessionsModel) renderPaneTree(605 func (m SessionsModel) renderPaneTree(
555 b *strings.Builder,606 b *strings.Builder,
556 ws *entity.WorkspaceSnapshot,607 ws *entity.WorkspaceSnapshot,

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 8

Tags 42

↑↓ moveenter openesc close