From d218c29ed1b8a41208ba3a6700c3776854467721 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sun, 4 Oct 2026 01:40:25 +0000 Subject: [PATCH] fix(tui): blank row under panel headers; no panel controls when Agents is the only panel --- strix/interface/tui/internal/app/agents.go | 2 +- strix/interface/tui/internal/app/panels.go | 13 +++++-- .../interface/tui/internal/app/panels_test.go | 37 +++++++++++++++++++ strix/interface/tui/internal/app/view.go | 21 ++++++----- .../tui/internal/app/vulnerabilities.go | 2 +- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/strix/interface/tui/internal/app/agents.go b/strix/interface/tui/internal/app/agents.go index 1b86f86c..b60300fa 100644 --- a/strix/interface/tui/internal/app/agents.go +++ b/strix/interface/tui/internal/app/agents.go @@ -221,7 +221,7 @@ func (m *Model) ensureAgentVisible() { func (m Model) agentPageSize() int { _, _, _, agentHeight := m.sidebarHeights() - return max(1, agentHeight-3) + return max(1, agentHeight-4) } func (m *Model) keepAgentSelectionInWindow() { diff --git a/strix/interface/tui/internal/app/panels.go b/strix/interface/tui/internal/app/panels.go index 88e1d985..86f0fac4 100644 --- a/strix/interface/tui/internal/app/panels.go +++ b/strix/interface/tui/internal/app/panels.go @@ -33,8 +33,12 @@ type panelRect struct { height int } +func (m Model) panelControls() bool { + return len(m.snapshot.Vulnerabilities) > 0 || len(m.snapshot.Connections) > 0 +} + func (m Model) panelShrunk(panel sidebarPanel) bool { - if panel == panelStats { + if panel == panelStats || !m.panelControls() { return false } return m.collapsedPanels[panel] || (m.zoomedPanel != panelNone && m.zoomedPanel != panel) @@ -115,6 +119,9 @@ func (m Model) panelTitle(panel sidebarPanel) string { func (m Model) panelHeader(panel sidebarPanel, width int) string { style := lipgloss.NewStyle().Foreground(dim) + if !m.panelControls() { + return truncate(style.Render(m.panelTitle(panel)), max(1, width)) + } glyph := panelZoomGlyph if m.zoomedPanel == panel { glyph = panelUnzoomGlyph @@ -145,7 +152,7 @@ func (m Model) panelBox(panel sidebarPanel, body string, width, height int, focu if panel != panelStats { content = m.panelHeader(panel, width-4) if body != "" { - content += "\n" + body + content += "\n\n" + body } } return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.RoundedBorder()). @@ -246,7 +253,7 @@ func (m *Model) revealPanel(panel sidebarPanel) { func (m *Model) clickPanel(rect panelRect, x, y int) bool { switch { - case rect.panel == panelStats: + case rect.panel == panelStats || !m.panelControls(): return false case rect.height <= 1 && !m.panelShrunk(rect.panel): m.togglePanelZoom(rect.panel) diff --git a/strix/interface/tui/internal/app/panels_test.go b/strix/interface/tui/internal/app/panels_test.go index a1947861..0e783e9e 100644 --- a/strix/interface/tui/internal/app/panels_test.go +++ b/strix/interface/tui/internal/app/panels_test.go @@ -318,3 +318,40 @@ func TestStatsPanelKeepsAssignedHeight(t *testing.T) { t.Fatalf("sidebar renders %d rows, want %d", got, m.height) } } + +func TestHeadersAreFollowedByABlankRow(t *testing.T) { + m := panelsModel(t) + _, sidebarWidth, _, _ := m.layout() + rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n") + for _, panel := range []sidebarPanel{panelAgents, panelFindings, panelMcp} { + rect := panelRectOf(t, m, panel) + header, blank, first := rows[rect.top+1], rows[rect.top+2], rows[rect.top+3] + if !strings.Contains(header, m.panelTitle(panel)) || strings.TrimSpace(strings.Trim(blank, "│")) != "" || strings.TrimSpace(strings.Trim(first, "│")) == "" { + t.Fatalf("panel %d should render header, blank row, content:\n%s", panel, strings.Join(rows[rect.top:rect.top+4], "\n")) + } + } +} + +func TestLonePanelHasNoControls(t *testing.T) { + m := panelsModel(t) + m.snapshot.Vulnerabilities = nil + m.snapshot.Connections = nil + _, sidebarWidth, _, _ := m.layout() + view := ansi.Strip(m.sidebarView(sidebarWidth, m.height)) + if strings.ContainsAny(view, "▾▸⤢⤡") || !strings.Contains(view, "Agents (31)") { + t.Fatalf("a lone Agents panel should carry no collapse or zoom controls:\n%s", view) + } + rect := panelRectOf(t, m, panelAgents) + m = click(t, m, m.width-3, rect.top+1) + m = click(t, m, m.width-10, rect.top+1) + if m.zoomedPanel != panelNone || len(m.collapsedPanels) != 0 || panelRectOf(t, m, panelAgents) != rect { + t.Fatalf("header clicks changed a lone panel: zoom=%v collapsed=%v", m.zoomedPanel, m.collapsedPanels) + } + if m.sidebarGap() != 0 { + t.Fatalf("lone Agents panel should take the spare rows, gap=%d", m.sidebarGap()) + } + m.snapshot.Connections = panelsModel(t).snapshot.Connections + if !strings.Contains(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "▾ Agents (31)") { + t.Fatalf("controls should return once a second panel exists") + } +} diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index 3a6ac6a3..a723919e 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -544,7 +544,7 @@ func (m Model) sidebarView(width, height int) string { Render(m.viewerBox(width - 4))} agents := "" if agentHeight > 1 { - agentRows := max(1, agentHeight-3) + agentRows := max(1, agentHeight-4) agents = withVerticalScrollbar( m.agentsView(max(1, width-5), agentRows), width-4, @@ -559,7 +559,7 @@ func (m Model) sidebarView(width, height int) string { if vulnHeight > 0 { findings := "" if vulnHeight > 1 { - vulnRows := max(1, vulnHeight-3) + vulnRows := max(1, vulnHeight-4) totalRows, offsetRows := m.vulnerabilityScrollRows() findings = withVerticalScrollbar( m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows), @@ -576,7 +576,7 @@ func (m Model) sidebarView(width, height int) string { if mcpHeight > 0 { roster := "" if mcpHeight > 1 { - roster = m.mcpConnectionsView(width-4, max(1, mcpHeight-3)) + roster = m.mcpConnectionsView(width-4, max(1, mcpHeight-4)) } parts = append(parts, m.panelBox(panelMcp, roster, width, mcpHeight, m.focus == focusMcp)) } @@ -598,22 +598,25 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.sidebarInnerWidth()).Render(m.statsView())) statsHeight = m.panelHeight(panelStats, min(15, statsRows+2)) if len(m.snapshot.Vulnerabilities) > 0 { - vulnHeight = m.panelHeight(panelFindings, min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+3)) + vulnHeight = m.panelHeight(panelFindings, min(13, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+4)) } // Header line + one line per connection + the box border (2). Capped so a // long roster cannot crowd out the agent tree; a roster past the cap scrolls // inside the panel. Absent entirely when the run has no MCP connections. if len(m.snapshot.Connections) > 0 { - mcpHeight = m.panelHeight(panelMcp, min(9, len(m.snapshot.Connections)+3)) + mcpHeight = m.panelHeight(panelMcp, min(10, len(m.snapshot.Connections)+4)) } - agentHeight = m.panelHeight(panelAgents, 4) - for _, h := range []*int{&statsHeight, &mcpHeight, &vulnHeight} { + agentHeight = m.panelHeight(panelAgents, 5) + for _, p := range []struct { + h *int + floor int + }{{&statsHeight, 4}, {&mcpHeight, 5}, {&vulnHeight, 5}} { over := m.viewerHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height if over <= 0 { break } - if *h > 4 { - *h -= min(over, *h-4) + if *p.h > p.floor { + *p.h -= min(over, *p.h-p.floor) } } for _, h := range []*int{&mcpHeight, &vulnHeight, &statsHeight} { diff --git a/strix/interface/tui/internal/app/vulnerabilities.go b/strix/interface/tui/internal/app/vulnerabilities.go index a4e1c6b8..66fc8117 100644 --- a/strix/interface/tui/internal/app/vulnerabilities.go +++ b/strix/interface/tui/internal/app/vulnerabilities.go @@ -135,7 +135,7 @@ func clampVulnerabilityOffset(offset, total, height int) int { func (m Model) vulnerabilityPageSize() int { _, vulnHeight, _, _ := m.sidebarHeights() - return max(1, vulnHeight-3) + return max(1, vulnHeight-4) } // vulnerabilityPageItems is how many findings a page step should move by: the