feat(tui): panel focus with click, scroll, and keyboard

Click any panel (Monitors, Logs, Detail) to focus it — accent border
follows focus. Mouse wheel scrolls the focused panel.

Keyboard: l toggles log panel focus. Arrow keys scroll logs when
focused, navigate monitors when not. Esc returns focus to monitors.

Log sidebar now supports scroll offset — tracks position across
renders without a viewport. Mouse wheel scrolls 3 lines, keyboard
scrolls 1.
This commit is contained in:
2026-06-20 19:44:35 -04:00
parent e5ac4a1fec
commit 7109b6fa1c
4 changed files with 110 additions and 18 deletions
+38 -6
View File
@@ -60,7 +60,7 @@ func (m Model) viewLogsSidebar(width, maxLines int) string {
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width) sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
var lines []string var all []string
for _, line := range logs { for _, line := range logs {
if strings.TrimSpace(line) == "" { if strings.TrimSpace(line) == "" {
continue continue
@@ -68,11 +68,43 @@ func (m Model) viewLogsSidebar(width, maxLines int) string {
if m.logFilterImportant && !isImportantLog(classifyLog(line)) { if m.logFilterImportant && !isImportantLog(classifyLog(line)) {
continue continue
} }
lines = append(lines, m.renderCompactLogLine(line, width)) all = append(all, m.renderCompactLogLine(line, width))
if maxLines > 0 && len(lines) >= maxLines {
break
}
} }
return sidebarStyle.Render(strings.Join(lines, "\n")) start := m.logScrollOffset
if start > len(all) {
start = len(all)
}
end := start + maxLines
if end > len(all) {
end = len(all)
}
visible := all[start:end]
return sidebarStyle.Render(strings.Join(visible, "\n"))
}
func (m *Model) scrollLogs(delta int) {
logs := m.engine.GetLogs()
total := 0
for _, line := range logs {
if strings.TrimSpace(line) == "" {
continue
}
if m.logFilterImportant && !isImportantLog(classifyLog(line)) {
continue
}
total++
}
m.logScrollOffset += delta
if m.logScrollOffset < 0 {
m.logScrollOffset = 0
}
if m.logScrollOffset > total-1 {
m.logScrollOffset = total - 1
}
if m.logScrollOffset < 0 {
m.logScrollOffset = 0
}
} }
+8
View File
@@ -96,6 +96,12 @@ const (
sectionUsers = 2 sectionUsers = 2
) )
const (
panelMonitors = 0
panelLogs = 1
panelDetail = 2
)
type sessionState int type sessionState int
const ( const (
@@ -124,6 +130,8 @@ type Model struct {
termWidth int termWidth int
termHeight int termHeight int
contentWidth int contentWidth int
focusedPanel int
logScrollOffset int
editID int editID int
editToken string editToken string
+52 -2
View File
@@ -274,6 +274,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
if msg.Button == tea.MouseButtonWheelUp {
m.scrollLogs(-3)
} else {
m.scrollLogs(3)
}
return m, nil
}
listLen := m.currentListLen() listLen := m.currentListLen()
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
if m.cursor > 0 { if m.cursor > 0 {
@@ -291,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
} }
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil return m, nil
} }
@@ -526,11 +538,26 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.currentTab == tabSettings { if m.currentTab == tabSettings {
m.switchSettingsSection(m.settingsSection - 1) m.switchSettingsSection(m.settingsSection - 1)
} }
case "right", "l": case "right":
if m.currentTab == tabSettings { if m.currentTab == tabSettings {
m.switchSettingsSection(m.settingsSection + 1) m.switchSettingsSection(m.settingsSection + 1)
} }
case "l":
switch m.currentTab {
case tabSettings:
m.switchSettingsSection(m.settingsSection + 1)
case tabMonitors:
if m.focusedPanel == panelLogs {
m.focusedPanel = panelMonitors
} else {
m.focusedPanel = panelLogs
}
}
case "up", "k": case "up", "k":
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.scrollLogs(-1)
return m, nil
}
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
if m.cursor < m.tableOffset { if m.cursor < m.tableOffset {
@@ -542,6 +569,10 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
} }
case "down", "j": case "down", "j":
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.scrollLogs(1)
return m, nil
}
max := m.currentListLen() - 1 max := m.currentListLen() - 1
if m.cursor < max { if m.cursor < max {
m.cursor++ m.cursor++
@@ -600,10 +631,14 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.state = stateAlertDetail m.state = stateAlertDetail
} }
case "esc": case "esc":
if m.currentTab == tabMonitors && m.detailOpen { if m.currentTab == tabMonitors {
if m.focusedPanel != panelMonitors {
m.focusedPanel = panelMonitors
} else if m.detailOpen {
m.detailOpen = false m.detailOpen = false
m.recalcLayout() m.recalcLayout()
} }
}
case "h": case "h":
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
site := m.sites[m.cursor] site := m.sites[m.cursor]
@@ -750,6 +785,18 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
} }
if m.currentTab == tabMonitors {
if m.zones.Get("panel-monitors").InBounds(msg) {
m.focusedPanel = panelMonitors
} else if m.zones.Get("panel-logs").InBounds(msg) {
m.focusedPanel = panelLogs
return m, nil
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
m.focusedPanel = panelDetail
return m, nil
}
}
prefix, listLen := m.currentZonePrefix() prefix, listLen := m.currentZonePrefix()
end := m.tableOffset + m.maxTableRows end := m.tableOffset + m.maxTableRows
if end > listLen { if end > listLen {
@@ -759,6 +806,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) { if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
m.cursor = i m.cursor = i
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil return m, nil
} }
} }
+10 -8
View File
@@ -159,9 +159,9 @@ func (m Model) viewDashboard() string {
rightW := availW - leftW rightW := availW - leftW
m.contentWidth = leftW - 2 m.contentWidth = leftW - 2
monitors := m.viewSitesTab() monitors := m.viewSitesTab()
monPanel := m.titledPanel("Monitors", monitors, leftW, !m.detailOpen) monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows) sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
logPanel := m.titledPanel("Logs", sidebarContent, rightW, false) logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel) top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
if m.detailOpen { if m.detailOpen {
site := "" site := ""
@@ -169,7 +169,7 @@ func (m Model) viewDashboard() string {
site = m.sites[m.cursor].Name site = m.sites[m.cursor].Name
} }
detail := m.viewDetailInline(availW - 2) detail := m.viewDetailInline(availW - 2)
detailPanel := m.titledPanel(site, detail, availW, true) detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
content = top + "\n" + detailPanel content = top + "\n" + detailPanel
} else { } else {
content = top content = top
@@ -178,14 +178,14 @@ func (m Model) viewDashboard() string {
m.contentWidth = m.termWidth - 2 m.contentWidth = m.termWidth - 2
monitors := m.viewSitesTab() monitors := m.viewSitesTab()
availW := m.termWidth - chromePadH availW := m.termWidth - chromePadH
monPanel := m.titledPanel("Monitors", monitors, availW, !m.detailOpen) monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
if m.detailOpen { if m.detailOpen {
site := "" site := ""
if m.cursor < len(m.sites) { if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name site = m.sites[m.cursor].Name
} }
detail := m.viewDetailInline(availW - 2) detail := m.viewDetailInline(availW - 2)
detailPanel := m.titledPanel(site, detail, availW, true) detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
content = monPanel + "\n" + detailPanel content = monPanel + "\n" + detailPanel
} else { } else {
content = monPanel content = monPanel
@@ -303,10 +303,12 @@ func (m Model) renderFooter(stats dashboardStats) string {
var keys string var keys string
switch m.currentTab { switch m.currentTab {
case tabMonitors: case tabMonitors:
if m.detailOpen { if m.focusedPanel == panelLogs {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [↑/↓]Select [T]Theme [q]Quit" keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
} else if m.detailOpen {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
} else { } else {
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
} }
case tabMaint: case tabMaint:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"