From 14cec4283d6ce90260cca20010fcfc0d591574af Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 1 Jul 2026 21:02:45 -0400 Subject: [PATCH 1/3] feat(tui): persist bottom panel preference across restarts Save bottom_panel pref (logs/maint/none) to store on toggle. Restore on startup via InitialModel, same pattern as detail_open. --- internal/tui/data.go | 15 +++++++++++++++ internal/tui/tui.go | 12 +++++++++++- internal/tui/update.go | 2 ++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/tui/data.go b/internal/tui/data.go index 8e6249b..c9d4a8e 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -50,6 +50,21 @@ func writeCmd(op string, fn func() error) tea.Cmd { } } +func (m *Model) saveBottomPanelPref() tea.Cmd { + v := "logs" + switch m.bottomPanel { + case bottomNone: + v = "none" + case bottomMaint: + v = "maint" + } + st := m.store + ctx := m.ctx + return writeCmd("Save bottom panel preference", func() error { + return st.SetPreference(ctx, "bottom_panel", v) + }) +} + func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site { var groups, ungrouped []models.Site children := make(map[int][]models.Site) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0af7ea7..c8a7203 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -243,6 +243,16 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor detailPref, _ := s.GetPreference(ctx, "detail_open") + bp := bottomLogs + if bpPref, _ := s.GetPreference(ctx, "bottom_panel"); bpPref != "" { + switch bpPref { + case "none": + bp = bottomNone + case "maint": + bp = bottomMaint + } + } + return Model{ ctx: ctx, state: stateDashboard, @@ -257,7 +267,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor theme: theme, themeIndex: themeIdx, st: newStyles(theme), - bottomPanel: bottomLogs, + bottomPanel: bp, detailOpen: detailPref == "true", demoMode: os.Getenv("UPTOP_DEMO") == "1", version: version, diff --git a/internal/tui/update.go b/internal/tui/update.go index 0d47f04..16b464f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -662,6 +662,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focusedPanel = panelMaint } m.recalcLayout() + return m, m.saveBottomPanelPref() case "l": if m.bottomPanel == bottomLogs { m.bottomPanel = bottomNone @@ -671,6 +672,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focusedPanel = panelLogs } m.recalcLayout() + return m, m.saveBottomPanelPref() case "up", "k": if m.focusedPanel == panelDetail && m.detailOpen { m.detailScrollOffset-- -- 2.52.0 From d6ba7d9af8e3d2d8588f5888f6a70a443069e779 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Wed, 1 Jul 2026 21:04:39 -0400 Subject: [PATCH 2/3] feat(tui): add scrollbar gutter to titledPanelH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in scrollbar track on the right border edge when totalItems > bodyH. Thin track (│) with muted thumb (┃) showing viewport position. Monitors panel passes len(sites) to enable it. Detail and fullscreen panels pass 0 to opt out. Any panel can opt in via the totalItems param. --- internal/tui/panel.go | 61 +++++++++++++++++++++++++----- internal/tui/view_dashboard.go | 4 +- internal/tui/view_detail_inline.go | 2 +- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/internal/tui/panel.go b/internal/tui/panel.go index 4d90525..a593fec 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -6,7 +6,13 @@ import ( "github.com/charmbracelet/lipgloss" ) -func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string { +type scrollbar struct { + pos int + total int + visible int +} + +func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, sb scrollbar, focused bool) string { if height <= 0 { return m.titledPanel(title, content, width, focused) } @@ -64,21 +70,58 @@ func (m Model) titledPanelH(title, content, footer string, width, height, scroll } visible := contentLines[scrollOffset:end] - borderLine := func(line string) string { - return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│") + sbVisible := sb.visible + if sbVisible <= 0 { + sbVisible = bodyH + } + showScrollbar := sb.total > 0 && sb.total > sbVisible + var thumbStart, thumbEnd int + if showScrollbar { + thumbSize := bodyH * sbVisible / sb.total + if thumbSize < 1 { + thumbSize = 1 + } + scrollRange := sb.total - sbVisible + if scrollRange < 1 { + scrollRange = 1 + } + trackSpace := bodyH - thumbSize + thumbStart = sb.pos * trackSpace / scrollRange + if thumbStart < 0 { + thumbStart = 0 + } + thumbEnd = thumbStart + thumbSize + if thumbEnd > bodyH { + thumbEnd = bodyH + } + } + + scrollTrack := bc.Render("│") + scrollThumb := lipgloss.NewStyle().Foreground(m.theme.Muted).Render("┃") + + borderLine := func(line string, idx int) string { + rightBorder := bc.Render("│") + if showScrollbar && idx >= thumbStart && idx < thumbEnd { + rightBorder = scrollThumb + } else if showScrollbar { + rightBorder = scrollTrack + } + return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + rightBorder + } + emptyLine := func(idx int) string { + return borderLine(strings.Repeat(" ", innerW), idx) } - emptyLine := borderLine(strings.Repeat(" ", innerW)) var lines []string lines = append(lines, top) - for _, line := range visible { - lines = append(lines, borderLine(line)) + for i, line := range visible { + lines = append(lines, borderLine(line, i)) } - for len(lines) < height-1-len(footerLines) { - lines = append(lines, emptyLine) + for i := len(visible); len(lines) < height-1-len(footerLines); i++ { + lines = append(lines, emptyLine(i)) } for _, line := range footerLines { - lines = append(lines, borderLine(line)) + lines = append(lines, borderLine(line, -1)) } lines = append(lines, bottom) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index f653ec0..8ddcb1b 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -163,7 +163,7 @@ func (m Model) viewMonitorsLayout() string { monTargetH := m.maxTableRows + 5 monitors := m.viewSitesTab() - monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, m.focusedPanel == panelMonitors)) + monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, scrollbar{pos: m.tableOffset, total: len(m.sites), visible: m.maxTableRows}, m.focusedPanel == panelMonitors)) var topParts []string topParts = append(topParts, monPanel) @@ -181,7 +181,7 @@ func (m Model) viewMonitorsLayout() string { monHeight := lipgloss.Height(monPanel) detail := m.viewDetailInline(detailW-2, monHeight) footer := m.detailFooter(detailW - 2) - detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail)) + detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, scrollbar{}, m.focusedPanel == panelDetail)) topParts = append(topParts, detailPanel) } diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index 8ee610d..1eada95 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -75,7 +75,7 @@ func (m Model) viewDetailFullscreen() string { } return lipgloss.NewStyle().Padding(1, 2).Render( - m.titledPanelH(title, content, footer, availW, panelH, m.detailScrollOffset, true)) + m.titledPanelH(title, content, footer, availW, panelH, m.detailScrollOffset, scrollbar{}, true)) } func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string { -- 2.52.0 From 1cfa0571c83548ffe95e6be74a5122296b4bb09d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 2 Jul 2026 11:09:07 -0400 Subject: [PATCH 3/3] feat(tui): improve scrollbar visibility and extend to all panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use block characters (█/░) with accent coloring for scrollbar thumb/track instead of box-drawing chars that blend with panel borders. Auto-derive scrollbar from content overflow in titledPanelH. Add scrollbar to logs and maintenance bottom panels. --- internal/tui/panel.go | 7 +++++-- internal/tui/tab_logs_sidebar.go | 14 ++++++++++++++ internal/tui/view_dashboard.go | 12 ++++++++---- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/internal/tui/panel.go b/internal/tui/panel.go index a593fec..16e5fee 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -70,6 +70,9 @@ func (m Model) titledPanelH(title, content, footer string, width, height, scroll } visible := contentLines[scrollOffset:end] + if sb.total == 0 && len(contentLines) > bodyH { + sb = scrollbar{pos: scrollOffset, total: len(contentLines), visible: bodyH} + } sbVisible := sb.visible if sbVisible <= 0 { sbVisible = bodyH @@ -96,8 +99,8 @@ func (m Model) titledPanelH(title, content, footer string, width, height, scroll } } - scrollTrack := bc.Render("│") - scrollThumb := lipgloss.NewStyle().Foreground(m.theme.Muted).Render("┃") + scrollTrack := lipgloss.NewStyle().Foreground(m.theme.Border).Render("░") + scrollThumb := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("█") borderLine := func(line string, idx int) string { rightBorder := bc.Render("│") diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index dcd5da5..8113aad 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -72,6 +72,20 @@ func (m Model) viewLogsStrip(width, maxLines int) string { return style.Render(strings.Join(visible, "\n")) } +func (m Model) filteredLogCount() int { + count := 0 + for _, entry := range m.engine.GetLogs() { + if strings.TrimSpace(entry.Message) == "" { + continue + } + if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) { + continue + } + count++ + } + return count +} + func (m *Model) scrollLogs(delta int) { logs := m.engine.GetLogs() total := 0 diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 8ddcb1b..b284340 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -189,12 +189,16 @@ func (m Model) viewMonitorsLayout() string { switch m.bottomPanel { case bottomLogs: - logContent := m.viewLogsStrip(availW-2, logsStripHeight-2) - logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs)) + maxLines := logsStripHeight - 2 + logContent := m.viewLogsStrip(availW-2, maxLines) + totalLogs := m.filteredLogCount() + logPanel := m.zones.Mark("panel-logs", m.titledPanelH("Logs", logContent, "", availW, logsStripHeight, 0, scrollbar{pos: m.logScrollOffset, total: totalLogs, visible: maxLines}, m.focusedPanel == panelLogs)) return top + "\n" + logPanel case bottomMaint: - maintContent := m.viewMaintStrip(availW-2, logsStripHeight-2) - maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", maintContent, availW, m.focusedPanel == panelMaint)) + maxLines := logsStripHeight - 2 + maintContent := m.viewMaintStrip(availW-2, maxLines) + totalMaint := len(m.activeMaintWindows()) + maintPanel := m.zones.Mark("panel-maint", m.titledPanelH("Maint", maintContent, "", availW, logsStripHeight, 0, scrollbar{pos: 0, total: totalMaint, visible: maxLines}, m.focusedPanel == panelMaint)) return top + "\n" + maintPanel } return top -- 2.52.0