From 442b3c3df59ebf50f415a85c8589024fead9c29f Mon Sep 17 00:00:00 2001 From: Ian Date: Mon, 12 Oct 2026 12:33:37 -0400 Subject: [PATCH] fix(tui): give the targets box only rows the agents panel would get, and always count hidden targets --- .../interface/tui/internal/app/panels_test.go | 41 +++++++++++++++++++ strix/interface/tui/internal/app/setup.go | 19 ++++++--- strix/interface/tui/internal/app/view.go | 35 +++++++++------- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/strix/interface/tui/internal/app/panels_test.go b/strix/interface/tui/internal/app/panels_test.go index c7d871ca..4528271d 100644 --- a/strix/interface/tui/internal/app/panels_test.go +++ b/strix/interface/tui/internal/app/panels_test.go @@ -388,3 +388,44 @@ func TestTargetsBoxDropsOnShortTerminal(t *testing.T) { t.Fatalf("sidebar renders %d rows on a %d-row terminal", got, m.height) } } + +// The targets box only takes rows the agent tree would otherwise grow into, so +// the stats panel keeps the Caido URL and the other panels keep their heights. +func TestTargetsBoxNeverSqueezesPanels(t *testing.T) { + for height := 19; height <= 40; height++ { + m := panelsModel(t) + m.height = height + m.snapshot.Usage = map[string]any{"total_tokens": 1234, "cost": 0.5} + m.snapshot.CaidoURL = "http://127.0.0.1:8080" + m.resizeViewport() + stats, vuln, mcp, _ := m.sidebarHeights() + m.snapshot.Targets = []string{"a", "b", "c", "d", "e"} + m.snapshot.TargetCount = 5 + m.resizeViewport() + gotStats, gotVuln, gotMcp, _ := m.sidebarHeights() + if gotStats != stats || gotVuln != vuln || gotMcp != mcp { + t.Fatalf("height %d: targets squeezed panels: stats %d->%d findings %d->%d mcp %d->%d", + height, stats, gotStats, vuln, gotVuln, mcp, gotMcp) + } + _, sidebarWidth, _, _ := m.layout() + if got := lipgloss.Height(m.sidebarView(sidebarWidth, m.height)); got != m.height { + t.Fatalf("height %d: sidebar renders %d rows", height, got) + } + } +} + +func TestTargetsViewCountsTheTargetsItCannotShow(t *testing.T) { + m := panelsModel(t) + m.snapshot.Targets = []string{"a", "b", "c", "d", "e"} + m.snapshot.TargetCount = 5 + for rows, want := range map[int]string{ + 2: "Targets 5\n+5 more", + 3: "Targets 5\n▸ a\n+4 more", + 6: "Targets 5\n▸ a\n▸ b\n▸ c\n▸ d\n+1 more", + 9: "Targets 5\n▸ a\n▸ b\n▸ c\n▸ d\n+1 more", + } { + if got := ansi.Strip(m.targetsView(20, rows)); got != want { + t.Fatalf("%d rows:\n%s\nwant:\n%s", rows, got, want) + } + } +} diff --git a/strix/interface/tui/internal/app/setup.go b/strix/interface/tui/internal/app/setup.go index 24ed7473..9b1b17a9 100644 --- a/strix/interface/tui/internal/app/setup.go +++ b/strix/interface/tui/internal/app/setup.go @@ -421,7 +421,7 @@ func (m Model) setupComposer(width int) string { inner := max(1, width-4) body := m.highlightInputSelection(m.input.View()) body += "\n\n" + m.setupSummaryView(inner) - if targets := m.targetsView(inner); targets != "" { + if targets := m.targetsView(inner, maxTargetRows); targets != "" { body += "\n" + targets } return lipgloss.NewStyle().Width(max(1, width-2)).Padding(0, 1). @@ -454,15 +454,22 @@ func (m Model) setupSummaryView(width int) string { return truncate(strings.Join(chips, render.Dim().Render(" · ")), max(1, width)) } -// targetsView lists what the scan is pointed at, once anything is queued. -func (m Model) targetsView(width int) string { - if len(m.snapshot.Targets) == 0 { +// maxTargetRows fits the header, four targets and the count of the rest. +const maxTargetRows = 6 + +// targetsView lists what the scan is pointed at, once anything is queued, in at +// most maxRows rows. Targets that do not fit are counted on the last row. +func (m Model) targetsView(width, maxRows int) string { + if len(m.snapshot.Targets) == 0 || maxRows < 2 { return "" } - const visible = 4 total := max(m.snapshot.TargetCount, len(m.snapshot.Targets)) + visible := min(len(m.snapshot.Targets), maxTargetRows-2, maxRows-1) + if visible < total { + visible = min(visible, maxRows-2) + } rows := []string{render.Bold(green).Render("Targets") + render.Dim().Render(fmt.Sprintf(" %d", total))} - for _, target := range m.snapshot.Targets[:min(visible, len(m.snapshot.Targets))] { + for _, target := range m.snapshot.Targets[:visible] { rows = append(rows, render.Col(dim).Render("▸ ")+render.Col(white).Render(truncate(target, max(1, width-2)))) } if hidden := total - visible; hidden > 0 { diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index c30251b2..df4acaa2 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -549,7 +549,7 @@ func (m Model) sidebarView(width, height int) string { Border(lipgloss.RoundedBorder()). BorderForeground(dark). Padding(0, 1). - Render(fixedPanelBody(m.targetsView(width-4), width-4, targetsHeight-2))) + Render(m.targetsView(width-4, targetsHeight-2))) } agents := "" if agentHeight > 1 { @@ -601,6 +601,13 @@ func (m Model) sidebarView(width, height int) string { } func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight int) { + _, statsHeight, vulnHeight, mcpHeight, agentHeight = m.sidebarLayout() + return +} + +// sidebarLayout sizes the targets box and the panels. The targets box only +// takes rows no panel needs, so it never squeezes the panels below it. +func (m Model) sidebarLayout() (targetsHeight, statsHeight, vulnHeight, mcpHeight, agentHeight int) { // Measure the stats panel the way its box will render it: a long model name // wraps inside the sidebar, and counting only its newlines would size the // box short and push the whole frame past the bottom of the terminal. @@ -620,7 +627,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight h *int floor int }{{&statsHeight, 4}, {&mcpHeight, 5}, {&vulnHeight, 5}} { - over := m.sidebarTopHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height + over := m.viewerHeight() + statsHeight + vulnHeight + mcpHeight + agentHeight - m.height if over <= 0 { break } @@ -629,14 +636,22 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight } } for _, h := range []*int{&mcpHeight, &vulnHeight, &statsHeight} { - if m.sidebarTopHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height { + if m.viewerHeight()+statsHeight+vulnHeight+mcpHeight+agentHeight <= m.height { break } if *h > 1 { *h = 1 } } - spare := max(0, m.height-m.sidebarTopHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight) + spare := max(0, m.height-m.viewerHeight()-statsHeight-vulnHeight-mcpHeight-agentHeight) + // The box needs its border and two rows: the header and a target or the + // count of hidden ones. A zoomed panel keeps the whole sidebar. + if targets := m.targetsView(m.sidebarInnerWidth(), maxTargetRows); targets != "" && m.zoomedPanel == panelNone { + if fit := min(lipgloss.Height(targets)+2, spare); fit >= 4 { + targetsHeight = fit + spare -= fit + } + } switch { case m.zoomedPanel == panelFindings && vulnHeight > 1: vulnHeight += spare @@ -658,17 +673,9 @@ func (m Model) sidebarTopHeight() int { return m.viewerHeight() + m.targetsBoxHeight() } -// targetsBoxHeight drops the box on terminals too short to also fit the agents -// box, the stats box at its floor and squeezed findings and MCP headers. func (m Model) targetsBoxHeight() int { - if len(m.snapshot.Targets) == 0 { - return 0 - } - room := m.height - m.viewerHeight() - 5 - 4 - 2 - if room < 3 { - return 0 - } - return min(lipgloss.Height(m.targetsView(m.sidebarInnerWidth()))+2, room) + targetsHeight, _, _, _, _ := m.sidebarLayout() + return targetsHeight } func (m Model) sidebarInnerWidth() int {