cli: fix sessions TUI height via bounded list rendering
- commit
- a86fc6300725f87d1ae52392d2c64c6490b425e1
- author
- brice · · 2026-02-10 06:25 UTC
- parent
- f6e095c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 353 | 353 | } | |
| 354 | 354 | ||
| 355 | 355 | t := m.theme | |
| 356 | - | var b strings.Builder | |
| 356 | + | var prefix strings.Builder | |
| 357 | 357 | ||
| 358 | 358 | // Header | |
| 359 | - | header := m.renderHeader() | |
| 360 | - | b.WriteString(header) | |
| 361 | - | b.WriteString("\n\n") | |
| 359 | + | prefix.WriteString(m.renderHeader()) | |
| 360 | + | prefix.WriteString("\n") | |
| 362 | 361 | ||
| 363 | 362 | // Error display | |
| 364 | 363 | 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") | |
| 367 | 367 | } | |
| 368 | 368 | ||
| 369 | 369 | // Status message | |
| 370 | 370 | 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") | |
| 381 | 374 | } | |
| 382 | 375 | ||
| 383 | - | b.WriteString("\n") | |
| 384 | - | ||
| 385 | 376 | // Help | |
| 386 | 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 | 395 | func (m SessionsModel) renderHeader() string { | |
| @@ -416,23 +419,61 @@ func (m SessionsModel) renderHeader() string { | |||
| 416 | 419 | 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 | 446 | 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 | 451 | isSelected := i == m.selectedIdx | |
| 424 | 452 | isExpanded := i == m.expandedIdx | |
| 425 | 453 | ||
| 426 | 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 details | |
| 430 | - | if isExpanded && info.State != nil { | |
| 431 | - | b.WriteString(m.renderSessionDetails(info)) | |
| 471 | + | if remaining > 0 { | |
| 472 | + | b.WriteString("\n") | |
| 432 | 473 | } | |
| 433 | 474 | } | |
| 434 | 475 | ||
| 435 | - | return b.String() | |
| 476 | + | return strings.TrimRight(b.String(), "\n") | |
| 436 | 477 | } | |
| 437 | 478 | ||
| 438 | 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 | 550 | var b strings.Builder | |
| 510 | 551 | ||
| 511 | 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 | 554 | return b.String() | |
| 514 | 555 | } | |
| 515 | 556 | ||
| @@ -547,10 +588,20 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string { | |||
| 547 | 588 | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | |
| 548 | 589 | } | |
| 549 | 590 | ||
| 550 | - | b.WriteString("\n") | |
| 551 | 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 | 605 | func (m SessionsModel) renderPaneTree( | |
| 555 | 606 | b *strings.Builder, | |
| 556 | 607 | ws *entity.WorkspaceSnapshot, | |
| @@ -353,40 +353,43 @@ func (m SessionsModel) View() string { | |||||
| 353 | } | 353 | } | ||
| 354 | 354 | ||||
| 355 | t := m.theme | 355 | t := m.theme | ||
| 356 | - | var b strings.Builder | 356 | + | var prefix strings.Builder |
| 357 | 357 | ||||
| 358 | // Header | 358 | // 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 display | 362 | // 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 message | 369 | // 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 | // Help | 376 | // 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 + stats | 419 | 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.Builder | 446 | 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.selectedIdx | 451 | isSelected := i == m.selectedIdx | ||
| 424 | isExpanded := i == m.expandedIdx | 452 | 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 details | 471 | + | 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.Builder | 550 | 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, | ||