From faca0d4d1dd1599ad1bbe1eabd16af26346196bd Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Sun, 4 Oct 2026 01:18:19 +0000 Subject: [PATCH] fix(tui): pin the Model panel to the sidebar bottom and enlarge the show-sidebar button --- strix/interface/tui/internal/app/panels.go | 36 ++++++++- .../interface/tui/internal/app/panels_test.go | 74 +++++++++++++++---- strix/interface/tui/internal/app/view.go | 10 +-- 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/strix/interface/tui/internal/app/panels.go b/strix/interface/tui/internal/app/panels.go index 2517beee..7423ba1d 100644 --- a/strix/interface/tui/internal/app/panels.go +++ b/strix/interface/tui/internal/app/panels.go @@ -34,6 +34,9 @@ type panelRect struct { } func (m Model) panelShrunk(panel sidebarPanel) bool { + if panel == panelStats { + return false + } return m.collapsedPanels[panel] || (m.zoomedPanel != panelNone && m.zoomedPanel != panel) } @@ -70,9 +73,15 @@ func (m Model) sidebarPanels() []panelRect { rects = append(rects, panelRect{panelMcp, top, mcpHeight}) top += mcpHeight } + top += m.sidebarGap() return append(rects, panelRect{panelStats, top, statsHeight}) } +func (m Model) sidebarGap() int { + statsHeight, vulnHeight, mcpHeight, agentHeight := m.sidebarHeights() + return max(0, m.height-m.viewerHeight()-agentHeight-vulnHeight-mcpHeight-statsHeight) +} + func (m Model) panelAt(y int) (panelRect, bool) { for _, rect := range m.sidebarPanels() { if y >= rect.top && y < rect.top+rect.height { @@ -106,6 +115,9 @@ func (m Model) panelTitle(panel sidebarPanel) string { func (m Model) panelHeader(panel sidebarPanel, width int) string { style := lipgloss.NewStyle().Foreground(dim) + if panel == panelStats { + return truncate(style.Render(m.panelTitle(panel)), max(1, width)) + } glyph := panelZoomGlyph if m.zoomedPanel == panel { glyph = panelUnzoomGlyph @@ -116,7 +128,11 @@ func (m Model) panelHeader(panel sidebarPanel, width int) string { } func (m Model) collapsedPanelRow(panel sidebarPanel, width int) string { - label := lipgloss.NewStyle().Foreground(dim).Render(panelCollapsedGlyph + " " + m.panelTitle(panel)) + title := m.panelTitle(panel) + if panel != panelStats { + title = panelCollapsedGlyph + " " + title + } + label := lipgloss.NewStyle().Foreground(dim).Render(title) return " " + truncate(label, max(1, width-1)) } @@ -138,7 +154,9 @@ func (m Model) panelBox(panel sidebarPanel, body string, width, height int, focu const ( toggleButtonWidth = 3 - sidebarRailWidth = toggleButtonWidth + 1 + railButtonWidth = 5 + railButtonHeight = 3 + sidebarRailWidth = railButtonWidth + 1 ) var toggleButtonFill = lipgloss.Color("#262626") @@ -158,16 +176,24 @@ func (m Model) railVisible() bool { } func (m Model) sidebarRail(height int) string { + button := lipgloss.NewStyle(). + Background(toggleButtonFill). + Foreground(brightWhite). + Bold(true). + Width(railButtonWidth). + Height(railButtonHeight). + Align(lipgloss.Center, lipgloss.Center). + Render(sidebarShowGlyph) return lipgloss.NewStyle(). Width(sidebarRailWidth). Height(height). Align(lipgloss.Right). - Render(sidebarToggleButton(sidebarShowGlyph)) + Render(button) } func (m Model) toggleButtonHit(x, y int) bool { if m.railVisible() { - return y == 0 && x >= m.width-toggleButtonWidth + return y < railButtonHeight && x >= m.width-railButtonWidth } return y == 1 && x >= m.width-2-toggleButtonWidth && x < m.width-2 } @@ -220,6 +246,8 @@ func (m *Model) revealPanel(panel sidebarPanel) { func (m *Model) clickPanel(rect panelRect, x, y int) bool { switch { + case rect.panel == panelStats: + return false case rect.height <= 1 && !m.panelShrunk(rect.panel): m.togglePanelZoom(rect.panel) case rect.height <= 1: diff --git a/strix/interface/tui/internal/app/panels_test.go b/strix/interface/tui/internal/app/panels_test.go index c4d175d5..103cbd42 100644 --- a/strix/interface/tui/internal/app/panels_test.go +++ b/strix/interface/tui/internal/app/panels_test.go @@ -58,7 +58,7 @@ func TestSidebarPanelsRenderHeadersWithControls(t *testing.T) { m := panelsModel(t) _, sidebarWidth, _, _ := m.layout() view := ansi.Strip(m.sidebarView(sidebarWidth, m.height)) - for _, want := range []string{"▾ Agents (31)", "▾ Findings (1)", "▾ MCP (12)", "▾ Model", "⤢"} { + for _, want := range []string{"▾ Agents (31)", "▾ Findings (1)", "▾ MCP (12)", "Model", "⤢"} { if !strings.Contains(view, want) { t.Fatalf("sidebar missing %q:\n%s", want, view) } @@ -106,14 +106,14 @@ func TestClickingZoomGlyphGivesPanelTheSidebar(t *testing.T) { if m.zoomedPanel != panelMcp { t.Fatalf("zoom glyph click did not zoom the panel: %d", m.zoomedPanel) } - for _, other := range []sidebarPanel{panelAgents, panelFindings, panelStats} { + for _, other := range []sidebarPanel{panelAgents, panelFindings} { if got := panelRectOf(t, m, other).height; got != 1 { t.Fatalf("panel %d should shrink to its header while another is zoomed, got %d", other, got) } } zoomed := panelRectOf(t, m, panelMcp) rects := m.sidebarPanels() - if last := rects[len(rects)-1]; last.top+last.height != m.height || zoomed.height < m.height-10 { + if last := rects[len(rects)-1]; last.top+last.height != m.height || zoomed.height < m.height-12 { t.Fatalf("zoomed panel does not fill the sidebar: %+v screen=%d", rects, m.height) } view := ansi.Strip(m.sidebarView(sidebarWidth, m.height)) @@ -160,14 +160,17 @@ func TestToggleButtonsHideAndShowSidebar(t *testing.T) { } frame := ansi.Strip(m.View()) rows = strings.Split(frame, "\n") - if !strings.HasSuffix(rows[0], " « ") || strings.Contains(rows[1], "«") || lipgloss.Width(rows[0]) != m.width { - t.Fatalf("hidden sidebar should leave a one-row show button in the rail:\n%s", strings.Join(rows[:2], "\n")) + if !strings.HasSuffix(rows[1], " « ") || strings.Contains(rows[0], "«") || strings.Contains(rows[3], "«") || lipgloss.Width(rows[0]) != m.width { + t.Fatalf("hidden sidebar should leave a %d-row show button in the rail:\n%s", railButtonHeight, strings.Join(rows[:4], "\n")) + } + if !m.toggleButtonHit(m.width-railButtonWidth, railButtonHeight-1) || m.toggleButtonHit(m.width-railButtonWidth-1, 0) || m.toggleButtonHit(m.width-1, railButtonHeight) { + t.Fatalf("show button hit zone does not match the drawn button") } if strings.Contains(frame, "»") { t.Fatalf("hide button drawn while the sidebar is hidden") } - m = click(t, m, m.width-3, 0) + m = click(t, m, m.width-3, 2) if showSidebar, _, _, _ := m.layout(); !showSidebar { t.Fatalf("show button click did not bring the sidebar back") } @@ -245,17 +248,60 @@ func TestSidebarFitsShortTerminal(t *testing.T) { if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got > m.height { t.Fatalf("sidebar renders %d rows on a %d-row terminal", got, m.height) } - squeezed, ok := m.panelAt(last.top) - if !ok || squeezed.panel != panelStats || squeezed.height != 1 { - t.Fatalf("expected the stats panel squeezed to its header, got %+v", squeezed) + squeezed := panelRectOf(t, m, panelMcp) + if squeezed.height != 1 { + t.Fatalf("expected the MCP panel squeezed to its header, got %+v", squeezed) } - m = click(t, m, m.width-6, last.top) - if m.zoomedPanel != panelStats { + if last.panel != panelStats || last.height < 3 { + t.Fatalf("Model panel must keep its box on short terminals: %+v", last) + } + m = click(t, m, m.width-6, squeezed.top) + if m.zoomedPanel != panelMcp { t.Fatalf("clicking a squeezed header should zoom it, zoomed=%v", m.zoomedPanel) } - statsHeight, _, _, _ := m.sidebarHeights() - if statsHeight < 4 { - t.Fatalf("zoomed stats panel still has no room: %d", statsHeight) +} + +func TestModelPanelHasNoControlsAndNeverShrinks(t *testing.T) { + m := panelsModel(t) + _, sidebarWidth, _, _ := m.layout() + stats := panelRectOf(t, m, panelStats) + rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n") + if header := rows[stats.top+1]; strings.ContainsAny(header, "▾▸⤢⤡") || !strings.Contains(header, "Model") { + t.Fatalf("Model header should carry no controls: %q", header) + } + before := m.sidebarPanels() + m = click(t, m, m.width-3, stats.top+1) + m = click(t, m, m.width-10, stats.top+1) + if m.zoomedPanel != panelNone || len(m.collapsedPanels) != 0 { + t.Fatalf("clicks on the Model header changed panel state: zoom=%v collapsed=%v", m.zoomedPanel, m.collapsedPanels) + } + m = click(t, m, m.width-3, panelRectOf(t, m, panelAgents).top+1) + if m.zoomedPanel != panelAgents { + t.Fatalf("precondition: agents zoomed") + } + after := panelRectOf(t, m, panelStats) + if after.height != before[len(before)-1].height { + t.Fatalf("Model panel shrank under zoom: %+v -> %+v", before[len(before)-1], after) + } +} + +func TestModelPanelSticksToBottom(t *testing.T) { + m := panelsModel(t) + m = click(t, m, m.width-10, panelRectOf(t, m, panelAgents).top+1) + if !m.collapsedPanels[panelAgents] { + t.Fatalf("precondition: agents collapsed") + } + stats := panelRectOf(t, m, panelStats) + if stats.top+stats.height != m.height { + t.Fatalf("Model panel not at the bottom: %+v (height %d)", stats, m.height) + } + _, sidebarWidth, _, _ := m.layout() + rows := strings.Split(ansi.Strip(m.sidebarView(sidebarWidth, m.height)), "\n") + if len(rows) != m.height { + t.Fatalf("sidebar renders %d rows, want %d", len(rows), m.height) + } + if !strings.Contains(rows[stats.top+1], "Model") { + t.Fatalf("Model header not where its rect says:\n%s", strings.Join(rows[stats.top-2:], "\n")) } } diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index dd7ad091..b1167e8c 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -580,6 +580,9 @@ func (m Model) sidebarView(width, height int) string { } parts = append(parts, m.panelBox(panelMcp, roster, width, mcpHeight, m.focus == focusMcp)) } + if gap := m.sidebarGap(); gap > 0 { + parts = append(parts, lipgloss.NewStyle().Width(width).Height(gap).Render("")) + } stats := "" if statsHeight > 1 { stats = fixedPanelBody(lipgloss.NewStyle().Width(width-4).Render(m.statsView()), width-4, statsHeight-3) @@ -604,8 +607,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight mcpHeight = m.panelHeight(panelMcp, min(9, len(m.snapshot.Connections)+3)) } agentHeight = m.panelHeight(panelAgents, 4) - squeezable := []*int{&statsHeight, &mcpHeight, &vulnHeight} - for _, h := range squeezable { + for _, h := range []*int{&statsHeight, &mcpHeight, &vulnHeight} { over := m.viewerHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height if over <= 0 { break @@ -614,7 +616,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight *h -= min(over, *h-4) } } - for _, h := range squeezable { + for _, h := range []*int{&mcpHeight, &vulnHeight, &statsHeight} { if m.viewerHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height { break } @@ -628,8 +630,6 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight vulnHeight += spare case m.zoomedPanel == panelMcp && mcpHeight > 1: mcpHeight += spare - case m.zoomedPanel == panelStats && statsHeight > 1: - statsHeight += spare case agentHeight > 1: agentHeight += spare }