From d6e6011b352d14c9380fdbfd4525a08fe9608a2d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 21 Jul 2026 19:37:26 -0400 Subject: [PATCH] feat(tui): add vim-style navigation (gg, G, ctrl-u, ctrl-d) All scrollable panels now support gg (top), G (bottom), ctrl-u (half page up), and ctrl-d (half page down). Works across dashboard panels (monitors, detail, maint, logs), fullscreen logs, fullscreen detail, and settings. Uses a pendingG flag for the two-key gg sequence. --- internal/tui/data.go | 112 +++++++++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 2 + internal/tui/update.go | 102 +++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) diff --git a/internal/tui/data.go b/internal/tui/data.go index c9d4a8e..7bb0f73 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -169,6 +169,118 @@ func (m *Model) clampCursor() { } } +func (m *Model) detailCmdIfNeeded() tea.Cmd { + if m.focusedPanel == panelMonitors && m.detailOpen && m.cursor < len(m.sites) { + m.detailMode = detailDefault + m.detailScrollOffset = 0 + return m.loadDetailCmd(m.sites[m.cursor].ID) + } + return nil +} + +func (m *Model) jumpToTop() { + switch m.focusedPanel { + case panelDetail: + m.detailScrollOffset = 0 + case panelMaint: + m.maintCursor = 0 + case panelLogs: + m.logScrollOffset = 0 + default: + m.cursor = 0 + m.tableOffset = 0 + m.syncSelectedID() + } +} + +func (m *Model) jumpToBottom() { + switch m.focusedPanel { + case panelDetail: + m.detailScrollOffset = 9999 + case panelMaint: + windows := m.activeMaintWindows() + if len(windows) > 0 { + m.maintCursor = len(windows) - 1 + } + case panelLogs: + total := m.filteredLogCount() + if total > 0 { + m.logScrollOffset = total - 1 + } + default: + max := m.currentListLen() - 1 + if max >= 0 { + m.cursor = max + if m.cursor >= m.tableOffset+m.maxTableRows { + m.tableOffset = m.cursor - m.maxTableRows + 1 + } + m.syncSelectedID() + } + } +} + +func (m *Model) halfPageUp() { + half := m.maxTableRows / 2 + if half < 1 { + half = 1 + } + switch m.focusedPanel { + case panelDetail: + m.detailScrollOffset -= half + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + case panelMaint: + m.maintCursor -= half + if m.maintCursor < 0 { + m.maintCursor = 0 + } + case panelLogs: + m.scrollLogs(-half) + default: + m.cursor -= half + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + } + m.syncSelectedID() + } +} + +func (m *Model) halfPageDown() { + half := m.maxTableRows / 2 + if half < 1 { + half = 1 + } + switch m.focusedPanel { + case panelDetail: + m.detailScrollOffset += half + case panelMaint: + windows := m.activeMaintWindows() + m.maintCursor += half + if len(windows) > 0 && m.maintCursor >= len(windows) { + m.maintCursor = len(windows) - 1 + } + case panelLogs: + m.scrollLogs(half) + default: + max := m.currentListLen() - 1 + m.cursor += half + if m.cursor > max { + m.cursor = max + } + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= m.tableOffset+m.maxTableRows { + m.tableOffset = m.cursor - m.maxTableRows + 1 + } + m.syncSelectedID() + } +} + // loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the // UI goroutine. Each call bumps tabSeq and stamps the reply with it, so // handleTabData can drop out-of-order results from slower earlier loads. The diff --git a/internal/tui/tui.go b/internal/tui/tui.go index c8a7203..aa97b5b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -218,6 +218,8 @@ type Model struct { filterMode bool filterText string + pendingG bool + // demoMode renders a stable status dot instead of the animated pulse so // screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1. demoMode bool diff --git a/internal/tui/update.go b/internal/tui/update.go index 16b464f..bef9808 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -252,6 +252,17 @@ func (m *Model) testAlertCmd(id int, name string) tea.Cmd { func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + if msg.String() == "g" { + if m.pendingG { + m.pendingG = false + m.logViewport.GotoTop() + return m, nil + } + m.pendingG = true + return m, nil + } + m.pendingG = false + switch msg.String() { case "esc", "q": m.state = stateDashboard @@ -261,6 +272,12 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { case "f": m.logFilterImportant = !m.logFilterImportant m.refreshLogContent() + case "G": + m.logViewport.GotoBottom() + case "ctrl+u": + m.logViewport.ScrollUp(m.logViewport.Height / 2) + case "ctrl+d": + m.logViewport.ScrollDown(m.logViewport.Height / 2) case "up", "k": m.logViewport.ScrollUp(1) case "down", "j": @@ -286,6 +303,17 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + if msg.String() == "g" { + if m.pendingG { + m.pendingG = false + m.detailScrollOffset = 0 + return m, nil + } + m.pendingG = true + return m, nil + } + m.pendingG = false + switch msg.String() { case "esc", "q": m.state = stateDashboard @@ -323,6 +351,15 @@ func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.loadSLACmd(m.slaSiteID, idx) } } + case "G": + m.detailScrollOffset = 9999 + case "ctrl+u": + m.detailScrollOffset -= 5 + if m.detailScrollOffset < 0 { + m.detailScrollOffset = 0 + } + case "ctrl+d": + m.detailScrollOffset += 5 case "up", "k": m.detailScrollOffset-- if m.detailScrollOffset < 0 { @@ -544,7 +581,52 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "g" { + if m.pendingG { + m.pendingG = false + m.settingsCursor = 0 + m.settingsOffset = 0 + return m, nil + } + m.pendingG = true + return m, nil + } + m.pendingG = false + switch msg.String() { + case "G": + max := m.settingsListLen() - 1 + if max >= 0 { + m.settingsCursor = max + if m.settingsCursor >= m.settingsOffset+m.maxTableRows { + m.settingsOffset = m.settingsCursor - m.maxTableRows + 1 + } + } + case "ctrl+u": + half := m.maxTableRows / 2 + if half < 1 { + half = 1 + } + m.settingsCursor -= half + if m.settingsCursor < 0 { + m.settingsCursor = 0 + } + if m.settingsCursor < m.settingsOffset { + m.settingsOffset = m.settingsCursor + } + case "ctrl+d": + half := m.maxTableRows / 2 + if half < 1 { + half = 1 + } + max := m.settingsListLen() - 1 + m.settingsCursor += half + if m.settingsCursor > max { + m.settingsCursor = max + } + if m.settingsCursor >= m.settingsOffset+m.maxTableRows { + m.settingsOffset = m.settingsCursor - m.maxTableRows + 1 + } case "esc", "S": m.state = stateDashboard case "ctrl+c": @@ -635,7 +717,27 @@ func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "g" { + if m.pendingG { + m.pendingG = false + m.jumpToTop() + return m, m.detailCmdIfNeeded() + } + m.pendingG = true + return m, nil + } + m.pendingG = false + switch msg.String() { + case "G": + m.jumpToBottom() + return m, m.detailCmdIfNeeded() + case "ctrl+u": + m.halfPageUp() + return m, m.detailCmdIfNeeded() + case "ctrl+d": + m.halfPageDown() + return m, m.detailCmdIfNeeded() case "q": return m, tea.Quit case "/":