From 1d14f640f4014416141334be62a921805f58f36b Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 30 Jun 2026 20:46:43 -0400 Subject: [PATCH] feat(tui): pin detail panel footer keys at bottom of sidebar Footer keys (period selector, edit/history/SLA shortcuts, Esc) are now pinned at the bottom of the detail panel, always visible regardless of scroll position. Scrollable content fills the space between the header and the pinned footer. --- internal/tui/panel.go | 24 ++++++++++++---- internal/tui/view_dashboard.go | 3 +- internal/tui/view_detail_inline.go | 44 ++++++++++++++++++------------ 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/internal/tui/panel.go b/internal/tui/panel.go index 55a3291..4d90525 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -func (m Model) titledPanelH(title, content string, width, height, scrollOffset int, focused bool) string { +func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string { if height <= 0 { return m.titledPanel(title, content, width, focused) } @@ -40,7 +40,13 @@ func (m Model) titledPanelH(title, content string, width, height, scrollOffset i inner := contentStyle.Render(content) contentLines := strings.Split(inner, "\n") - bodyH := height - 2 + var footerLines []string + if footer != "" { + footerRendered := contentStyle.Render(footer) + footerLines = strings.Split(footerRendered, "\n") + } + + bodyH := height - 2 - len(footerLines) if bodyH < 1 { bodyH = 1 } @@ -58,13 +64,21 @@ func (m Model) titledPanelH(title, content string, width, height, scrollOffset i } visible := contentLines[scrollOffset:end] + borderLine := func(line string) string { + return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│") + } + emptyLine := borderLine(strings.Repeat(" ", innerW)) + var lines []string lines = append(lines, top) for _, line := range visible { - lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│")) + lines = append(lines, borderLine(line)) } - for len(lines) < height-1 { - lines = append(lines, bc.Render("│")+strings.Repeat(" ", innerW)+bc.Render("│")) + for len(lines) < height-1-len(footerLines) { + lines = append(lines, emptyLine) + } + for _, line := range footerLines { + lines = append(lines, borderLine(line)) } lines = append(lines, bottom) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 6b81089..bdcde86 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -192,7 +192,8 @@ func (m Model) viewMonitorsLayout() string { } monHeight := lipgloss.Height(monPanel) detail := m.viewDetailInline(detailW-2, monHeight) - detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail)) + footer := m.detailFooter(detailW - 2) + detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail)) topParts = append(topParts, detailPanel) } diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index ca5cc33..5c1ab94 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -143,8 +143,6 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid b.WriteString(" " + label.Render("No state changes") + "\n") } - b.WriteString("\n " + m.detailKeys() + "\n") - return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) } @@ -216,6 +214,33 @@ func (m Model) detailKeys() string { return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [Esc] Back") } +func (m Model) detailFooter(width int) string { + label := m.st.subtleStyle + var lines []string + + switch m.detailMode { + case detailSLA: + var keys []string + for i, p := range slaPeriods { + k := fmt.Sprintf("[%s] %s", p.key, p.label) + if i == m.slaPeriodIdx { + keys = append(keys, m.st.titleStyle.Render(k)) + } else { + keys = append(keys, label.Render(k)) + } + } + lines = append(lines, " "+strings.Join(keys, " ")) + lines = append(lines, " "+label.Render("[Esc] Back")) + case detailHistory: + lines = append(lines, " "+label.Render("[Esc] Back")) + default: + lines = append(lines, " "+m.detailKeys()) + } + + content := strings.Join(lines, "\n") + return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content) +} + func (m Model) fmtStatusWord(status string) string { switch status { case "DOWN", "SSL EXP": @@ -292,20 +317,6 @@ func (m Model) viewSLASidebar(width, _ int) string { } } - b.WriteString("\n") - - var keys []string - for i, p := range slaPeriods { - k := fmt.Sprintf("[%s] %s", p.key, p.label) - if i == m.slaPeriodIdx { - keys = append(keys, m.st.titleStyle.Render(k)) - } else { - keys = append(keys, label.Render(k)) - } - } - b.WriteString(" " + strings.Join(keys, " ") + "\n") - b.WriteString(" " + label.Render("[Esc] Back") + "\n") - return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) } @@ -368,7 +379,6 @@ func (m Model) viewHistorySidebar(width, _ int) string { statParts = append(statParts, "avg "+fmtDuration(avg)) } b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n") - b.WriteString(" " + label.Render("[Esc] Back") + "\n") return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) }