fix(tui): blank row under panel headers; no panel controls when Agents is the only panel

This commit is contained in:
Ahmed Allam 2026-10-04 01:40:25 +00:00 • committed by Ahmed Allam
parent 849671f288
commit d218c29ed1
5 changed files with 61 additions and 14 deletions

View file

@ -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() {

View file

@ -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)

View file

@ -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")
}
}

View file

@ -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} {

View file

@ -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